feat: finding exceptions accepted risk resolution guidance v1 (spec 354) #425

Merged
ahmido merged 1 commits from 354-finding-exceptions-accepted-risk-resolution-guidance-v1 into platform-dev 2026-06-05 02:20:48 +00:00
28 changed files with 3148 additions and 63 deletions

View File

@ -25,6 +25,7 @@
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\OperateHub\OperateHubShell;
use App\Support\ResolutionGuidance\Adapters\AcceptedRiskResolutionAdapter;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -517,6 +518,27 @@ public function selectedFindingUrl(): ?string
);
}
/**
* @return array<string, mixed>|null
*/
public function selectedExceptionGuidance(): ?array
{
$record = $this->selectedFindingException();
if (! $record instanceof FindingException) {
return null;
}
/** @var AcceptedRiskResolutionAdapter $adapter */
$adapter = app(AcceptedRiskResolutionAdapter::class);
return $adapter->forQueue(
$record,
detailUrl: $this->selectedExceptionUrl(),
findingUrl: $this->selectedFindingUrl(),
);
}
public function clearSelectedException(): void
{
$this->selectedFindingExceptionId = null;

View File

@ -7,6 +7,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Resources\FindingExceptionResource\Pages;
use App\Models\FindingException;
use App\Models\FindingExceptionEvidenceReference;
@ -22,8 +23,10 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\NavigationScope;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\ResolutionGuidance\Adapters\AcceptedRiskResolutionAdapter;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -169,6 +172,15 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make(__('localization.accepted_risk_guidance.section'))
->schema([
ViewEntry::make('accepted_risk_guidance')
->hiddenLabel()
->view('filament.infolists.entries.accepted-risk-guidance')
->state(fn (FindingException $record): array => static::acceptedRiskGuidance($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Exception')
->schema([
TextEntry::make('status')
@ -283,7 +295,7 @@ public static function relatedContextEntries(FindingException $record): array
label: 'Approval queue',
value: 'Review pending exception requests',
secondaryValue: 'Return to the queue for the rest of this tenants governance workload.',
targetUrl: static::approvalQueueUrl($record->tenant),
targetUrl: static::approvalQueueUrl($record->tenant, $record, static::navigationContext()),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open approval queue',
@ -294,6 +306,31 @@ public static function relatedContextEntries(FindingException $record): array
return $entries;
}
/**
* @return array<string, mixed>
*/
public static function acceptedRiskGuidance(FindingException $record): array
{
$findingUrl = null;
if ($record->finding && $record->tenant instanceof ManagedEnvironment) {
$findingUrl = FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant);
}
$queueUrl = $record->tenant instanceof ManagedEnvironment && static::canAccessApprovalQueueForTenant($record->tenant)
? static::approvalQueueUrl($record->tenant, $record, static::navigationContext())
: null;
/** @var AcceptedRiskResolutionAdapter $adapter */
$adapter = app(AcceptedRiskResolutionAdapter::class);
return $adapter->forDetail(
$record,
queueUrl: $queueUrl,
findingUrl: $findingUrl,
);
}
public static function table(Table $table): Table
{
return $table
@ -670,7 +707,11 @@ public static function canAccessApprovalQueueForTenant(?ManagedEnvironment $tena
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
public static function approvalQueueUrl(?ManagedEnvironment $tenant = null): ?string
public static function approvalQueueUrl(
?ManagedEnvironment $tenant = null,
?FindingException $exception = null,
?CanonicalNavigationContext $navigationContext = null,
): ?string
{
$tenant ??= static::resolveTenantContextForCurrentPanel();
@ -678,8 +719,23 @@ public static function approvalQueueUrl(?ManagedEnvironment $tenant = null): ?st
return null;
}
return route('admin.finding-exceptions.open-queue', [
'environment' => (string) $tenant->external_id,
]);
$parameters = array_merge(
$navigationContext?->toQuery() ?? [],
array_filter([
'locale' => request()->query('locale'),
'environment_id' => (int) $tenant->getKey(),
'exception' => $exception?->getKey(),
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
return FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: $parameters,
);
}
private static function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
}

View File

@ -53,8 +53,15 @@ public function __invoke(Request $request, ManagedEnvironment $environment): Red
abort(404);
}
return redirect()->to(FindingExceptionsQueue::getUrl([
'tenant' => (string) $environment->external_id,
], panel: 'admin'));
$parameters = array_replace($request->query(), [
'environment_id' => (int) $environment->getKey(),
]);
unset($parameters['tenant']);
return redirect()->to(FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
));
}
}

View File

@ -0,0 +1,573 @@
<?php
declare(strict_types=1);
namespace App\Support\ResolutionGuidance\Adapters;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\ResolutionGuidance\ResolutionAction;
use App\Support\ResolutionGuidance\ResolutionCase;
use Illuminate\Support\Str;
final readonly class AcceptedRiskResolutionAdapter
{
public function __construct(
private FindingRiskGovernanceResolver $resolver,
) {}
/**
* @return array{
* key:string,
* scope:array<string, int|string>,
* severity:string,
* status:string,
* title:string,
* reason:string,
* impact:string,
* primary_action:array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* },
* secondary_actions:list<array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* }>,
* source_refs:list<array{type:string,id:int|string}>,
* evidence_refs:list<array{type:string,id:int|string}>,
* technical_details:array<string, string>
* }
*/
public function forQueue(
FindingException $exception,
?string $detailUrl = null,
?string $findingUrl = null,
): array {
return $this->buildCase($exception, 'queue', [
'detail_url' => $detailUrl,
'finding_url' => $findingUrl,
]);
}
/**
* @return array{
* key:string,
* scope:array<string, int|string>,
* severity:string,
* status:string,
* title:string,
* reason:string,
* impact:string,
* primary_action:array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* },
* secondary_actions:list<array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* }>,
* source_refs:list<array{type:string,id:int|string}>,
* evidence_refs:list<array{type:string,id:int|string}>,
* technical_details:array<string, string>
* }
*/
public function forDetail(
FindingException $exception,
?string $queueUrl = null,
?string $findingUrl = null,
): array {
return $this->buildCase($exception, 'detail', [
'queue_url' => $queueUrl,
'finding_url' => $findingUrl,
]);
}
/**
* @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions
* @return array{
* key:string,
* scope:array<string, int|string>,
* severity:string,
* status:string,
* title:string,
* reason:string,
* impact:string,
* primary_action:array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* },
* secondary_actions:list<array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* }>,
* source_refs:list<array{type:string,id:int|string}>,
* evidence_refs:list<array{type:string,id:int|string}>,
* technical_details:array<string, string>
* }
*/
private function buildCase(FindingException $exception, string $surface, array $actions): array
{
$finding = $exception->relationLoaded('finding')
? $exception->finding
: $exception->finding()->withSubjectDisplayName()->first();
$case = $this->classifyCase($exception, $finding);
$title = $this->title($case, $exception);
$reason = $this->reason($case, $exception, $finding);
$impact = $this->impact($case);
$caseKey = 'accepted_risk.'.$case;
$primaryAction = $this->primaryAction(
$caseKey,
$case,
$surface,
$actions,
$this->dominantNextStep($case, $exception, $finding),
);
return ResolutionCase::make(
key: $caseKey,
scope: array_filter([
'type' => 'finding_exception',
'surface' => $surface,
'workspace_id' => (int) $exception->workspace_id,
'managed_environment_id' => (int) $exception->managed_environment_id,
'finding_exception_id' => (int) $exception->getKey(),
'finding_id' => (int) $exception->finding_id,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
severity: $this->severity($case),
status: $this->statusLabel($case),
title: $title,
reason: $reason,
impact: $impact,
primaryAction: $primaryAction,
secondaryActions: $this->secondaryActions(
$caseKey,
$surface,
$actions,
is_string($primaryAction['url'] ?? null) ? $primaryAction['url'] : null,
),
sourceRefs: $this->sourceRefs($exception),
evidenceRefs: $this->evidenceRefs($exception),
technicalDetails: $this->technicalDetails($exception, $finding),
);
}
private function classifyCase(FindingException $exception, ?Finding $finding): string
{
$status = $this->resolver->resolveExceptionStatus($exception);
$storedValidity = (string) $exception->current_validity_state;
$validity = $finding instanceof Finding
? $this->resolver->resolveGovernanceValidity($finding, $exception)
: $storedValidity;
$isPendingRenewal = $exception->isPendingRenewal();
if ($finding instanceof Finding && $exception->requiresFreshDecisionForFinding($finding)) {
return 'fresh_decision_required';
}
if (! $isPendingRenewal && $status === FindingException::STATUS_PENDING) {
return 'pending';
}
if ($storedValidity === FindingException::VALIDITY_MISSING_SUPPORT || $validity === FindingException::VALIDITY_MISSING_SUPPORT) {
return 'missing_support';
}
if ($status === FindingException::STATUS_EXPIRED || $validity === FindingException::VALIDITY_EXPIRED) {
return 'expired';
}
if ($status === FindingException::STATUS_REVOKED || $validity === FindingException::VALIDITY_REVOKED) {
return 'revoked_or_rejected';
}
if ($status === FindingException::STATUS_REJECTED || $validity === FindingException::VALIDITY_REJECTED) {
return 'revoked_or_rejected';
}
if ($status === FindingException::STATUS_EXPIRING || $validity === FindingException::VALIDITY_EXPIRING) {
return 'expiring';
}
if ($status === FindingException::STATUS_PENDING) {
return 'pending';
}
if ($this->missingGovernanceFields($exception) !== []) {
return 'incomplete_governance';
}
return 'ready';
}
private function severity(string $case): string
{
return match ($case) {
'ready' => 'success',
'expiring', 'pending', 'fresh_decision_required', 'incomplete_governance' => 'warning',
default => 'danger',
};
}
private function statusLabel(string $case): string
{
return match ($case) {
'ready' => __('localization.accepted_risk_guidance.status_ready'),
'expiring', 'pending', 'fresh_decision_required', 'incomplete_governance' => __('localization.accepted_risk_guidance.status_action_required'),
default => __('localization.accepted_risk_guidance.status_blocked'),
};
}
private function title(string $case, FindingException $exception): string
{
return match ($case) {
'ready' => __('localization.accepted_risk_guidance.title_ready'),
'expiring' => __('localization.accepted_risk_guidance.title_expiring'),
'expired' => __('localization.accepted_risk_guidance.title_expired'),
'pending' => $exception->isPendingRenewal()
? __('localization.accepted_risk_guidance.title_pending_renewal')
: __('localization.accepted_risk_guidance.title_pending'),
'missing_support' => __('localization.accepted_risk_guidance.title_missing_support'),
'fresh_decision_required' => __('localization.accepted_risk_guidance.title_fresh_decision_required'),
'incomplete_governance' => __('localization.accepted_risk_guidance.title_incomplete_governance'),
default => (string) (
$this->resolver->resolveExceptionStatus($exception) === FindingException::STATUS_REJECTED
? __('localization.accepted_risk_guidance.title_rejected')
: __('localization.accepted_risk_guidance.title_revoked')
),
};
}
private function reason(string $case, FindingException $exception, ?Finding $finding): string
{
return match ($case) {
'ready' => __('localization.accepted_risk_guidance.reason_ready'),
'expiring' => __('localization.accepted_risk_guidance.reason_expiring'),
'expired' => __('localization.accepted_risk_guidance.reason_expired'),
'pending' => $exception->isPendingRenewal()
? __('localization.accepted_risk_guidance.reason_pending_renewal')
: __('localization.accepted_risk_guidance.reason_pending'),
'missing_support' => __('localization.accepted_risk_guidance.reason_missing_support'),
'fresh_decision_required' => __('localization.accepted_risk_guidance.reason_fresh_decision_required'),
'incomplete_governance' => __('localization.accepted_risk_guidance.reason_incomplete_governance', [
'fields' => implode(', ', $this->missingGovernanceFields($exception)),
]),
default => __('localization.accepted_risk_guidance.reason_revoked_or_rejected'),
};
}
private function impact(string $case): string
{
return match ($case) {
'ready' => __('localization.accepted_risk_guidance.impact_ready'),
'expiring' => __('localization.accepted_risk_guidance.impact_expiring'),
'expired' => __('localization.accepted_risk_guidance.impact_expired'),
'pending' => __('localization.accepted_risk_guidance.impact_pending'),
'missing_support' => __('localization.accepted_risk_guidance.impact_missing_support'),
'fresh_decision_required' => __('localization.accepted_risk_guidance.impact_fresh_decision_required'),
'incomplete_governance' => __('localization.accepted_risk_guidance.impact_incomplete_governance'),
default => __('localization.accepted_risk_guidance.impact_revoked_or_rejected'),
};
}
private function dominantNextStep(string $case, FindingException $exception, ?Finding $finding): string
{
return match ($case) {
'ready' => __('localization.accepted_risk_guidance.next_step_ready'),
'expiring' => __('localization.accepted_risk_guidance.next_step_expiring'),
'expired' => __('localization.accepted_risk_guidance.next_step_expired'),
'pending' => $exception->isPendingRenewal()
? __('localization.accepted_risk_guidance.next_step_pending_renewal')
: __('localization.accepted_risk_guidance.next_step_pending'),
'revoked_or_rejected' => __('localization.accepted_risk_guidance.next_step_revoked_or_rejected'),
'missing_support' => __('localization.accepted_risk_guidance.next_step_missing_support'),
'fresh_decision_required' => __('localization.accepted_risk_guidance.next_step_fresh_decision_required'),
'incomplete_governance' => __('localization.accepted_risk_guidance.next_step_incomplete_governance'),
};
}
/**
* @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions
* @return array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* }
*/
private function primaryAction(string $caseKey, string $case, string $surface, array $actions, string $label): array
{
$url = $surface === 'detail' && $case === 'pending'
? ($actions['queue_url'] ?? null)
: null;
if (is_string($url) && trim($url) !== '') {
return ResolutionAction::fromArray([
'key' => $caseKey.'.primary_action',
'label' => $label,
'url' => trim($url),
'icon' => 'heroicon-o-arrow-top-right-on-square',
'kind' => 'environment_link',
], $caseKey.'.primary_action', $label);
}
return ResolutionAction::none(
key: $caseKey.'.primary_action',
label: $label,
);
}
/**
* @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions
* @return list<array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* }>
*/
private function secondaryActions(string $caseKey, string $surface, array $actions, ?string $primaryUrl = null): array
{
$secondaryActions = $surface === 'queue'
? [
$this->navigationAction(
$caseKey.'.open_exception',
__('localization.accepted_risk_guidance.action_open_exception'),
$actions['detail_url'] ?? null,
),
$this->navigationAction(
$caseKey.'.open_finding',
__('localization.accepted_risk_guidance.action_open_finding'),
$actions['finding_url'] ?? null,
),
]
: [
$this->navigationAction(
$caseKey.'.open_finding',
__('localization.accepted_risk_guidance.action_open_finding'),
$actions['finding_url'] ?? null,
),
$this->navigationAction(
$caseKey.'.open_queue',
__('localization.accepted_risk_guidance.action_open_queue'),
$actions['queue_url'] ?? null,
),
];
return array_values(array_filter(
$secondaryActions,
static fn (mixed $action): bool => is_array($action)
&& (! is_string($primaryUrl) || $primaryUrl === '' || ($action['url'] ?? null) !== $primaryUrl),
));
}
/**
* @return array{
* key:string,
* label:string,
* type:string,
* url:?string,
* icon:string,
* kind:string,
* action_name:?string,
* capability:?string,
* requires_confirmation:bool,
* audit_event:?string,
* operation_run_type:?string,
* disabled_reason:?string
* }|null
*/
private function navigationAction(string $key, string $label, ?string $url): ?array
{
if (! is_string($url) || trim($url) === '') {
return null;
}
return ResolutionAction::fromArray([
'key' => $key,
'label' => $label,
'url' => trim($url),
'icon' => 'heroicon-o-arrow-top-right-on-square',
'kind' => 'environment_link',
], $key, $label);
}
/**
* @return list<array{type:string,id:int|string}>
*/
private function sourceRefs(FindingException $exception): array
{
$refs = [
[
'type' => 'finding_exception',
'id' => (int) $exception->getKey(),
],
[
'type' => 'finding',
'id' => (int) $exception->finding_id,
],
];
if (is_numeric($exception->current_decision_id)) {
$refs[] = [
'type' => 'finding_exception_decision',
'id' => (int) $exception->current_decision_id,
];
}
return $refs;
}
/**
* @return list<array{type:string,id:int|string}>
*/
private function evidenceRefs(FindingException $exception): array
{
$evidenceReferences = $exception->relationLoaded('evidenceReferences')
? $exception->evidenceReferences
: $exception->evidenceReferences()->get();
return $evidenceReferences
->map(fn ($reference): array => [
'type' => 'finding_exception_evidence_reference',
'id' => (int) $reference->getKey(),
])
->all();
}
/**
* @return array<string, string>
*/
private function technicalDetails(FindingException $exception, ?Finding $finding): array
{
$resolvedStatus = $this->resolver->resolveExceptionStatus($exception);
$resolvedValidity = $finding instanceof Finding
? (string) ($this->resolver->resolveGovernanceValidity($finding, $exception) ?? $exception->current_validity_state)
: (string) $exception->current_validity_state;
$displayValidity = (string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT
? FindingException::VALIDITY_MISSING_SUPPORT
: $resolvedValidity;
$decisionType = $exception->currentDecisionType();
$missingFields = $this->missingGovernanceFields($exception);
return array_filter([
__('localization.accepted_risk_guidance.detail_environment_label') => (string) ($exception->tenant?->name ?: __('localization.accepted_risk_guidance.detail_not_recorded_value')),
__('localization.accepted_risk_guidance.detail_status_label') => BadgeRenderer::label(BadgeDomain::FindingExceptionStatus)($resolvedStatus),
__('localization.accepted_risk_guidance.detail_validity_label') => BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity)($displayValidity),
__('localization.accepted_risk_guidance.detail_owner_label') => (string) ($exception->owner?->name ?: __('localization.accepted_risk_guidance.detail_missing_value')),
__('localization.accepted_risk_guidance.detail_review_due_label') => $exception->review_due_at?->toDayDateTimeString() ?? __('localization.accepted_risk_guidance.detail_missing_value'),
__('localization.accepted_risk_guidance.detail_expires_label') => $exception->expires_at?->toDayDateTimeString() ?? __('localization.accepted_risk_guidance.detail_not_recorded_value'),
__('localization.accepted_risk_guidance.detail_current_decision_label') => $decisionType !== null
? Str::headline($decisionType)
: __('localization.accepted_risk_guidance.detail_not_recorded_value'),
__('localization.accepted_risk_guidance.detail_request_reason_label') => filled($exception->request_reason)
? Str::limit((string) $exception->request_reason, 120)
: __('localization.accepted_risk_guidance.detail_missing_value'),
__('localization.accepted_risk_guidance.detail_missing_fields_label') => $missingFields !== []
? implode(', ', $missingFields)
: null,
], static fn (mixed $value): bool => is_string($value) && $value !== '');
}
/**
* @return list<string>
*/
private function missingGovernanceFields(FindingException $exception): array
{
$fields = [];
if (! is_numeric($exception->owner_user_id)) {
$fields[] = __('localization.accepted_risk_guidance.detail_owner_label');
}
if (! filled($exception->request_reason)) {
$fields[] = __('localization.accepted_risk_guidance.detail_request_reason_label');
}
if (! $exception->review_due_at instanceof \DateTimeInterface) {
$fields[] = __('localization.accepted_risk_guidance.detail_review_due_label');
}
return $fields;
}
}

View File

@ -424,6 +424,67 @@
'verification_in_progress_detail' => 'Läuft',
'verification_not_run_detail' => 'Nicht ausgeführt',
],
'accepted_risk_guidance' => [
'section' => 'Accepted-Risk-Leitfaden',
'status_ready' => 'Bereit',
'status_action_required' => 'Aktion erforderlich',
'status_blocked' => 'Blockiert',
'reason_label' => 'Grund',
'impact_label' => 'Auswirkung',
'primary_action_label' => 'Empfohlener nächster Schritt',
'review_focus_label' => 'Was zu prüfen ist',
'secondary_actions_label' => 'Verwandter Kontext',
'title_ready' => 'Accepted-Risk-Governance ist aktuell',
'title_expiring' => 'Das Accepted-Risk-Prüffenster läuft bald ab',
'title_expired' => 'Accepted-Risk-Governance ist abgelaufen',
'title_revoked' => 'Accepted-Risk-Governance wurde widerrufen',
'title_rejected' => 'Accepted-Risk-Governance wurde abgelehnt',
'title_pending' => 'Accepted-Risk-Anfrage wartet auf Prüfung',
'title_pending_renewal' => 'Accepted-Risk-Verlängerung wartet auf Prüfung',
'title_missing_support' => 'Accepted Risk ist erfasst, aber ohne gültige Governance-Unterstützung',
'title_fresh_decision_required' => 'Accepted-Risk-Entscheidung muss erneut geprüft werden',
'title_incomplete_governance' => 'Der Governance-Kontext des Accepted Risk ist unvollständig',
'reason_ready' => 'Der aktuelle Accepted-Risk-Eintrag hat weiterhin eine gültige Governance-Basis.',
'reason_expiring' => 'Das aktuelle Accepted-Risk-Governance-Fenster ist noch aktiv, läuft aber bald ab und muss geprüft werden.',
'reason_expired' => 'Das aktuelle Accepted-Risk-Governance-Fenster ist abgelaufen und bietet keine aktive Abdeckung mehr.',
'reason_pending' => 'Diese Accepted-Risk-Anfrage wartet noch auf ihre erste Governance-Entscheidung.',
'reason_pending_renewal' => 'Für das aktuelle Accepted-Risk-Fenster wurde eine Verlängerung angefragt und benötigt noch eine Entscheidung.',
'reason_revoked_or_rejected' => 'Die letzte Governance-Entscheidung bietet keine aktive Accepted-Risk-Abdeckung mehr.',
'reason_missing_support' => 'Dieser Ausnahme-Eintrag ist vorhanden, bietet derzeit aber keine gültige Governance-Unterstützung.',
'reason_fresh_decision_required' => 'Das verknüpfte Finding hat sich nach der früheren Ausnahmeentscheidung geändert, daher ist eine neue Entscheidung erforderlich.',
'reason_incomplete_governance' => 'Im aktuellen Ausnahme-Eintrag fehlen: :fields.',
'impact_ready' => 'Für diese Ausnahme ist derzeit keine dringende Governance-Nachverfolgung erforderlich.',
'impact_expiring' => 'Wenn das aktuelle Fenster nicht rechtzeitig geprüft wird, kann dieses Accepted Risk nicht länger als aktiv gesteuert gelten.',
'impact_expired' => 'Für dieses Accepted Risk besteht keine aktuelle Governance-Abdeckung mehr und es sollte nicht mehr als sicher gesteuert gelten.',
'impact_revoked_or_rejected' => 'Für dieses Accepted Risk besteht keine genehmigte Governance-Basis mehr und es sollte geprüft werden, bevor erneut darauf vertraut wird.',
'impact_pending' => 'Bis eine Entscheidung erfasst ist, begründet diese Ausnahme keine neue Accepted-Risk-Abdeckung.',
'impact_missing_support' => 'Auf dieses Accepted Risk sollte nicht als gesteuert vertraut werden, bis gültige Governance-Unterstützung wiederhergestellt oder das Finding formell in aktive Remediation zurückgeführt wurde.',
'impact_fresh_decision_required' => 'Das verknüpfte Finding hat sich nach der früheren Ausnahmeentscheidung geändert, daher sollte ohne Prüfung nicht weiter auf die alte Entscheidung vertraut werden.',
'impact_incomplete_governance' => 'Fehlende Verantwortlichkeit oder Review-Daten schwächen den Governance-Eintrag, auch wenn die Ausnahme noch vorhanden ist.',
'action_open_exception' => 'Ausnahmedetail öffnen',
'action_open_finding' => 'Finding öffnen',
'action_open_queue' => 'Freigabe-Queue öffnen',
'next_step_ready' => 'Es ist keine dringende Änderung nötig. Behalten Sie dieses Accepted Risk in der regulären Governance-Prüfung.',
'next_step_expiring' => 'Prüfen Sie das aktive Governance-Fenster, bevor es abläuft.',
'next_step_expired' => 'Prüfen Sie, ob dieses Accepted-Risk-Fenster verlängert werden sollte oder ob das Finding in aktive Remediation zurückkehren muss.',
'next_step_pending' => 'Prüfen Sie die ausstehende Ausnahmeanfrage und erfassen Sie eine Genehmigungs- oder Ablehnungsentscheidung.',
'next_step_pending_renewal' => 'Prüfen Sie die ausstehende Verlängerungsanfrage und entscheiden Sie, ob das Accepted-Risk-Fenster verlängert werden soll.',
'next_step_revoked_or_rejected' => 'Prüfen Sie, warum die Governance-Abdeckung beendet wurde, bevor erneut auf dieses Accepted Risk vertraut wird.',
'next_step_missing_support' => 'Prüfen Sie, ob gültige Governance-Unterstützung wiederhergestellt werden kann oder ob das Finding in aktive Remediation zurückkehren muss.',
'next_step_fresh_decision_required' => 'Prüfen Sie das geänderte Finding und erfassen Sie eine neue Accepted-Risk-Entscheidung, bevor Sie sich auf die frühere Governance verlassen.',
'next_step_incomplete_governance' => 'Vervollständigen Sie den fehlenden Governance-Kontext, bevor Sie sich auf dieses Accepted Risk verlassen.',
'detail_environment_label' => 'Umgebung',
'detail_status_label' => 'Lifecycle-Status',
'detail_validity_label' => 'Governance-Gültigkeit',
'detail_owner_label' => 'Owner',
'detail_request_reason_label' => 'Anfragebegründung',
'detail_review_due_label' => 'Review fällig',
'detail_expires_label' => 'Läuft ab',
'detail_current_decision_label' => 'Aktuelle Entscheidung',
'detail_missing_fields_label' => 'Fehlende Governance-Eingaben',
'detail_missing_value' => 'Fehlt',
'detail_not_recorded_value' => 'Nicht erfasst',
],
'review' => [
'reporting' => 'Berichte',
'customer_reviews' => 'Kundenreviews',

View File

@ -424,6 +424,67 @@
'verification_in_progress_detail' => 'In progress',
'verification_not_run_detail' => 'Not run',
],
'accepted_risk_guidance' => [
'section' => 'Accepted-risk guidance',
'status_ready' => 'Ready',
'status_action_required' => 'Action required',
'status_blocked' => 'Blocked',
'reason_label' => 'Reason',
'impact_label' => 'Impact',
'primary_action_label' => 'Recommended next step',
'review_focus_label' => 'What to review',
'secondary_actions_label' => 'Related context',
'title_ready' => 'Accepted-risk governance is current',
'title_expiring' => 'Accepted-risk review window is nearing expiry',
'title_expired' => 'Accepted-risk governance has expired',
'title_revoked' => 'Accepted-risk governance was revoked',
'title_rejected' => 'Accepted-risk governance was rejected',
'title_pending' => 'Accepted-risk request is pending review',
'title_pending_renewal' => 'Accepted-risk renewal is pending review',
'title_missing_support' => 'Accepted risk is on record without valid governance support',
'title_fresh_decision_required' => 'Accepted-risk decision must be reviewed again',
'title_incomplete_governance' => 'Accepted-risk governance context is incomplete',
'reason_ready' => 'The current accepted-risk record still has a valid governance basis.',
'reason_expiring' => 'The current accepted-risk governance window is still active, but it is nearing expiry and needs review.',
'reason_expired' => 'The current accepted-risk governance window has lapsed and no longer provides active coverage.',
'reason_pending' => 'This accepted-risk request is still waiting for its first governance decision.',
'reason_pending_renewal' => 'A renewal was requested for the current accepted-risk window and still needs a decision.',
'reason_revoked_or_rejected' => 'The latest governance decision no longer provides active accepted-risk coverage.',
'reason_missing_support' => 'This exception record is present, but it does not currently provide valid governance support.',
'reason_fresh_decision_required' => 'The linked finding changed after the earlier exception decision, so a fresh decision is required.',
'reason_incomplete_governance' => 'The current exception record is missing: :fields.',
'impact_ready' => 'No urgent governance follow-up is required on this exception right now.',
'impact_expiring' => 'If the current window is not reviewed in time, this accepted risk can no longer be treated as actively governed.',
'impact_expired' => 'This accepted risk no longer has current governance coverage and should not be treated as safely governed.',
'impact_revoked_or_rejected' => 'This accepted risk no longer has an approved governance basis and should be reviewed before it is relied on again.',
'impact_pending' => 'Until a decision is recorded, this exception does not establish new accepted-risk coverage.',
'impact_missing_support' => 'This accepted risk should not be relied on as governed until valid governance support is re-established or the finding is formally returned to active remediation.',
'impact_fresh_decision_required' => 'The linked finding changed after the earlier exception decision, so the previous decision should not be relied on without review.',
'impact_incomplete_governance' => 'Missing accountability or review data weakens the governance record even when the exception is still present.',
'action_open_exception' => 'Open exception detail',
'action_open_finding' => 'Open finding',
'action_open_queue' => 'Open approval queue',
'next_step_ready' => 'No urgent change is required. Keep this accepted risk under routine governance review.',
'next_step_expiring' => 'Review the active governance window before it lapses.',
'next_step_expired' => 'Review whether this accepted-risk window should be renewed or whether the finding must return to active remediation.',
'next_step_pending' => 'Review the pending exception request and record an approval or rejection decision.',
'next_step_pending_renewal' => 'Review the pending renewal request and decide whether the accepted-risk window should be renewed.',
'next_step_revoked_or_rejected' => 'Review why governance coverage ended before this accepted risk is relied on again.',
'next_step_missing_support' => 'Review whether valid governance support can be re-established, or whether the finding must return to active remediation.',
'next_step_fresh_decision_required' => 'Review the changed finding and record a fresh accepted-risk decision before relying on earlier governance.',
'next_step_incomplete_governance' => 'Complete the missing governance context before relying on this accepted risk.',
'detail_environment_label' => 'Environment',
'detail_status_label' => 'Lifecycle status',
'detail_validity_label' => 'Governance validity',
'detail_owner_label' => 'Owner',
'detail_request_reason_label' => 'Request reason',
'detail_review_due_label' => 'Review due',
'detail_expires_label' => 'Expires',
'detail_current_decision_label' => 'Current decision',
'detail_missing_fields_label' => 'Missing governance inputs',
'detail_missing_value' => 'Missing',
'detail_not_recorded_value' => 'Not recorded',
],
'review' => [
'reporting' => 'Reporting',
'customer_reviews' => 'Customer reviews',

View File

@ -0,0 +1,9 @@
@php
$guidance = $getState();
$guidance = is_array($guidance) ? $guidance : [];
@endphp
@include('filament.partials.accepted-risk-guidance-card', [
'guidance' => $guidance,
'inlinePrimaryAction' => false,
])

View File

@ -1,5 +1,6 @@
<x-filament-panels::page>
@php($selectedException = $this->selectedFindingException())
@php($selectedGuidance = $this->selectedExceptionGuidance())
@php($environmentFilterChip = $this->environmentFilterChip())
<x-filament::section>
@ -28,42 +29,32 @@
@if ($selectedException)
<x-filament::section heading="Focused review lane">
<x-slot name="description">
The selected exception defines the focused review context. Approval decisions appear only while the request is pending.
The selected exception defines the focused review context. The dominant guidance card explains what matters now before deeper decision history and evidence.
</x-slot>
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,26rem)]">
@include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
'selectedException' => $selectedException,
])
<div class="grid gap-4">
<div class="rounded-2xl border border-primary-200 bg-primary-50/80 p-4 shadow-sm dark:border-primary-500/30 dark:bg-primary-500/10">
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">
@if ($selectedException->isPending())
Decision lane
@else
Governance context
@endif
</div>
<div class="mt-2 text-sm text-primary-800 dark:text-primary-200">
@if ($selectedException->isPending())
Approve exception and Reject exception are the only promoted next steps while this request remains pending.
@else
This accepted-risk record is already active, so approval decisions are not shown here. Use Exit focused review or open the exception/finding detail for follow-up.
@endif
</div>
</div>
@if ($selectedGuidance)
@include('filament.partials.accepted-risk-guidance-card', [
'guidance' => $selectedGuidance,
'inlinePrimaryAction' => false,
])
@endif
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related records
Focused review controls
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Open exception detail and Open finding stay available for context, but they remain separate from approval or rejection decisions.
Existing review actions stay in the page header so approval, rejection, and navigation keep their current confirmation and authorization boundaries.
</div>
</div>
</div>
@include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
'selectedException' => $selectedException,
])
</div>
</x-filament::section>
@else

View File

@ -0,0 +1,165 @@
@php
$guidance = is_array($guidance ?? null) ? $guidance : [];
$primaryAction = is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : [];
$secondaryActions = is_array($guidance['secondary_actions'] ?? null) ? $guidance['secondary_actions'] : [];
$technicalDetails = is_array($guidance['technical_details'] ?? null) ? $guidance['technical_details'] : [];
$inlinePrimaryAction = (bool) ($inlinePrimaryAction ?? false);
$severity = (string) ($guidance['severity'] ?? 'warning');
$status = (string) ($guidance['status'] ?? '');
$title = (string) ($guidance['title'] ?? '');
$reason = (string) ($guidance['reason'] ?? '');
$impact = (string) ($guidance['impact'] ?? '');
$actionName = is_string($primaryAction['action_name'] ?? null) ? (string) $primaryAction['action_name'] : null;
$primaryActionUrl = is_string($primaryAction['url'] ?? null) ? (string) $primaryAction['url'] : null;
$primaryActionLabel = (string) ($primaryAction['label'] ?? '');
$primaryActionType = (string) ($primaryAction['type'] ?? '');
$primaryActionHeading = ($primaryActionType === 'none' || ($actionName === null && $primaryActionUrl === null))
? __('localization.accepted_risk_guidance.review_focus_label')
: __('localization.accepted_risk_guidance.primary_action_label');
[$badgeColor, $accentClasses] = match ($severity) {
'success' => [
'success',
'border-l-success-500 dark:border-l-success-400',
],
'danger' => [
'danger',
'border-l-danger-500 dark:border-l-danger-400',
],
default => [
'warning',
'border-l-warning-500 dark:border-l-warning-400',
],
};
@endphp
<div
class="rounded-xl border border-l-4 border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-5 {{ $accentClasses }}"
data-testid="accepted-risk-guidance-card"
data-guidance-key="{{ $guidance['key'] ?? '' }}"
>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
@if ($status !== '')
<x-filament::badge :color="$badgeColor" size="sm" data-testid="accepted-risk-guidance-status">
{{ $status }}
</x-filament::badge>
@endif
@if ($title !== '')
<h2 class="text-sm font-semibold text-gray-950 dark:text-white sm:text-base" data-testid="accepted-risk-guidance-title">
{{ $title }}
</h2>
@endif
</div>
@if ($reason !== '')
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ __('localization.accepted_risk_guidance.reason_label') }}
</div>
<p class="text-sm text-gray-800 dark:text-gray-100" data-testid="accepted-risk-guidance-reason">
{{ $reason }}
</p>
</div>
@endif
@if ($impact !== '')
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ __('localization.accepted_risk_guidance.impact_label') }}
</div>
<p class="text-sm text-gray-700 dark:text-gray-200" data-testid="accepted-risk-guidance-impact">
{{ $impact }}
</p>
</div>
@endif
@if ($technicalDetails !== [])
<dl class="grid gap-3 sm:grid-cols-2" data-testid="accepted-risk-guidance-details">
@foreach ($technicalDetails as $label => $value)
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $label }}
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $value }}
</dd>
</div>
@endforeach
</dl>
@endif
</div>
<div class="space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $primaryActionHeading }}
</div>
@if ($inlinePrimaryAction && $actionName !== null)
<x-filament::button
color="primary"
wire:click="mountAction('{{ $actionName }}')"
wire:loading.attr="disabled"
class="w-full justify-center whitespace-normal text-center"
data-testid="accepted-risk-guidance-primary-action"
>
{{ $primaryActionLabel }}
</x-filament::button>
@elseif ($primaryActionUrl !== null)
<x-filament::button
color="primary"
tag="a"
href="{{ $primaryActionUrl }}"
class="w-full justify-center whitespace-normal text-center"
data-testid="accepted-risk-guidance-primary-action"
>
{{ $primaryActionLabel }}
</x-filament::button>
@elseif ($primaryActionLabel !== '')
<div
class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm font-medium text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
data-testid="accepted-risk-guidance-primary-action"
>
{{ $primaryActionLabel }}
</div>
@endif
@if ($secondaryActions !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ __('localization.accepted_risk_guidance.secondary_actions_label') }}
</div>
<div class="flex flex-wrap gap-2">
@foreach ($secondaryActions as $secondaryAction)
@php
if (! is_array($secondaryAction)) {
continue;
}
$secondaryUrl = is_string($secondaryAction['url'] ?? null) ? (string) $secondaryAction['url'] : null;
$secondaryLabel = (string) ($secondaryAction['label'] ?? '');
@endphp
@if ($secondaryUrl !== null && $secondaryLabel !== '')
<x-filament::button
color="gray"
size="sm"
tag="a"
href="{{ $secondaryUrl }}"
class="max-w-full whitespace-normal text-center"
data-testid="accepted-risk-guidance-secondary-action"
>
{{ $secondaryLabel }}
</x-filament::button>
@endif
@endforeach
</div>
</div>
@endif
</div>
</div>
</div>

View File

@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Resources\FindingExceptionResource;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->browser()->timeout(30_000);
uses(RefreshDatabase::class);
function spec354BrowserScreenshot(string $name): string
{
return 'spec354-'.$name;
}
function spec354CopyBrowserScreenshot(string $name): void
{
$filename = spec354BrowserScreenshot($name).'.png';
$source = base_path('tests/Browser/Screenshots/'.$filename);
$targetDirectory = repo_path('specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/artifacts/screenshots');
if (! is_dir($targetDirectory)) {
@mkdir($targetDirectory, 0755, true);
}
if (! is_file($source)) {
$source = \Pest\Browser\Support\Screenshot::path($filename);
}
for ($attempt = 0; $attempt < 10 && ! is_file($source); $attempt++) {
usleep(100_000);
clearstatcache(true, $source);
}
if (is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) {
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$filename);
}
}
function spec354AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $tenant): void
{
$workspaceId = (int) $tenant->workspace_id;
$test->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $tenant->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenant->getKey(),
]);
setAdminPanelContext($tenant);
}
function spec354BrowserException(
ManagedEnvironment $tenant,
User $user,
array $findingAttributes = [],
array $exceptionAttributes = [],
?string $decisionType = FindingExceptionDecision::TYPE_APPROVED,
): FindingException {
$decisionMetadata = is_array($exceptionAttributes['decision_metadata'] ?? null)
? $exceptionAttributes['decision_metadata']
: [];
unset($exceptionAttributes['decision_metadata']);
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
], $findingAttributes));
$exception = FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'approved_by_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'request_reason' => 'Spec354 browser accepted-risk guidance',
'approval_reason' => 'Spec354 browser approval',
'requested_at' => now()->subDays(5),
'approved_at' => now()->subDays(4),
'effective_from' => now()->subDays(4),
'review_due_at' => now()->addDays(10),
'expires_at' => now()->addDays(30),
'evidence_summary' => ['reference_count' => 0],
], $exceptionAttributes));
if ($decisionType !== null) {
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => $decisionType,
'reason' => 'Spec354 browser decision',
'metadata' => $decisionMetadata,
'decided_at' => now()->subDays(4),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
}
return $exception->fresh(['finding', 'tenant', 'owner', 'currentDecision', 'decisions.actor', 'evidenceReferences']);
}
it('smokes expiring queue guidance', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$expiring = spec354BrowserException($tenant, $user, exceptionAttributes: [
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
]);
spec354AuthenticateBrowser($this, $user, $tenant);
visit(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'exception' => (int) $expiring->getKey(),
]))
->resize(1440, 1100)
->waitForText(__('localization.accepted_risk_guidance.title_expiring'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee(__('localization.accepted_risk_guidance.review_focus_label'))
->assertSee(__('localization.accepted_risk_guidance.next_step_expiring'))
->assertSee(__('localization.accepted_risk_guidance.action_open_exception'))
->screenshot(true, spec354BrowserScreenshot('ui-026-finding-exceptions-queue-guidance'));
spec354CopyBrowserScreenshot('ui-026-finding-exceptions-queue-guidance');
});
it('smokes expired queue guidance', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$expired = spec354BrowserException($tenant, $user, exceptionAttributes: [
'review_due_at' => now()->subDays(2),
'expires_at' => now()->subDay(),
]);
spec354AuthenticateBrowser($this, $user, $tenant);
visit(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'exception' => (int) $expired->getKey(),
]))
->waitForText(__('localization.accepted_risk_guidance.title_expired'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee(__('localization.accepted_risk_guidance.impact_expired'));
});
it('smokes pending-renewal queue guidance while governance remains non-lapsed', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$pendingRenewalValid = spec354BrowserException($tenant, $user, exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
'decision_metadata' => [
'previous_review_due_at' => now()->addDays(10)->toIso8601String(),
'previous_expires_at' => now()->addDays(30)->toIso8601String(),
],
], decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED);
spec354AuthenticateBrowser($this, $user, $tenant);
visit(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'exception' => (int) $pendingRenewalValid->getKey(),
]))
->waitForText(__('localization.accepted_risk_guidance.title_pending_renewal'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee(__('localization.accepted_risk_guidance.next_step_pending_renewal'));
});
it('smokes pending-renewal queue guidance when expired governance stays dominant', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$pendingRenewalExpired = spec354BrowserException($tenant, $user, exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
'decision_metadata' => [
'previous_review_due_at' => now()->subDays(2)->toIso8601String(),
'previous_expires_at' => now()->subDay()->toIso8601String(),
],
], decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED);
spec354AuthenticateBrowser($this, $user, $tenant);
visit(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'exception' => (int) $pendingRenewalExpired->getKey(),
]))
->waitForText(__('localization.accepted_risk_guidance.title_expired'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertDontSee(__('localization.accepted_risk_guidance.title_pending_renewal'));
});
it('smokes german queue localization without fake remediation copy', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$expiring = spec354BrowserException($tenant, $user, exceptionAttributes: [
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
]);
spec354AuthenticateBrowser($this, $user, $tenant);
visit(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'exception' => (int) $expiring->getKey(),
'locale' => 'de',
]))
->waitForText('Was zu prüfen ist')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Das aktuelle Accepted-Risk-Governance-Fenster ist noch aktiv, läuft aber bald ab und muss geprüft werden.')
->assertDontSee('The current accepted-risk governance window is still active, but it is nearing expiry and needs review.')
->assertDontSee('Fix accepted risk');
});
it('smokes detail guidance hierarchy and pending-renewal queue continuity', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$incomplete = spec354BrowserException($tenant, $user, exceptionAttributes: [
'owner_user_id' => null,
'request_reason' => '',
'review_due_at' => null,
]);
$pendingRenewalValid = spec354BrowserException($tenant, $user, exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
'decision_metadata' => [
'previous_review_due_at' => now()->addDays(10)->toIso8601String(),
'previous_expires_at' => now()->addDays(30)->toIso8601String(),
],
], decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED);
spec354AuthenticateBrowser($this, $user, $tenant);
visit(FindingExceptionResource::getUrl('view', ['record' => $incomplete], tenant: $tenant))
->resize(1440, 1100)
->waitForText(__('localization.accepted_risk_guidance.title_incomplete_governance'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee(__('localization.accepted_risk_guidance.detail_missing_fields_label'))
->assertSee('Renew exception')
->screenshot(true, spec354BrowserScreenshot('ui-036-exception-detail-guidance'));
spec354CopyBrowserScreenshot('ui-036-exception-detail-guidance');
visit(FindingExceptionResource::getUrl('view', ['record' => $pendingRenewalValid], tenant: $tenant))
->waitForText(__('localization.accepted_risk_guidance.title_pending_renewal'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee(__('localization.accepted_risk_guidance.primary_action_label'))
->assertSee(__('localization.accepted_risk_guidance.next_step_pending_renewal'))
->click(__('localization.accepted_risk_guidance.next_step_pending_renewal'))
->waitForText(__('localization.accepted_risk_guidance.title_pending_renewal'))
->assertSee($tenant->name);
});
it('smokes ready and missing-support detail semantics without fake remediation', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$ready = spec354BrowserException($tenant, $user);
$missingSupport = spec354BrowserException($tenant, $user, exceptionAttributes: [
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
]);
spec354AuthenticateBrowser($this, $user, $tenant);
visit(FindingExceptionResource::getUrl('view', ['record' => $ready], tenant: $tenant))
->resize(1440, 1100)
->waitForText(__('localization.accepted_risk_guidance.title_ready'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee(__('localization.accepted_risk_guidance.review_focus_label'))
->assertSee(__('localization.accepted_risk_guidance.impact_ready'))
->assertDontSee(__('localization.accepted_risk_guidance.primary_action_label'))
->assertDontSee(__('localization.accepted_risk_guidance.title_expired'));
visit(FindingExceptionResource::getUrl('view', ['record' => $missingSupport], tenant: $tenant))
->waitForText(__('localization.accepted_risk_guidance.title_missing_support'))
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee(__('localization.accepted_risk_guidance.review_focus_label'))
->assertSee(__('localization.accepted_risk_guidance.next_step_missing_support'))
->assertDontSee(__('localization.accepted_risk_guidance.primary_action_label'))
->assertDontSee('Fix accepted risk')
->assertDontSee('Resolve risk');
});

View File

@ -139,7 +139,7 @@
->get(route('admin.finding-exceptions.open-queue', ['environment' => (string) $tenant->external_id]))
->assertRedirect(
\App\Filament\Pages\Monitoring\FindingExceptionsQueue::getUrl([
'tenant' => (string) $tenant->external_id,
'environment_id' => (int) $tenant->getKey(),
], panel: 'admin')
);

View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function spec354DetailFixture(
string $role = 'owner',
array $findingAttributes = [],
array $exceptionAttributes = [],
?string $decisionType = FindingExceptionDecision::TYPE_APPROVED,
): array {
[$user, $tenant] = createUserWithTenant(role: $role);
$approver = \App\Models\User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
], $findingAttributes));
$exception = FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'approved_by_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'request_reason' => 'Spec354 detail request',
'approval_reason' => 'Spec354 detail approval',
'requested_at' => now()->subDays(6),
'approved_at' => now()->subDays(5),
'effective_from' => now()->subDays(5),
'review_due_at' => now()->addDays(10),
'expires_at' => now()->addDays(30),
'evidence_summary' => ['reference_count' => 0],
], $exceptionAttributes));
if ($decisionType !== null) {
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $approver->getKey(),
'decision_type' => $decisionType,
'reason' => 'Spec354 detail decision',
'metadata' => [],
'decided_at' => now()->subDays(5),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
}
test()->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
return [$user, $tenant, $finding->fresh(), $exception->fresh(['finding', 'tenant', 'owner', 'currentDecision', 'decisions.actor', 'evidenceReferences'])];
}
it('renders dominant guidance before decision history and keeps incomplete governance visible first', function (): void {
[, , , $exception] = spec354DetailFixture(exceptionAttributes: [
'owner_user_id' => null,
'request_reason' => '',
'review_due_at' => null,
]);
$component = Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
->assertOk()
->assertSee(__('localization.accepted_risk_guidance.title_incomplete_governance'))
->assertSee(__('localization.accepted_risk_guidance.detail_missing_fields_label'))
->assertActionVisible('renew_exception')
->assertActionVisible('revoke_exception');
$html = $component->html();
expect(strpos($html, __('localization.accepted_risk_guidance.title_incomplete_governance')))
->toBeLessThan(strpos($html, 'Decision history'));
});
it('keeps readonly detail semantics aligned while hiding manage actions', function (): void {
[$user, $tenant, , $exception] = spec354DetailFixture(role: 'readonly', exceptionAttributes: [
'status' => FindingException::STATUS_EXPIRING,
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
]);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
->assertOk()
->assertSee(__('localization.accepted_risk_guidance.title_expiring'))
->assertActionHidden('renew_exception')
->assertActionHidden('revoke_exception')
->assertDontSee(__('localization.accepted_risk_guidance.action_open_queue'));
});
it('keeps existing related context scoped and renders without outbound http', function (): void {
bindFailHardGraphClient();
[, $tenant, , $exception] = spec354DetailFixture();
assertNoOutboundHttp(function () use ($tenant, $exception): void {
$response = $this->get(FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant));
$response->assertSuccessful()
->assertSee(__('localization.accepted_risk_guidance.title_ready'))
->assertSee(__('localization.accepted_risk_guidance.action_open_finding'))
->assertSee(__('localization.accepted_risk_guidance.action_open_queue'));
});
});
it('routes detail guidance and related-context queue links through the explicit queue filter contract', function (): void {
[, $tenant, , $exception] = spec354DetailFixture();
$originalQuery = request()->query();
$context = CanonicalNavigationContext::forDecisionRegister(
canonicalRouteName: DecisionRegister::getRouteName(),
tenantId: (int) $tenant->getKey(),
backLinkUrl: DecisionRegister::getUrl(panel: 'admin', parameters: [
'managed_environment_id' => (string) $tenant->getKey(),
]),
);
request()->query->replace($context->toQuery());
$guidance = FindingExceptionResource::acceptedRiskGuidance($exception);
$relatedContext = FindingExceptionResource::relatedContextEntries($exception);
$guidanceQueueAction = collect($guidance['secondary_actions'])->firstWhere('key', 'accepted_risk.ready.open_queue');
$relatedQueueEntry = collect($relatedContext)->firstWhere('key', 'approval_queue');
expect($guidanceQueueAction)->toBeArray()
->and($relatedQueueEntry)->toBeArray()
->and((string) $guidanceQueueAction['url'])->toContain('environment_id='.(string) $tenant->getKey())
->and((string) $guidanceQueueAction['url'])->toContain('exception='.(string) $exception->getKey())
->and((string) $guidanceQueueAction['url'])->toContain('nav%5Bsource_surface%5D=governance.decision_register')
->and((string) $guidanceQueueAction['url'])->not->toContain('tenant=')
->and((string) $relatedQueueEntry['targetUrl'])->toContain('environment_id='.(string) $tenant->getKey())
->and((string) $relatedQueueEntry['targetUrl'])->toContain('exception='.(string) $exception->getKey())
->and((string) $relatedQueueEntry['targetUrl'])->not->toContain('tenant=');
parse_str((string) parse_url((string) $guidanceQueueAction['url'], PHP_URL_QUERY), $guidanceQuery);
parse_str((string) parse_url((string) $relatedQueueEntry['targetUrl'], PHP_URL_QUERY), $relatedQuery);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::withQueryParams($guidanceQuery)
->test(FindingExceptionsQueue::class)
->assertActionVisible('view_tenant_register')
->assertSet('selectedFindingExceptionId', (int) $exception->getKey());
Livewire::withQueryParams($relatedQuery)
->test(FindingExceptionsQueue::class)
->assertActionVisible('view_tenant_register')
->assertSet('selectedFindingExceptionId', (int) $exception->getKey());
request()->query->replace($originalQuery);
});
it('promotes pending detail guidance to a real approval-queue affordance', function (): void {
[, $tenant, , $exception] = spec354DetailFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
],
decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
);
$guidance = FindingExceptionResource::acceptedRiskGuidance($exception);
expect($guidance['title'])->toBe(__('localization.accepted_risk_guidance.title_pending_renewal'))
->and($guidance['primary_action']['url'])->toBeString()
->and((string) $guidance['primary_action']['url'])->toContain('environment_id='.(string) $tenant->getKey())
->and((string) $guidance['primary_action']['url'])->toContain('exception='.(string) $exception->getKey())
->and($guidance['primary_action']['type'])->toBe('navigation');
});

View File

@ -71,8 +71,9 @@
->test(FindingExceptionsQueue::class)
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
->assertSee('Focused review lane')
->assertSee('Decision lane')
->assertSee('Related records')
->assertSee(__('localization.accepted_risk_guidance.review_focus_label'))
->assertSee(__('localization.accepted_risk_guidance.next_step_pending'))
->assertSee('Focused review controls')
->assertDontSee('Quiet monitoring mode')
->assertActionVisible('clear_selected_exception')
->assertActionVisible('approve_selected_exception')

View File

@ -26,7 +26,7 @@
$makeException = function (\App\Models\ManagedEnvironment $tenant, array $attributes): FindingException {
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$finding = Finding::factory()->for($tenant)->riskAccepted()->create();
return FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
@ -104,8 +104,9 @@
->assertSee('Expiring')
->assertSee($tenantA->name)
->assertSee('Focused review lane')
->assertSee('Governance context')
->assertSee('This accepted-risk record is already active')
->assertSee(__('localization.accepted_risk_guidance.review_focus_label'))
->assertSee(__('localization.accepted_risk_guidance.title_expiring'))
->assertSee(__('localization.accepted_risk_guidance.next_step_expiring'))
->assertSee('Open exception detail')
->assertDontSee('This exception is no longer decision-ready');
});

View File

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\ManagedEnvironment;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function spec354QueueEnvironment(
string $userRole = 'owner',
string $workspaceRole = 'manager',
): array {
[$user, $tenant] = createUserWithTenant(role: $userRole, workspaceRole: $workspaceRole);
test()->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
return [$user, $tenant];
}
function spec354QueueException(
ManagedEnvironment $tenant,
\App\Models\User $user,
array $findingAttributes = [],
array $exceptionAttributes = [],
): FindingException {
$decisionType = (string) ($exceptionAttributes['decision_type'] ?? \App\Models\FindingExceptionDecision::TYPE_APPROVED);
$decisionMetadata = is_array($exceptionAttributes['decision_metadata'] ?? null)
? $exceptionAttributes['decision_metadata']
: [];
unset($exceptionAttributes['decision_type']);
unset($exceptionAttributes['decision_metadata']);
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
], $findingAttributes));
$exception = FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'approved_by_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'request_reason' => 'Spec354 queue guidance request',
'approval_reason' => 'Spec354 queue approval',
'requested_at' => now()->subDays(5),
'approved_at' => now()->subDays(4),
'effective_from' => now()->subDays(4),
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
'evidence_summary' => ['reference_count' => 0],
], $exceptionAttributes));
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => $decisionType,
'reason' => 'Spec354 queue guidance decision',
'metadata' => $decisionMetadata,
'decided_at' => now()->subDays(4),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
return $exception->fresh(['finding', 'tenant', 'owner', 'requester', 'currentDecision', 'decisions.actor', 'evidenceReferences']);
}
it('shows one dominant expiring guidance case with repo-backed secondary actions only', function (): void {
[$user, $tenant] = spec354QueueEnvironment();
$expiring = spec354QueueException($tenant, $user);
Livewire::withQueryParams([
'exception' => (int) $expiring->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertSet('selectedFindingExceptionId', (int) $expiring->getKey())
->assertSee('data-testid="accepted-risk-guidance-card"', false)
->assertSee(__('localization.accepted_risk_guidance.title_expiring'))
->assertSee(__('localization.accepted_risk_guidance.impact_expiring'))
->assertSee(__('localization.accepted_risk_guidance.action_open_exception'))
->assertSee(__('localization.accepted_risk_guidance.action_open_finding'))
->assertSee(__('localization.accepted_risk_guidance.detail_owner_label'))
->assertDontSee('Fix provider')
->assertDontSee('Grant permissions automatically');
});
it('preserves governance inbox continuity on queue guidance links', function (): void {
[$user, $tenant] = spec354QueueEnvironment();
$exception = spec354QueueException($tenant, $user);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'finding_exceptions',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'environment_id' => (string) $tenant->getKey(),
'family' => 'finding_exceptions',
]),
);
$component = Livewire::withQueryParams(array_replace($context->toQuery(), [
'environment_id' => (int) $tenant->getKey(),
'exception' => (int) $exception->getKey(),
]))
->test(FindingExceptionsQueue::class)
->assertSee(__('localization.accepted_risk_guidance.action_open_exception'))
->assertSee(__('localization.accepted_risk_guidance.action_open_finding'));
$guidance = $component->instance()->selectedExceptionGuidance();
$secondaryActions = collect(is_array($guidance['secondary_actions'] ?? null) ? $guidance['secondary_actions'] : []);
$openExceptionAction = $secondaryActions->firstWhere('key', 'accepted_risk.expiring.open_exception');
expect($openExceptionAction)->toBeArray()
->and((string) $openExceptionAction['url'])
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions');
});
it('keeps queue access workspace scoped and preserves explicit environment filter semantics', function (): void {
[$user, $tenant] = spec354QueueEnvironment();
$otherWorkspaceTenant = ManagedEnvironment::factory()->create();
spec354QueueException($tenant, $user);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'environment_id' => (int) $otherWorkspaceTenant->getKey(),
]))
->assertNotFound();
});
it('keeps approve and reject safety intact while rendering guidance without outbound http', function (): void {
bindFailHardGraphClient();
[$user, $tenant] = spec354QueueEnvironment();
$pending = spec354QueueException($tenant, $user, exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'approved_by_user_id' => null,
'approved_at' => null,
'effective_from' => null,
'approval_reason' => null,
'decision_type' => \App\Models\FindingExceptionDecision::TYPE_REQUESTED,
]);
assertNoOutboundHttp(function () use ($pending): void {
Livewire::withQueryParams([
'exception' => (int) $pending->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertSee(__('localization.accepted_risk_guidance.title_pending'))
->assertActionVisible('approve_selected_exception')
->assertActionVisible('reject_selected_exception')
->mountAction('approve_selected_exception')
->callMountedAction()
->assertHasActionErrors(['approval_reason']);
Livewire::withQueryParams([
'exception' => (int) $pending->getKey(),
])
->test(FindingExceptionsQueue::class)
->mountAction('reject_selected_exception')
->callMountedAction()
->assertHasActionErrors(['rejection_reason']);
});
});
it('keeps expired and expiring carried-over governance dominant over pending renewal on the queue', function (): void {
[$user, $tenant] = spec354QueueEnvironment();
$expiredRenewal = spec354QueueException($tenant, $user, exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
'decision_type' => \App\Models\FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
'decision_metadata' => [
'previous_review_due_at' => now()->subDays(2)->toIso8601String(),
'previous_expires_at' => now()->subDay()->toIso8601String(),
],
]);
$expiringRenewal = spec354QueueException($tenant, $user, exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
'decision_type' => \App\Models\FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
'decision_metadata' => [
'previous_review_due_at' => now()->addDay()->toIso8601String(),
'previous_expires_at' => now()->addDays(2)->toIso8601String(),
],
]);
Livewire::withQueryParams([
'exception' => (int) $expiredRenewal->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertSee(__('localization.accepted_risk_guidance.title_expired'));
Livewire::withQueryParams([
'exception' => (int) $expiringRenewal->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertSee(__('localization.accepted_risk_guidance.title_expiring'))
->assertDontSee(__('localization.accepted_risk_guidance.title_pending_renewal'));
});
it('renders localized dominant queue guidance copy for german locale', function (): void {
$originalLocale = app()->getLocale();
[$user, $tenant] = spec354QueueEnvironment();
$expiring = spec354QueueException($tenant, $user);
app()->setLocale('de');
Livewire::withQueryParams([
'exception' => (int) $expiring->getKey(),
])
->test(FindingExceptionsQueue::class)
->assertSee(__('localization.accepted_risk_guidance.reason_expiring'))
->assertSee(__('localization.accepted_risk_guidance.impact_expiring'))
->assertDontSee('The current accepted-risk governance window is still active, but it is nearing expiry and needs review.');
app()->setLocale($originalLocale);
});

View File

@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Support\ResolutionGuidance\Adapters\AcceptedRiskResolutionAdapter;
use App\Support\ResolutionGuidance\ResolutionAction;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function spec354AdapterFixture(
array $findingAttributes = [],
array $exceptionAttributes = [],
?string $decisionType = FindingExceptionDecision::TYPE_APPROVED,
array $decisionMetadata = [],
): array {
[$owner, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$approver = \App\Models\User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
], $findingAttributes));
$exception = FindingException::query()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $owner->getKey(),
'owner_user_id' => (int) $owner->getKey(),
'approved_by_user_id' => (int) $approver->getKey(),
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'request_reason' => 'Temporary compensating control is still in place.',
'approval_reason' => 'Accepted until scheduled remediation is complete.',
'requested_at' => now()->subDays(10),
'approved_at' => now()->subDays(9),
'effective_from' => now()->subDays(9),
'review_due_at' => now()->addDays(14),
'expires_at' => now()->addDays(30),
'evidence_summary' => ['reference_count' => 0],
], $exceptionAttributes));
if ($decisionType !== null) {
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $approver->getKey(),
'decision_type' => $decisionType,
'reason' => 'Spec354 adapter test decision.',
'metadata' => $decisionMetadata,
'decided_at' => now()->subDays(9),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
}
return [$tenant, $finding->fresh(), $exception->fresh(['finding', 'tenant', 'owner', 'currentDecision', 'evidenceReferences'])];
}
it('classifies ready and expiring accepted-risk states', function (): void {
[, , $ready] = spec354AdapterFixture();
[, , $expiring] = spec354AdapterFixture(
exceptionAttributes: [
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
],
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($ready)['key'])->toBe('accepted_risk.ready')
->and($adapter->forQueue($ready)['title'])->toBe(__('localization.accepted_risk_guidance.title_ready'))
->and($adapter->forQueue($expiring)['key'])->toBe('accepted_risk.expiring')
->and($adapter->forQueue($expiring)['title'])->toBe(__('localization.accepted_risk_guidance.title_expiring'));
});
it('classifies expired accepted-risk state', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'review_due_at' => now()->subDays(3),
'expires_at' => now()->subDay(),
],
decisionType: FindingExceptionDecision::TYPE_APPROVED,
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['title'])->toBe(__('localization.accepted_risk_guidance.title_expired'));
});
it('classifies revoked accepted-risk state', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_REVOKED,
'current_validity_state' => FindingException::VALIDITY_REVOKED,
'revoked_at' => now()->subDay(),
],
decisionType: FindingExceptionDecision::TYPE_REVOKED,
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['title'])->toBe(__('localization.accepted_risk_guidance.title_revoked'));
});
it('classifies rejected accepted-risk state', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_REJECTED,
'current_validity_state' => FindingException::VALIDITY_REJECTED,
'rejected_at' => now()->subDay(),
],
decisionType: FindingExceptionDecision::TYPE_REJECTED,
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['title'])->toBe(__('localization.accepted_risk_guidance.title_rejected'));
});
it('distinguishes pending initial review from pending renewal', function (): void {
[, , $pendingInitial] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'approved_by_user_id' => null,
'approved_at' => null,
'effective_from' => null,
'approval_reason' => null,
],
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
);
[, , $pendingRenewal] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
],
decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($pendingInitial)['title'])->toBe(__('localization.accepted_risk_guidance.title_pending'))
->and($adapter->forQueue($pendingInitial)['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_pending'))
->and($adapter->forQueue($pendingRenewal)['title'])->toBe(__('localization.accepted_risk_guidance.title_pending_renewal'))
->and($adapter->forQueue($pendingRenewal)['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_pending_renewal'));
});
it('keeps lapsed carried-over governance dominant over pending renewal guidance', function (): void {
[, , $expiredRenewal] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
],
decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
decisionMetadata: [
'previous_review_due_at' => now()->subDays(2)->toIso8601String(),
'previous_expires_at' => now()->subDay()->toIso8601String(),
],
);
[, , $expiringRenewal] = spec354AdapterFixture(
exceptionAttributes: [
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_VALID,
],
decisionType: FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
decisionMetadata: [
'previous_review_due_at' => now()->addDay()->toIso8601String(),
'previous_expires_at' => now()->addDays(2)->toIso8601String(),
],
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($expiredRenewal)['key'])->toBe('accepted_risk.expired')
->and($adapter->forQueue($expiringRenewal)['key'])->toBe('accepted_risk.expiring');
});
it('classifies missing support, fresh decision required, and incomplete governance states', function (): void {
[, , $missingSupport] = spec354AdapterFixture(
exceptionAttributes: [
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'status' => FindingException::STATUS_ACTIVE,
],
);
[, , $freshDecision] = spec354AdapterFixture(
findingAttributes: [
'status' => Finding::STATUS_NEW,
],
);
[, , $incompleteGovernance] = spec354AdapterFixture(
exceptionAttributes: [
'owner_user_id' => null,
'request_reason' => '',
'review_due_at' => null,
],
);
$adapter = app(AcceptedRiskResolutionAdapter::class);
expect($adapter->forQueue($missingSupport)['key'])->toBe('accepted_risk.missing_support')
->and($adapter->forQueue($freshDecision)['key'])->toBe('accepted_risk.fresh_decision_required')
->and($adapter->forQueue($freshDecision)['reason'])->toContain('fresh decision')
->and($adapter->forDetail($incompleteGovernance)['key'])->toBe('accepted_risk.incomplete_governance')
->and($adapter->forDetail($incompleteGovernance)['technical_details'])->toHaveKey(__('localization.accepted_risk_guidance.detail_missing_fields_label'));
});
it('keeps missing support as review focus instead of a fake primary action', function (): void {
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'status' => FindingException::STATUS_ACTIVE,
],
);
$guidance = app(AcceptedRiskResolutionAdapter::class)->forDetail($exception);
expect($guidance['primary_action']['type'])->toBe(ResolutionAction::TYPE_NONE)
->and($guidance['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_missing_support'))
->and($guidance['primary_action']['label'])->toContain('Review whether');
});
it('localizes dominant accepted-risk guidance copy for german locale', function (): void {
$originalLocale = app()->getLocale();
[, , $exception] = spec354AdapterFixture(
exceptionAttributes: [
'review_due_at' => now()->addDay(),
'expires_at' => now()->addDays(2),
],
);
app()->setLocale('de');
$guidance = app(AcceptedRiskResolutionAdapter::class)->forQueue($exception);
expect($guidance['reason'])->toBe(__('localization.accepted_risk_guidance.reason_expiring'))
->and($guidance['impact'])->toBe(__('localization.accepted_risk_guidance.impact_expiring'))
->and($guidance['reason'])->not->toContain('The current accepted-risk governance window');
app()->setLocale($originalLocale);
});
it('stays database-local and preserves conservative wording without mutating downstream review output state', function (): void {
bindFailHardGraphClient();
[, , $exception] = spec354AdapterFixture();
assertNoOutboundHttp(function () use ($exception): void {
$guidance = app(AcceptedRiskResolutionAdapter::class)->forDetail($exception);
expect($guidance['primary_action']['label'])->toBe(__('localization.accepted_risk_guidance.next_step_ready'));
});
});

View File

@ -8,41 +8,42 @@ # UI-012 Finding Exceptions Queue
| Archetype | Exceptions / Accepted Risk |
| Design depth | Strategic Surface |
| Repo truth | repo-verified |
| Screenshot | `../screenshots/desktop/ui-012-finding-exceptions-queue.png` |
| Browser status | Reached through workspace route. |
| Screenshot | `Spec 354 browser proof: ../../specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/artifacts/screenshots/spec354-ui-026-finding-exceptions-queue-guidance.png` |
| Browser status | Re-validated through direct workspace queue routes for expiring and expired accepted-risk states. |
## First Five Seconds
The page is an accepted-risk queue. It needs to make risk ownership, expiry, evidence basis, and approval/rejection consequences immediately clear.
The page should answer three questions before the operator reads the table:
1. which exception is in focus
2. whether the accepted-risk record is ready, expiring, expired, pending, or incomplete
3. what the next safe action is without widening current approval or rejection authority
## Productization Review
- Decision-first: strong candidate.
- Evidence-first: exception evidence and linked findings should be visible.
- Context: workspace hub with environment-filter possibilities.
- Customer/auditor safety: high, because accepted risk is customer-relevant.
- Diagnostics: raw finding/provider evidence should be secondary.
- Decision-first: now explicit. The focused review lane starts with a dominant accepted-risk guidance card before secondary diagnostics.
- Evidence-first: owner, review due, expiry, decision history, and related finding context stay visible in the same first-screen lane.
- Context: workspace-owned monitoring surface with explicit `exception` focus and optional governance-inbox continuity.
- Customer/auditor safety: high because this queue decides whether accepted risk can still be relied on as actively governed.
- Diagnostics: secondary. Header actions, sidebar detail, and the queue table remain source-owned under the guidance summary.
## Information Inventory
Default content should show exception state, requester/owner, affected environment, expiration, evidence links, decision history, and required action.
Default content should show dominant governance state, reason, impact, next step, related finding/exception links, owner, review due, expires, current decision, and the surrounding queue context.
## Dangerous Actions
Approve exception, reject renewal, revoke exception, and accept risk are high impact. They require explicit confirmation, authorization, audit, and customer-safe explanation.
Approve and reject actions remain high impact and stay in the existing header-controlled flow. The new guidance must not invent unsupported remediation buttons or bypass confirmation, authorization, and audit semantics.
## Scores
## Spec 354 Follow-up
| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 3 | 3 | 3 | 4 | 3 | 3 | 4 | 3 | 3 | 4 | 3 | 4 |
## Top Issues
1. Risk decision language needs product-target treatment.
2. Evidence basis and expiry must be visible before approval.
3. Customer-safe accepted-risk wording requires review.
- Accepted-risk queue guidance is now derived from existing finding/exception truth through one bounded adapter.
- The queue shows one dominant guidance case with existing repo-backed secondary links only.
- Governance Inbox continuity remains intact on downstream exception detail links.
- Browser proof:
- `spec354-ui-026-finding-exceptions-queue-guidance.png` captures the expiring first-screen hierarchy.
- The same queue route was also re-validated for the expired state in the integrated browser.
## Target Direction
P0/P1 individual target depending on customer-review sequencing. Treat as the accepted-risk decision pattern.
Keep this surface as the workspace-owned accepted-risk decision queue. Future changes should extend the bounded guidance adapter or existing queue actions, not create a parallel decision rail or fake auto-fix layer.

View File

@ -0,0 +1,51 @@
# UI-036 Exception Detail
| Field | Value |
| --- | --- |
| Route | `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions/{record}` |
| Source | `FindingExceptionResource::view` |
| Area / scope | Governance / environment detail |
| Archetype | Exceptions / Accepted Risk |
| Design depth | Strategic Surface |
| Repo truth | repo-verified |
| Screenshot | `Spec 354 browser proof: ../../specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/artifacts/screenshots/spec354-ui-036-exception-detail-guidance.png` |
| Browser status | Re-validated through direct environment detail routes for incomplete-governance and calm-ready owner states. |
## First Five Seconds
The page should answer three questions before the operator drops into decision history:
1. is this accepted-risk record still governable
2. what is missing or urgent right now
3. which existing lifecycle action owns the next step
## Productization Review
- Decision-first: now explicit. The accepted-risk guidance section appears before deeper decision history and evidence.
- Evidence-first: environment, status, validity, owner, review due, expiry, and current decision remain visible in the first guidance block.
- Context: environment-bound detail page with optional continuity back to workspace-owned governance surfaces.
- Customer/auditor safety: high because this page explains whether the exception still provides a valid governance basis.
- Diagnostics: secondary. Decision history and evidence references remain below the first-screen guidance.
## Information Inventory
Default content should show dominant governance state, reason, impact, next step, environment, lifecycle status, governance validity, owner, review due, expiry, current decision, request reason, and missing governance inputs when applicable.
## Dangerous Actions
`renew_exception` and `revoke_exception` remain source-owned header actions with current confirmation and authorization boundaries. The guidance section must not duplicate or invent lifecycle mutations.
## Spec 354 Follow-up
- Incomplete governance support is now first-screen visible before decision history and deep evidence.
- Calm ready state remains calm and does not render a competing warning stack.
- Existing repo-backed actions stay intact:
- `Renew exception`
- `Revoke exception`
- Browser proof:
- `spec354-ui-036-exception-detail-guidance.png` captures the incomplete-governance state.
- The integrated browser also re-validated the calm ready detail state on the same route family.
## Target Direction
Keep this page as the accepted-risk lifecycle owner surface. Future work should widen behavior only through existing record actions or bounded continuity links, not by shifting customer-facing or review-output responsibilities onto this detail page.

View File

@ -41,7 +41,7 @@ # Route Inventory
| UI-033 | `/admin/workspaces/{workspace}/environments/{environment}/findings` | resource | Environment Findings | Findings | environment-bound | route exists | environment entitlement | Findings / Inbox | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Environment list page. |
| UI-034 | `/admin/workspaces/{workspace}/environments/{environment}/findings/{record}` | resource | Finding Detail | Findings | environment record | route exists | environment + record entitlement | Findings / Inbox | Evidence / Audit | Strategic Surface | repo-verified | - | - | Core triage detail route; needs individual review. |
| UI-035 | `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions` | resource | Environment Exceptions | Governance | environment-bound | route exists | environment entitlement | Exceptions / Accepted Risk | Findings / Inbox | Domain Pattern Surface | repo-verified | - | - | Environment-specific exception list. |
| UI-036 | `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions/{record}` | resource | Exception Detail | Governance | environment record | route exists | environment + record entitlement | Exceptions / Accepted Risk | Evidence / Audit | Strategic Surface | repo-verified | - | - | Risk acceptance detail; dangerous-action review needed. |
| UI-036 | `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions/{record}` | resource | Exception Detail | Governance | environment record | reachable | environment + record entitlement | Exceptions / Accepted Risk | Evidence / Audit | Strategic Surface | repo-verified | [desktop](../../specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/artifacts/screenshots/spec354-ui-036-exception-detail-guidance.png) | [report](page-reports/ui-036-exception-detail.md) | Accepted-risk lifecycle detail re-validated for incomplete-governance and calm-ready owner states. |
| UI-037 | `/admin/reviews` | page | Review Register | Reviews | workspace hub | reachable | workspace member | Reviews | Evidence / Audit | Strategic Surface | repo-verified | [desktop](screenshots/desktop/ui-011-reviews.png) | [report](page-reports/ui-011-reviews.md) | Review planning and proof surface. |
| UI-038 | `/admin/reviews/workspace` | page | Customer Review Workspace | Customer review | workspace hub | reachable | workspace member | Customer Workspace | Reviews | Strategic Surface | repo-verified | [desktop](screenshots/desktop/ui-006-customer-review-workspace.png) | [report](page-reports/ui-006-customer-review-workspace.md) | Highest customer-safe productization surface. |
| UI-039 | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews` | resource | Environment Reviews | Reviews | environment-bound | route exists | environment entitlement | Reviews | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Environment-scoped review list. |

View File

@ -4,9 +4,9 @@ # Unresolved Pages
Summary:
- High-priority unresolved/manual-review entries: 30.
- High-priority unresolved/manual-review entries: 29.
- Capability/fixture blockers with desktop evidence: UI-051, UI-053, UI-061.
- Strategic routes not browser-captured in this bounded pass: 26.
- Strategic routes not browser-captured in this bounded pass: 25.
- Hidden/file-discovered manual-review surface: UI-080.
| ID | Page | Blocker / Reason | Needed Evidence | Next Action |
@ -16,7 +16,6 @@ # Unresolved Pages
| UI-014 | Environment Onboarding | Provider setup wizard route exists but was not captured. | Draft/onboarding fixture with consent and permission states. | Include in provider onboarding target pass. |
| UI-017 | Operation Detail | Dynamic operation record route requires a run fixture. | OperationRun records covering success, failure, running, retryable states. | Add operation detail report later. |
| UI-034 | Finding Detail | Dynamic finding detail requires seeded finding state. | Finding records with owner, severity, exception, and close state. | Add strategic finding detail mockup. |
| UI-036 | Exception Detail | Accepted-risk detail requires seeded exception record. | Pending, approved, expired, rejected exception states. | Add accepted-risk detail mockup. |
| UI-042 | Review Pack Detail | Export/evidence artifact detail requires seeded review pack. | Review pack with files, freshness, and download state. | Add review-pack target artifact. |
| UI-044 | Evidence Overview | Workspace evidence landing was not captured. | Workspace with evidence sources, gaps, and stale states. | Add evidence overview report. |
| UI-046 | Evidence Snapshot Detail | Dynamic raw/support evidence detail requires snapshot record. | Snapshot with normalized summary and raw payload. | Add progressive-disclosure review. |

View File

@ -0,0 +1,36 @@
# Requirements Checklist: Spec 354 - Finding Exceptions / Accepted Risk Resolution Guidance v1
Purpose: Validate preparation readiness only. This checklist does not certify implementation, runtime tests, or browser proof.
## Candidate And Guardrail
- [x] CHK001 The candidate source is explicit: direct user draft plus repo-real accepted-risk follow-up materials.
- [x] CHK002 No completed spec package is being reopened or normalized back to preparation state.
- [x] CHK003 The selected slice is narrower than a broad governance-workbench, portal, or GRC rebuild and fits the post-Spec-353 follow-through need.
## Repo Truth Alignment
- [x] CHK004 The prep records the exact current accepted-risk owner surfaces instead of inventing new queue/detail pages.
- [x] CHK005 Existing `FindingRiskGovernanceResolver` truth is named explicitly as the primary guidance source.
- [x] CHK006 Existing customer-safe accepted-risk wording in downstream review-output surfaces is treated as continuity context, not a new owner surface.
- [x] CHK007 Existing queue audit coverage (`ui-012`) is carried forward as the strategic productization proof source.
## Constitution And Scope
- [x] CHK008 The spec forbids new persistence, a new workflow engine, a new review-impact framework, and a broader portal/workbench rewrite.
- [x] CHK009 Provider/platform boundary handling is explicit and keeps this slice platform-core and governance-owned.
- [x] CHK010 Existing capability, audit, and `OperationRun` ownership remain explicit.
- [x] CHK011 UI/Productization coverage is explicit for queue and detail surfaces.
## Test Governance And Readiness
- [x] CHK012 Unit, Feature/Livewire, and Browser coverage are named in the narrowest honest mix.
- [x] CHK013 The plan names concrete runtime seams and likely touched files instead of relying on vague architecture intent.
- [x] CHK014 The tasks are ordered, verifiable, and scoped to this slice only.
- [x] CHK015 No open question blocks a bounded implementation loop.
## Review Outcome
- [x] Ready for implementation prep handoff.
- [x] Main caveat recorded: any stale-governance case based on finding-change timestamps is conditional and must be omitted if repo proof is weak.
- [x] This checklist validates preparation only. No application implementation, runtime test execution, or browser smoke has been performed in this prep step.

View File

@ -0,0 +1,43 @@
# Accepted Risk Guidance Signal Map: Spec 354
Inventory the existing repo-backed signals that may feed accepted-risk resolution guidance without adding new persistence or new workflow truth.
## Required Inputs
| Signal | Current source | Notes |
|---|---|---|
| Exception status | `FindingException.status` | existing lifecycle truth |
| Validity state | `FindingException.current_validity_state` and resolver output | existing governance-support truth |
| Review due / expiry | `FindingException.review_due_at`, `expires_at` | existing urgency inputs |
| Decision posture | `FindingException.currentDecisionType()` and `FindingExceptionDecision` | existing lifecycle/action context |
| Linked finding state | `Finding` + `FindingRiskGovernanceResolver` | existing risk-accepted workflow truth |
| Owner / rationale presence | existing `FindingException` fields | completeness signals only |
| Related evidence / audit / review context | existing linked routes and summaries only | secondary links, not primary truth |
## Guidance Cases
| Case key | Required signals | Primary action | Secondary actions | Notes |
|---|---|---|---|---|
| `accepted_risk.ready` | valid support, no urgent expiry, complete governance support | inspect accepted risk or no urgent action | finding / existing related context where repo-backed | calm state only |
| `accepted_risk.expiring` | expiring validity | review accepted risk | open finding / existing related context / evidence references | high-priority queue case |
| `accepted_risk.expired` | expired support | review accepted risk | open finding / decision history | no fake auto-renew |
| `accepted_risk.revoked_or_rejected` | revoked or rejected support | open finding or review accepted risk | decision history / related context | action depends on current repo-backed source owner |
| `accepted_risk.pending` | pending approval or pending renewal | review accepted risk | open finding / decision history | keep language conservative |
| `accepted_risk.missing_support` | existing exception record has `current_validity_state=missing_support` or equivalent repo-real missing-support posture | review accepted risk | open finding / decision history | owner surfaces do not synthesize no-record accepted-risk rows |
| `accepted_risk.fresh_decision_required` | `FindingException::requiresFreshDecisionForFinding()` is true and resolver warning copy is present | review accepted risk | open finding / decision history | preserve current repo-real signal; do not broaden into a new stale-governance framework |
| `accepted_risk.incomplete_governance` | missing owner, rationale, or review support on an existing exception record | review accepted risk | open finding / existing related context | use only repo-backed completeness signals |
| `accepted_risk.wording_reference` | conservative accepted-risk wording already exists in current review truth | no downstream artifact mutation in this slice | open accepted risk / open finding when repo-backed | owner-surface wording reference only |
## Guardrail
Current repo truth already exposes one bounded fresh-decision-required signal through `FindingException::requiresFreshDecisionForFinding()` and `FindingRiskGovernanceResolver`.
This slice may preserve and surface that signal more clearly, but it must not add a broader timestamp-, diff-, or change-history-based stale-governance framework.
## Forbidden Signals
- live Graph/provider calls during render
- synthetic review-impact scores
- inferred customer-safe summaries that are not already repo-backed
- hidden shell/session context treated as accepted-risk authority
- legacy query aliases treated as scope authority

View File

@ -0,0 +1,297 @@
# Implementation Plan: Spec 354 - Finding Exceptions / Accepted Risk Resolution Guidance v1
- Branch: `354-finding-exceptions-accepted-risk-resolution-guidance-v1`
- Date: 2026-06-05
- Spec: `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/spec.md`
- Input: Spec 354 + repo inspection of Finding Exceptions queue/detail surfaces, accepted-risk governance resolver, Governance Inbox accepted-risk routing, and downstream review-output continuity.
## Summary
Add one derived accepted-risk guidance layer to the existing Finding Exceptions queue and detail owner surfaces so operators can see one dominant accepted-risk case, one dominant next-step affordance, and conservative owner-surface wording without reconstructing meaning from badges, dates, and grouped actions.
The implementation stays narrow:
- reuse existing `FindingException`, `FindingExceptionDecision`, `Finding`, `FindingRiskGovernanceResolver`, `ResolutionCase`, and current lifecycle actions
- keep queue/detail as the owning accepted-risk surfaces
- preserve current action confirmation, authorization, audit, and notification behavior
- reuse downstream customer-safe wording only where already repo-backed
- avoid new persistence, new provider seams, and new workflow frameworks
## Technical Context
- Language/Version: PHP 8.4.15, Laravel 12.52.x
- UI stack: Filament 5.2.x, Livewire 4.x
- Database: PostgreSQL, no schema change planned
- Testing: Pest unit + feature/Livewire + one strategic browser smoke
- Validation lanes: fast-feedback + confidence + browser
- Local runtime posture: Sail-first
- Deployment/runtime impact: no expected env, migration, queue-family, scheduler, storage, or panel/provider change
- Global search: unchanged; `FindingExceptionResource` remains not globally searchable
## Current Repo Truth That Constrains The Slice
- `FindingExceptionsQueue` already owns:
- workspace-wide accepted-risk access
- explicit `environment_id` filter behavior
- selected-record inspect state
- approve / reject actions with confirmation
- related finding and queue/deep-link actions
- `ViewFindingException` already owns:
- accepted-risk detail presentation
- renew / revoke actions with confirmation
- decision-register return-link continuity
- `FindingExceptionResource` already disables global search.
- `FindingRiskGovernanceResolver` already derives:
- accepted-risk workflow family
- governance warning text
- primary narrative
- next-action copy
- validity / attention signals
- the current repo-real fresh-decision-required warning path
- `GovernanceInboxSectionBuilder` already exposes accepted-risk lane text, due context, and the primary action label `Review accepted risk`.
- Customer-safe accepted-risk summaries already exist in review-output paths through `EnvironmentReviewComposer`, Customer Review Workspace, and review-pack summaries, but this slice should treat them as wording reference rather than a runtime mutation target.
- The queue audit (`ui-012`) already marks risk decision language, expiry visibility, and customer-safe wording as top issues.
- There is no need for a second accepted-risk model, a new accepted-risk page family, or a new review-output engine.
## Domain / Model Implications
- No schema or migration change is planned.
- No new persisted accepted-risk readiness entity, review-impact entity, or action-history model is allowed in this slice.
- The narrowest acceptable implementation shape is one derived accepted-risk guidance adapter or selector over:
- current `FindingException`
- current `FindingExceptionDecision`
- current linked `Finding`
- current `FindingRiskGovernanceResolver`
- current queue/detail action availability
- Existing ownership boundaries remain unchanged:
- exception lifecycle truth stays `FindingException` / `FindingExceptionDecision` owned
- source finding truth stays `Finding` owned
- customer-safe review truth stays review/output owned and unchanged by this slice
- any new guidance state stays request-local and derived
## UI / Filament / Livewire Implications
- Filament v5 continues to run on Livewire v4.x; no version or API drift is permitted.
- No panel/provider registration change is allowed; `apps/platform/bootstrap/providers.php` remains untouched.
- `FindingExceptionResource` stays not globally searchable.
- Existing destructive or high-impact accepted-risk actions must keep their current confirmation, authorization, notification, and audit posture.
- No new asset registration is planned, so there is no expected `filament:assets` deployment change for this spec.
## RBAC / Policy Implications
- Workspace membership and entitled environment access remain the only scope authorities.
- Current capabilities continue to decide queue visibility, detail visibility, and lifecycle-action executability.
- Guidance selection itself must remain safe for unauthorized users by operating only on already-authorized page state.
- Queue and detail must keep deny-as-not-found semantics for out-of-scope workspace/environment access.
## Audit / Logging / Evidence Implications
- Existing approve / reject / renew / revoke handling and current audit emission remain authoritative.
- No new audit stream, notification family, or evidence artifact is planned.
- The implementation must keep accepted-risk render paths read-only and side-effect free.
- Existing related-context disclosure, decision history, and evidence references remain secondary and only appear when already repo-backed on the current owner surfaces.
## Data / Migration Implications
- No database migration, backfill, or persisted projection is planned.
- All derived guidance output must be request-local and DB-backed from already stored truth.
- Compatibility shims are not justified because no data shape replacement is proposed in this prep slice.
## Rollout Considerations
- No feature flag is expected because the slice is a bounded presentation improvement over existing repo truth.
- Staging validation should still prove four operator states explicitly:
- expiring accepted risk
- expired, revoked, or fresh-decision-required support
- incomplete governance support (owner/rationale/review due missing on an existing exception record)
- calm valid state
- Production risk is limited to guidance hierarchy, wrong-link regressions, and owner-surface wording drift, so focused tests and one bounded browser smoke remain the main rollout controls.
## UI / Surface Guardrail Plan
- **Guardrail scope**:
- `FindingExceptionsQueue`
- selected-record queue summary and action hierarchy
- `ViewFindingException`
- downstream accepted-risk wording continuity only where already repo-backed
- **Affected surfaces**:
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
- `apps/platform/app/Filament/Resources/FindingExceptionResource.php`
- `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- `apps/platform/resources/views/filament/pages/monitoring/partials/finding-exception-queue-sidebar.blade.php`
- `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php`
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
- **Native vs custom**:
- preserve native Filament queue/detail ownership
- avoid new custom page families
- allow one bounded derived guidance adapter or selector if necessary
- **Shared-family relevance**:
- status messaging
- next-action guidance
- accepted-risk wording
- evidence / decision-history disclosure
- Governance Inbox to owner-surface continuity
- **Required tests / smoke**:
- focused unit tests for accepted-risk guidance selection
- feature/Livewire tests for queue/detail rendering and scope-safe action/link hierarchy
- one bounded browser smoke for the strategic queue/detail surfaces
- **UI/Productization coverage**:
- update `ui-012-finding-exceptions-queue.md`
- create or update `ui-036-exception-detail.md`
- update `route-inventory.md` coverage for `UI-036`
- update `unresolved-pages.md` to remove or reclassify `UI-036` once durable coverage exists
- update `design-coverage-matrix.md` only if classification or surface counts change
## Shared Pattern And System Fit
- **Preferred reuse path**:
- current `FindingRiskGovernanceResolver` truth
- current `ResolutionCase` / `ResolutionAction` contract
- current `GovernanceActionCatalog`
- current queue/detail links and navigation helpers
- current review-output accepted-risk wording as conservative wording reference only
- **Likely implementation shape**:
- one bounded `FindingExceptionResolutionAdapter` or page-local selector under the current `ResolutionGuidance` path
- queue/detail-specific mapping stays local to the accepted-risk owner surfaces
- existing paired lifecycle actions remain source-owned even when one dominant next-step affordance is promoted in the guidance summary
- **Avoid**:
- new accepted-risk workflow engine
- new persisted readiness or action state
- new global review-impact framework
- new provider/platform abstraction
## OperationRun UX Impact
Spec 354 does not create a new `OperationRun` type and does not require new `OperationRun` links on the accepted-risk owner surfaces.
Implementation responsibility is limited to preserving the current no-new-OperationRun-link posture unless an already-present owner-surface related-context path exists.
## Likely Runtime Files
| Area | Repo-real files |
|---|---|
| Queue runtime | `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php` |
| Queue focused-review partial | `apps/platform/resources/views/filament/pages/monitoring/partials/finding-exception-queue-sidebar.blade.php` |
| Detail runtime | `apps/platform/app/Filament/Resources/FindingExceptionResource.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php` |
| Accepted-risk truth | `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php`, `apps/platform/app/Services/Findings/FindingExceptionService.php` |
| Shared guidance contract | `apps/platform/app/Support/ResolutionGuidance/ResolutionCase.php`, `apps/platform/app/Support/ResolutionGuidance/ResolutionAction.php` |
| Adjacent routing / continuity | `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, current related-navigation helpers |
| Downstream wording reference only | `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, current review-pack summary builders |
| UI audit docs | `docs/ui-ux-enterprise-audit/page-reports/ui-012-finding-exceptions-queue.md`, `docs/ui-ux-enterprise-audit/page-reports/ui-036-exception-detail.md`, `docs/ui-ux-enterprise-audit/route-inventory.md`, `docs/ui-ux-enterprise-audit/unresolved-pages.md` |
## Likely Test Files
| Layer | Planned file |
|---|---|
| Unit | `apps/platform/tests/Unit/ResolutionGuidance/Spec354AcceptedRiskResolutionAdapterTest.php` |
| Feature/Livewire | `apps/platform/tests/Feature/Monitoring/Spec354FindingExceptionsQueueGuidanceTest.php` |
| Feature/Livewire | `apps/platform/tests/Feature/Findings/Spec354FindingExceptionDetailGuidanceTest.php` |
| Browser | `apps/platform/tests/Browser/Spec354AcceptedRiskGuidanceSmokeTest.php` |
## Implementation Approach
### Phase 0 - Repo Truth Gate
1. Re-read `spec.md`, `plan.md`, `tasks.md`, `repo-truth-map.md`, `contracts/accepted-risk-guidance-signal-map.md`, and `checklists/requirements.md`.
2. Re-verify the current runtime truth in the queue/detail/resolver/governance files listed below.
3. Keep draft mismatches explicit:
- no new accepted-risk model
- no global-search change
- no standalone customer-facing accepted-risk surface
4. Confirm no migration, package, env var, queue-family, storage, panel/provider, or global-search change is required.
### Phase 1 - Tests First
1. Add unit coverage for deterministic guidance selection:
- valid accepted risk
- expiring accepted risk
- expired support
- revoked or rejected support
- fresh decision required
- pending or renewal-requested support
- missing governance support on an existing exception record
- incomplete owner/rationale/review support
- conservative owner-surface wording reuse without downstream artifact mutation
2. Add feature/Livewire coverage for the queue:
- one dominant case
- one dominant next-step affordance
- existing related context only when repo-backed
- no fake remediation buttons
- scope-safe links and explicit `environment_id` behavior
- out-of-scope access stays 404 and member-but-missing-capability behavior stays aligned with current queue semantics
3. Add feature/Livewire coverage for detail:
- one dominant case
- current infolist hierarchy remains detail-owned
- current high-impact actions remain state- and capability-bound
- owner/rationale/review support is visible before deeper diagnostics
- out-of-scope detail access stays 404 and action denial remains capability-bound
4. Add one browser smoke:
- queue expiring state
- queue or detail expired/revoked state
- detail incomplete-governance state
- calm valid state
### Phase 2 - Derived Guidance Contract
1. Choose the narrowest implementation shape:
- prefer one bounded accepted-risk adapter or selector
- avoid expanding review-output or provider adapters into a generic risk-workflow engine
2. Build one derived accepted-risk payload with:
- key
- title
- status
- severity
- reason
- impact
- primary action or dominant next-step mapping
- secondary actions
- technical details
3. Keep priority ordering explicit and narrow.
4. Preserve the current fresh-decision-required signal from `requiresFreshDecisionForFinding()` and do not expand it into a broader stale-governance framework.
### Phase 3 - Queue Integration
1. Add a top guidance presentation to `FindingExceptionsQueue` without removing current queue/table/selected-record truth.
2. Reuse existing repo-backed actions and links:
- inspect accepted risk
- approve exception
- reject exception
- open finding
- existing related context only when already available on the current surface
3. Keep destructive/high-impact actions unchanged:
- confirmation
- authorization
- audit
- notifications
4. Do not widen authorization because guidance is more visible.
### Phase 4 - Detail Integration
1. Add an explicit guidance summary to the detail surface through `FindingExceptionResource::infolist()` and `ViewFindingException`.
2. Reuse current owner/rationale/expiry/review data before inventing any new accepted-risk state.
3. Keep renew and revoke source-owned.
4. Keep decision history, evidence references, and related context secondary.
### Phase 5 - Downstream Continuity
1. Reuse current Governance Inbox accepted-risk deep-link and update it only if label or target continuity is inconsistent after queue/detail guidance becomes decision-first.
2. Reuse existing conservative accepted-risk wording as owner-surface reference only; do not mutate downstream review-output artifacts in this slice.
3. Avoid turning downstream review-output surfaces into second accepted-risk owner surfaces.
### Phase 6 - Copy, Audit, And Artifacts
1. Update only the copy required in `apps/platform/lang/en/localization.php`.
2. Update matching copy in `apps/platform/lang/de/localization.php`.
3. Update `docs/ui-ux-enterprise-audit/page-reports/ui-012-finding-exceptions-queue.md`.
4. Create or update `docs/ui-ux-enterprise-audit/page-reports/ui-036-exception-detail.md`.
5. Update `docs/ui-ux-enterprise-audit/route-inventory.md` and `docs/ui-ux-enterprise-audit/unresolved-pages.md` for `UI-036`.
6. Save screenshots under the Spec 354 artifact path, or record the host-visible artifact blocker honestly.
### Phase 7 - Validation
1. Run focused unit, feature, and browser coverage for this slice.
2. Re-run current queue/detail related acceptance and guard tests in the narrowest honest family.
3. Confirm final render paths remain DB-local and do not call `GraphClientInterface` or provider HTTP during page render.
4. Run `pint` and `git diff --check`.
5. Report broader-suite or unrelated browser-harness issues honestly if they remain outside this slice.

View File

@ -0,0 +1,87 @@
# Repo Truth Map: Spec 354 - Finding Exceptions / Accepted Risk Resolution Guidance v1
## Scope
Bounded accepted-risk guidance follow-up over the existing queue and detail owner surfaces.
This prep package must not reopen completed customer-review, provider-readiness, or broad governance-workbench packages.
## Candidate Selection Summary
- **Selected candidate**: direct user-provided Spec 354 draft
- **Why selected**:
- explicit user-provided next slice
- explicit follow-up note in Spec 353
- strategic queue audit `ui-012-finding-exceptions-queue.md`
- existing repo-real accepted-risk foundations already exist, so the narrow next step is productization on the owning surfaces
- **Why not the older backlog items**:
- the active candidate queue says no safe automatic next-best-prep target remains
- earlier customer-review/provider/governance lanes already have newer spec packages
- this user-provided candidate is a bounded direct follow-up rather than a duplicate refresh of an older manual-promotion item
## Completed-Spec Guardrail Result
| Related spec | Status in repo | Guardrail handling |
|---|---|---|
| Spec 343 - Customer Review Attestation / Accepted Risk Lifecycle | Implemented | context only |
| Spec 346 - Governance Inbox Final Operator Workflow | Draft | adjacent context only |
| Spec 349 - Customer Review Workspace Output Resolution Guidance | Draft | adjacent context only |
| Spec 350 - Operator Resolution Guidance Framework v1 | Draft | shared-contract context only |
| Spec 351 - Review Output Resolve Actions v1 | Draft | adjacent action-mapping context only |
| Spec 352 - Environment Dashboard Operator Guidance Consolidation | Draft | adjacent routing/wiring context only |
| Spec 353 - Provider Connections Resolution Guidance v1 | Implemented (close-out audit pending) | context only; do not reopen |
No completed spec package is being normalized back into preparation-only wording.
## Primary Runtime Surfaces
| Surface | Repo truth | Why it matters to Spec 354 |
|---|---|---|
| `FindingExceptionsQueue` | workspace-wide accepted-risk queue with selected-record review state, explicit `environment_id` filter, approve/reject actions, and related links | primary operator owner surface |
| `ViewFindingException` | environment-bound accepted-risk detail with renew/revoke actions and decision-register return-link support | action-owning detail surface |
| `FindingExceptionResource` | accepted-risk resource with global search disabled | keep global search unchanged and preserve current resource contract |
| `FindingRiskGovernanceResolver` | derives workflow family, warnings, narrative, next action, validity, and governance attention | primary existing truth source for guidance selection |
| `GovernanceInboxSectionBuilder` | emits accepted-risk lane labels, due context, and `Review accepted risk` deep link | continuity source, not owner surface |
| `EnvironmentReviewComposer` and current review-pack summaries | already emit customer-safe accepted-risk wording | wording reference only; downstream artifacts stay unchanged in this slice |
## Runtime Signals Already Available
| Signal family | Existing repo-backed inputs |
|---|---|
| Exception lifecycle | `status`, `current_validity_state`, `expires_at`, `review_due_at`, `revoked_at`, `currentDecisionType()` |
| Governance support completeness | owner, request reason, evidence refs, pending-renewal state, valid exception presence |
| Finding relationship | linked `Finding`, workflow family, accepted-risk status, stale-governance warning text |
| Queue/detail action truth | approve, reject, renew, revoke, inspect/open links, and current related-context disclosure |
| Downstream review impact | current review-output accepted-risk wording exists as reference truth, but downstream artifacts are not in-scope mutation targets for this slice |
## Draft-To-Repo Corrections
1. The queue already exists and is already the accepted-risk workbench. Spec 354 must productize it rather than inventing a new queue or register.
2. The detail page already owns renew/revoke actions. Spec 354 must keep those actions source-owned.
3. `FindingRiskGovernanceResolver` already contains accepted-risk narrative and next-action truth. Spec 354 must adapt or wrap it instead of writing a second lifecycle interpreter from scratch.
4. Governance Inbox already routes accepted-risk work into the queue with a repo-real label. Spec 354 only needs continuity, not a new inbox lane.
5. Customer-safe accepted-risk wording already exists in downstream review surfaces. Spec 354 must keep those surfaces secondary.
## Current Gaps This Spec May Close
| Gap | Repo evidence |
|---|---|
| No single dominant guidance case on queue owner surface | queue audit `ui-012` and current queue/detail runtime split |
| Accepted-risk explanation still distributed across badges, warnings, and grouped actions | current queue/detail structure plus resolver copy |
| Existing fresh-decision-required warning is not yet promoted into a decision-first summary on the owner surfaces | `requiresFreshDecisionForFinding()` plus resolver warning copy already exist, but remain embedded inside secondary warning treatment |
## Out Of Scope Confirmed By Repo Truth
- No new accepted-risk or attestation table
- No new review-pack format or export renderer
- No new provider-readiness work
- No new Governance Inbox or dashboard rebuild
- No new portal or customer-facing standalone accepted-risk page
- No new global-search enablement for `FindingExceptionResource`
## Likely Narrow Implementation Shape
- one bounded accepted-risk adapter or selector under the existing resolution-guidance support path
- queue summary integration
- detail summary integration
- continuity fixes only where current Governance Inbox deep links or owner-surface wording would otherwise contradict the new guidance

View File

@ -0,0 +1,465 @@
# Feature Specification: Spec 354 - Finding Exceptions / Accepted Risk Resolution Guidance v1
**Feature Branch**: `354-finding-exceptions-accepted-risk-resolution-guidance-v1`
**Created**: 2026-06-05
**Status**: Draft
**Type**: Platform productization / accepted-risk operator guidance / finding-exception workflow consolidation
**Runtime posture**: Bounded operator-guidance follow-up over existing Finding Exception, Governance Inbox, Customer Review Workspace, Environment Review, Review Pack, and Resolution Guidance truth. No new GRC engine, no new persistence, no customer portal, no workflow replatform, and no provider-architecture change.
**Input**: Direct user-provided Spec 354 draft (attachment) + repo truth from Spec 353 follow-up notes, `docs/ui-ux-enterprise-audit/page-reports/ui-012-finding-exceptions-queue.md`, and current finding-exception runtime surfaces.
## Dependencies And Repo-Truth Adjustments
This spec is a bounded follow-up over already repo-real accepted-risk, review, and operator-guidance foundations:
- Spec 343 - Customer Review Attestation / Accepted Risk Lifecycle
- Spec 346 - Governance Inbox Final Operator Workflow
- Spec 349 - Customer Review Workspace Output Resolution Guidance
- Spec 350 - Operator Resolution Guidance Framework v1
- Spec 351 - Review Output Resolve Actions v1
- Spec 352 - Environment Dashboard Operator Guidance Consolidation
- Spec 353 - Provider Connections Resolution Guidance v1
Repo-truth adjustments against the user draft:
- `App\Filament\Pages\Monitoring\FindingExceptionsQueue` already exists as the workspace-wide accepted-risk decision surface and already owns selected-record review state, explicit `environment_id` filtering, and approve/reject actions.
- `App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException` already exists as the environment-bound accepted-risk detail surface and already exposes `renew_exception` and `revoke_exception` with confirmation, authorization, and notification behavior.
- `App\Services\Findings\FindingRiskGovernanceResolver` already derives accepted-risk workflow family, warning text, narrative, and next-action guidance from existing `Finding` plus `FindingException` truth; Spec 354 must reuse or adapt that truth instead of inventing a second accepted-risk state machine.
- `App\Support\GovernanceInbox\GovernanceInboxSectionBuilder` already emits accepted-risk lane copy and already deep-links the operator to `FindingExceptionsQueue` with the action label `Review accepted risk`.
- `App\Filament\Resources\FindingExceptionResource` already disables global search. Spec 354 must preserve that state; no new global-search scope is needed.
- Customer-safe accepted-risk wording already exists in review-output surfaces through `EnvironmentReviewComposer`, `CustomerReviewWorkspace`, and current review-pack summaries. Spec 354 must reuse those downstream semantics rather than inventing a new customer-facing risk language.
- The real gap is not missing accepted-risk truth. The gap is that the owning accepted-risk queue/detail surfaces still spread operator guidance across badges, due dates, warning text, decision history, and grouped actions instead of answering one first-order question in five seconds: what needs review now, why it matters, and what the next safe action is.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already stores and exposes accepted-risk and finding-exception truth, but the queue and detail surfaces still require operators to reconstruct which accepted risk needs action, why it matters to governance, and whether the next step is renewal, revocation, proof review, or customer-safe wording review.
- **Today's failure**: Expiring, expired, incomplete, or weakly governed accepted risks can remain visible only as mixed status badges, due labels, warning strings, and action groups. Operators still need to translate those fragments into one clear governance decision.
- **User-visible improvement**: Finding Exceptions Queue and Finding Exception detail become decision-first accepted-risk surfaces that show one dominant guidance case with title, reason, impact, one dominant next-step affordance, and only existing repo-backed secondary context.
- **Smallest enterprise-capable version**: Reuse existing `FindingException`, `FindingExceptionDecision`, `Finding`, `FindingRiskGovernanceResolver`, queue/detail actions, and current customer-safe review signals to derive one accepted-risk guidance case on the existing queue and detail surfaces. Keep Governance Inbox, Customer Review Workspace, Environment Review, Review Pack, and Dashboard continuity bounded to already repo-backed links or wording only.
- **Explicit non-goals**: No new accepted-risk table or audit artifact, no new approval engine, no new risk-scoring framework, no new portal, no review-pack renderer change, no customer-review architecture rewrite, no Governance Inbox rebuild, no provider readiness work, no new workflow engine, and no fake remediation actions.
- **Permanent complexity imported**: One bounded accepted-risk guidance adapter or selector over the existing `ResolutionCase` / `ResolutionAction` path, focused unit/feature/browser coverage, one repo-truth map, one signal-map artifact, and UI-audit follow-through. No new persisted entity, no new enum family, no new queue family, and no new provider/platform framework is intended.
- **Why now**: Spec 353 explicitly deferred accepted-risk / finding-exception resolution guidance as the next bounded follow-up, and `ui-012-finding-exceptions-queue.md` already marks the queue as a strategic accepted-risk decision surface with P0/P1 productization pressure.
- **Why not local**: Copy-only tweaks on the queue or detail page would leave accepted-risk guidance fragmented across queue/detail/review surfaces. A broad governance-workflow rebuild would be disproportionate. The narrow correct slice is one derived guidance layer on the existing accepted-risk owner surfaces.
- **Approval class**: Workflow Compression.
- **Red flags triggered**: #1 Neue Achsen (one additional derived accepted-risk guidance case layer) and #2 Neue Meta-Infrastruktur (bounded adapter/selector under the existing shared guidance path). Defense: the slice stays on existing owner surfaces, reuses current truth, forbids new persistence/frameworks, and keeps the adapter bounded to existing `ResolutionCase` / `ResolutionAction` reuse instead of creating a second workflow engine.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve.
## Candidate Source And Completed-Spec Guardrail
- **Candidate source**:
- direct user-provided Spec 354 draft
- explicit follow-up candidate recorded in `specs/353-provider-connections-resolution-guidance-v1/spec.md`
- `docs/ui-ux-enterprise-audit/page-reports/ui-012-finding-exceptions-queue.md`
- current accepted-risk/productization truth in `FindingExceptionsQueue`, `ViewFindingException`, `FindingRiskGovernanceResolver`, and Governance Inbox accepted-risk routing
- **Completed-spec guardrail result**:
- no `specs/354-*` package or branch existed before this preparation run
- Spec 343 (`Implemented`) and Spec 353 (`Implemented (close-out audit pending)`) are completed historical context only and must not be reopened or normalized
- Specs 346, 349, 350, 351, and 352 are adjacent draft/prepared context only and are not being converted back into a fresh prep target
- no completed task checklist, validation history, smoke result, or close-out text is being removed from any related spec package
- **Close alternatives deferred**:
- broader Governance Inbox follow-through beyond the existing accepted-risk deep-link path
- customer-facing localization/copy hardening outside accepted-risk wording that already affects review output
- broader provider/onboarding productization after Spec 353
- wider dashboard or lifecycle-taxonomy follow-through beyond existing accepted-risk context links
- **Smallest viable implementation slice**: existing `FindingExceptionsQueue` plus `ViewFindingException` only: derive one dominant accepted-risk guidance case, keep one dominant next-step affordance, preserve current approval/renewal/revocation behavior as source-owned actions, and reuse only the existing queue/detail related context already present on those owner surfaces.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace-wide accepted-risk queue plus environment-bound accepted-risk detail.
- **Primary Routes**:
- `/admin/finding-exceptions/queue`
- `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions`
- `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions/{record}`
- existing deep links from `/admin/governance/inbox` and current review/dashboards only when they already route into those accepted-risk surfaces
- **Data Ownership**:
- `FindingException` remains accepted-risk / exception lifecycle truth
- `FindingExceptionDecision` remains decision history and current-decision truth
- `Finding` remains source finding truth and linked workflow state
- `EvidenceSnapshot`, `EnvironmentReview`, `ReviewPack`, and `AuditLog` remain secondary proof or downstream-output truth where already linked
- any new guidance case remains derived-only and request-scoped
- **RBAC**:
- workspace membership remains required
- `FindingExceptionsQueue` keeps its existing workspace-scoped access posture and continues to require the existing queue capability boundary
- detail and mutation flows continue to use environment entitlement plus existing `Capabilities::FINDING_EXCEPTION_VIEW`, `FINDING_EXCEPTION_APPROVE`, and `FINDING_EXCEPTION_MANAGE`
- non-member / out-of-scope workspace or environment access remains deny-as-not-found
- member-but-missing-capability remains denied according to current policy and capability semantics
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: `FindingExceptionsQueue` remains workspace-wide and uses explicit `environment_id` filtering only. No hidden shell/session environment state may become authority for accepted-risk guidance.
- **Explicit entitlement checks preventing cross-tenant leakage**: queue filters, detail links, related finding links, review/workspace links, and proof links must continue to resolve only through current workspace membership plus entitled environment scope.
## UI Surface Impact *(mandatory - UI-COV-001)*
- [ ] No UI surface impact
- [x] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [x] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [x] Workspace/environment context presentation changed
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
- **Route/page/surface**:
- `FindingExceptionsQueue`
- selected-record accepted-risk summary and related actions inside the queue
- `ViewFindingException`
- accepted-risk continuity from Governance Inbox, Customer Review Workspace, Environment Review, Review Pack, and Environment Dashboard only where the current repo already exposes those links or wording
- **Current or new page archetype**:
- `FindingExceptionsQueue`: existing strategic workspace queue / accepted-risk decision surface (`UI-026`)
- `ViewFindingException`: existing environment-bound accepted-risk detail surface (`UI-036`)
- **Design depth**:
- queue: Strategic Surface
- detail: Strategic Surface because it owns high-impact accepted-risk lifecycle actions
- **Repo-truth level**: repo-verified existing runtime surfaces
- **Existing pattern reused**:
- `docs/ui-ux-enterprise-audit/page-reports/ui-012-finding-exceptions-queue.md`
- current queue/detail action-surface contracts
- existing `ResolutionCase` / `ResolutionAction` contract
- existing accepted-risk wording in Governance Inbox and review-output surfaces
- **New pattern required**: one bounded accepted-risk guidance adapter or selector over current `FindingRiskGovernanceResolver` truth; no new workflow engine or new customer-facing contract family
- **Screenshot required**: yes, for both queue and detail under `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/artifacts/screenshots/`
- **Page audit required**:
- update `docs/ui-ux-enterprise-audit/page-reports/ui-012-finding-exceptions-queue.md`
- create or update `docs/ui-ux-enterprise-audit/page-reports/ui-036-exception-detail.md`
- update `docs/ui-ux-enterprise-audit/route-inventory.md` so `UI-036` links to the durable detail-page report and screenshot path
- **Customer-safe review required**: yes, because accepted-risk wording flows into customer-facing review and review-pack surfaces even though Spec 354 itself remains operator-facing
- **Dangerous-action review required**: yes; approve, reject, renew, and revoke remain high-impact and must keep confirmation, authorization, audit, and conservative copy
- **Coverage files updated or explicitly not needed**:
- [x] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [x] `docs/ui-ux-enterprise-audit/page-reports/...`
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [x] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [ ] `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)**: status messaging, next-step guidance, accepted-risk wording, decision/evidence disclosure, action links, and Governance Inbox continuity
- **Systems touched**:
- `App\Filament\Pages\Monitoring\FindingExceptionsQueue`
- `App\Filament\Resources\FindingExceptionResource`
- `App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException`
- `App\Services\Findings\FindingRiskGovernanceResolver`
- `App\Support\ResolutionGuidance\ResolutionCase`
- `App\Support\ResolutionGuidance\ResolutionAction`
- `App\Support\Ui\GovernanceActions\GovernanceActionCatalog`
- `App\Support\GovernanceInbox\GovernanceInboxSectionBuilder`
- existing queue/detail related-context helpers and existing review wording sources as read-only reference only
- **Existing pattern(s) to extend**:
- current accepted-risk queue/detail action hierarchy
- current Governance Inbox accepted-risk lane
- current review-output customer-safe accepted-risk summaries as wording reference only
- current `FindingRiskGovernanceResolver` narrative and next-action truth
- **Shared contract / presenter / builder / renderer to reuse**:
- `FindingRiskGovernanceResolver`
- `ResolutionCase` / `ResolutionAction`
- `GovernanceActionCatalog`
- `BadgeRenderer`
- current queue/detail link builders and current related-navigation helpers
- **Why the existing shared path is sufficient or insufficient**: the repo already has accepted-risk truth and an existing guidance contract, but the owner surfaces do not yet consume that truth as one explicit accepted-risk decision case with one dominant next action.
- **Allowed deviation and why**: one bounded accepted-risk adapter or selector is allowed if it wraps existing truth and avoids pushing queue/detail mapping logic directly into Blade or action closures.
- **Consistency impact**: titles, reason/impact structure, accepted-risk wording, next-step labels, and queue/detail related-context disclosure must stay aligned between queue, detail, Governance Inbox, and existing customer-safe review-output wording.
- **Review focus**: prevent a second accepted-risk workflow engine, prevent fake remediation buttons, prevent raw internal rationale or provider diagnostics from becoming default-visible, and prevent queue/detail drift from the shared guidance contract.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: deep-link semantics only
- **Shared OperationRun UX contract/layer reused**: existing proof links where accepted-risk evidence or downstream review artifacts already point at `OperationRun` detail
- **Delegated start/completion UX behaviors**: unchanged; Spec 354 does not add any new start, dedupe, block, or completion behavior
- **Local surface-owned behavior that remains**: ranking the dominant accepted-risk guidance case and choosing which already repo-backed navigation or source-owned action is primary
- **Queued DB-notification policy**: unchanged
- **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 governance truth over existing finding / accepted-risk / review artifacts
- **Seams affected**: accepted-risk wording and proof-link routing only
- **Neutral platform terms preserved or introduced**: accepted risk, finding exception, governance follow-up, review impact, evidence basis, decision history, related context
- **Provider-specific semantics retained and why**: only where existing linked finding or evidence detail already contains provider-backed wording; Spec 354 does not add new provider-shaped platform-core terms
- **Why this does not deepen provider coupling accidentally**: no Graph call, provider connection state, provider credential seam, or provider-owned persistence is introduced
- **Follow-up path**: none; provider readiness remains the separate Spec 353 lane
## UI / Surface Guardrail Impact *(mandatory)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Finding Exceptions Queue overview | yes | Native Filament page plus existing Blade composition | accepted-risk queue, next-action guidance, proof links | page, URL-query, selected-record state | no | Existing route only |
| Queue selected-record summary and actions | yes | Native page state over existing queue | accepted-risk decision hierarchy, action grouping | page, selected-record summary | no | Reuse current selected-record model |
| View Finding Exception detail | yes | Native Filament detail page | accepted-risk reasoning, lifecycle action hierarchy, evidence/audit disclosure | detail, header actions | no | Existing route only |
| Governance Inbox deep-link continuity | possible | Existing page linking into queue | accepted-risk work routing | URL only | no | Only if existing label/target continuity needs bounded adjustment |
| Customer-review / review-pack accepted-risk wording continuity | reference only | Existing downstream wording source | customer-safe accepted-risk wording | wording only | no | Used as conservative wording reference only; no downstream surface redesign or mutation in this slice |
## Decision-First Surface Role *(mandatory)*
| 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 |
|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | Primary Decision Surface | Operator decides whether an accepted risk needs review now and which existing decision path to enter | status, reason, impact, expiry/review due, one dominant next-step affordance | linked finding, decision history, evidence, existing related context | Primary because it is the workspace-wide accepted-risk queue | follows governance follow-up workflow | removes reconstruction across badges, dates, and grouped actions |
| View Finding Exception | Secondary Context and action-owning detail | Operator validates the selected accepted risk and performs the permitted source-owned action | current validity, owner/rationale, evidence summary, one dominant next-step affordance | full decision history, deeper evidence refs, related finding/queue links | Secondary because it deepens the chosen queue item and owns the lifecycle action | follows accepted-risk detail workflow | prevents a second equal-weight decision home |
| Governance Inbox | Secondary Context | Operator routes from broader governance attention into the accepted-risk owner surface | queue item reason, impact, primary action label | deeper queue/detail context after open | Secondary because it is the daily cross-domain workbench, not the accepted-risk owner | follows `open the owning surface` workflow | avoids duplicating accepted-risk lifecycle controls |
## Audience-Aware Disclosure *(mandatory)*
| 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 |
|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | operator-MSP, manager, support where authorized | title, reason, impact, owner/due context, one dominant next-step affordance | linked finding state, evidence basis summary, decision history summary | raw provider evidence, low-level metadata, fingerprints, internal-only rationale detail | yes | raw/support detail remains secondary or capability-gated | queue states the blocker once; later sections add proof |
| View Finding Exception | operator-MSP, manager, support where authorized | current validity, reason, impact, action-safe state, owner/rationale/expiry where repo-backed | decision history, evidence references, related context | raw copied context, low-level provider/debug detail | yes | raw/support detail remains secondary | detail deepens the chosen case instead of restating the queue summary |
| Downstream wording continuity | customer-safe reader, operator-MSP | existing conservative accepted-risk wording remains the wording reference only | operator diagnostics stay on owner surfaces | raw/internal queue/detail rationale hidden by default | no new owner-surface action added here | internal governance detail hidden from customer-safe defaults | owner-surface copy reuses conservative wording instead of changing downstream artifacts |
## UI/UX Surface Classification *(mandatory)*
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | Queue / Decision Surface | Accepted-risk workspace queue | review the selected accepted risk | explicit inspect action / selected-record panel | existing explicit inspect model remains authoritative | grouped below the dominant next-step affordance and queue summary | existing approve/reject actions stay grouped and confirmed | `/admin/finding-exceptions/queue` | selected-record detail or existing detail route | workspace shell + visible `environment_id` filter | Finding exceptions / accepted risk | current validity, reason, impact, next step | none |
| View Finding Exception | Detail / Governance Record | Accepted-risk lifecycle detail | renew, revoke, or review proof based on allowed state | existing detail route | N/A | contextual proof and related links stay secondary | renew/revoke remain confirmed and source-owned | `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions` | `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions/{record}` | workspace + environment route | Finding exception | current decision truth and next step | none |
## Operator Surface Contract *(mandatory)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | Workspace governance operator | decide which accepted risk needs action now | workspace accepted-risk queue | Which accepted risk needs review, and what should I do next? | status, reason, impact, owner/due, environment, one dominant next-step affordance | linked finding/evidence detail only after explicit open | accepted-risk validity, governance attention, owner/due completeness, owner-surface continuity only | none by default on queue summary; source-owned actions only | inspect accepted risk, open finding, open existing related context | approve and reject remain grouped, confirmed, and auditable |
| View Finding Exception | Environment governance operator | validate and act on the selected accepted risk | detail surface | Is this accepted risk still safe to rely on, and what action is allowed now? | current validity, reason, impact, owner/rationale/expiry, one dominant next-step affordance | decision history, deeper evidence refs, related queue/finding context | accepted-risk validity, pending/renewal posture, governance support completeness | existing exception lifecycle only | renew, revoke, open related context | renew and revoke remain high-impact and confirmation-gated |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: one bounded accepted-risk adapter or selector is allowed only inside the existing `ResolutionGuidance` path if it keeps queue/detail mapping derived and testable
- **New enum/state/reason family?**: no; reuse existing `FindingException` status and validity truth plus existing resolver output
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: accepted-risk follow-up still requires reconstructing meaning from fragmented queue/detail signals
- **Existing structure is insufficient because**: queue/detail surfaces already have state, warnings, and actions, but they do not yet present one explicit decision-first accepted-risk case
- **Narrowest correct implementation**: derive one guidance case over existing finding/exception truth on the current queue/detail surfaces and keep downstream links conservative and repo-backed
- **Ownership cost**: one adapter or selector, focused tests, copy updates, and page-audit maintenance
- **Alternative intentionally rejected**: new accepted-risk workflow engine, new persisted review state, new dashboard/workbench, or broad customer portal/GRC rebuild
- **Release truth**: current-release productization over repo-real finding-exception and review-output truth
## Summary
Spec 354 turns accepted-risk owner surfaces into decision-first guidance surfaces.
The implementation remains narrow:
- reuse existing `FindingException`, `FindingExceptionDecision`, `Finding`, and `FindingRiskGovernanceResolver` truth
- keep queue and detail surfaces authoritative
- preserve current high-impact action ownership, confirmation, authorization, and audit behavior
- reuse downstream customer-safe wording where already repo-backed
- avoid new persistence, new frameworks, and fake remediation
## Problem Statement
Accepted risks are governance-of-record artifacts, not passive labels.
TenantPilot already stores accepted-risk state, current decision, owner, expiry, review due dates, linked finding state, and downstream customer-safe summaries. The owner surfaces still require operators to infer what matters most from fragments:
- warning strings
- status badges
- due dates
- grouped header actions
- queue rows
- Governance Inbox routing
That fragmentation increases the chance of false calmness:
- expiring accepted risks may look merely informational
- missing owner or rationale may be noticed too late
- revoked or expired support may still be mentally treated as valid governance
- downstream customer-review output may rely on accepted-risk wording without a clear operator reminder that follow-up is needed
## Primary Users And Operators
- MSP or workspace operators managing governance follow-up
- tenant/environment operators responsible for accepted-risk lifecycle actions
- support users with existing entitlement who need audit/evidence context after the primary decision is made
This slice does not target a customer portal or public audience directly.
## Goals
### G1 - Add accepted-risk resolution guidance on the owner surfaces
Finding Exceptions Queue and accepted-risk detail must show one dominant guidance case when accepted-risk follow-up is required.
### G2 - Preserve existing exception and decision truth
Use existing records, resolver output, and current source-owned actions. Do not introduce a second accepted-risk model.
### G3 - Keep one dominant next-step affordance
Each guidance case exposes one dominant next-step affordance. On source-owned lifecycle surfaces, that affordance may point to an existing paired action group rather than collapsing truthful action pairs into one synthetic button. Other supported links remain visibly secondary.
### G4 - Keep customer-safe boundary conservative
Accepted-risk wording may flow into review-output surfaces, but raw internal rationale, provider diagnostics, copied context, and internal-only detail stay secondary or hidden where appropriate.
### G5 - Reuse existing shared guidance and action contracts
Use the current `ResolutionCase` / `ResolutionAction` and current governance action catalog where they are already sufficient.
### G6 - Keep evidence and related context on demand
Operators must be able to open existing related context that is already repo-backed on the owner surfaces, but the first-read queue/detail surface must remain calm and decision-first.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Review expiring or expired accepted risks quickly (Priority: P1)
As a workspace governance operator, I can open Finding Exceptions Queue and immediately see whether an accepted risk is expiring or already expired, why that matters, and what I should do next.
**Why this priority**: This is the core operator workflow gap described by the queue audit and by Spec 353's follow-up note.
**Independent Test**: Seed valid, expiring, and expired exceptions; open the queue; confirm one dominant guidance case, one dominant next-step affordance, and only existing repo-backed secondary context.
**Acceptance Scenarios**:
1. **Given** an accepted risk is expiring soon, **When** the queue renders, **Then** the page states that review is required, explains the impact, and offers one dominant next-step affordance.
2. **Given** an accepted risk already expired, **When** the queue or detail renders, **Then** the page states that the prior governance is no longer current and offers a repo-backed next action.
### User Story 2 - Review or complete accepted-risk details from the owner surface (Priority: P1)
As an environment governance operator, I can open the accepted-risk detail and understand whether owner, rationale, expiry, or governance support is missing before I decide to renew or revoke.
**Why this priority**: Accepted-risk lifecycle actions are already repo-real, but their decision context still needs first-screen hierarchy.
**Independent Test**: Seed exceptions missing owner/rationale/review due dates or missing support states; open the detail page; confirm the page explains the issue before deeper diagnostics and preserves current action safety rules.
**Acceptance Scenarios**:
1. **Given** an accepted risk is missing owner or rationale, **When** detail renders, **Then** the page explains that customer-safe reliance is incomplete and routes the operator to the existing owner surface action path.
2. **Given** an accepted risk can be renewed or revoked, **When** detail renders, **Then** the page keeps those actions available only when the current capability and state permit them, with current confirmation behavior unchanged.
### User Story 3 - Understand downstream review impact without exposing internal rationale (Priority: P2)
As an operator preparing customer-safe review output, I can tell whether an accepted risk affects downstream review wording or evidence trust without exposing internal-only detail by default.
**Why this priority**: Accepted risks are customer-relevant, but Spec 354 must not leak internal governance detail into customer-safe output paths.
**Independent Test**: Seed a review/output context with accepted-risk follow-up; verify queue/detail surfaces reuse conservative wording as owner-surface context only and do not require downstream artifact changes.
**Acceptance Scenarios**:
1. **Given** an accepted risk is included in current review-output truth, **When** the owner surfaces render, **Then** they expose a conservative downstream impact message without requiring a new downstream route or artifact change.
2. **Given** internal rationale or low-level diagnostics exist, **When** the operator views the accepted-risk guidance, **Then** those details remain secondary and are not treated as customer-safe summary text.
### User Story 4 - Calm ready state when no accepted-risk action is required (Priority: P3)
As an operator, when the current accepted-risk record is governed and needs no urgent action, I want the surface to stay calm and not render another warning wall.
**Why this priority**: Guidance should reduce noise, not replace one alert stack with another.
**Independent Test**: Seed a governed, valid accepted risk and verify queue/detail show a calm ready state with non-urgent secondary context.
**Acceptance Scenarios**:
1. **Given** the accepted risk is valid, complete, and not near expiry, **When** the surface renders, **Then** it shows a calm ready state and no urgent warning copy.
## Edge Cases
- No accepted risk needs action.
- Accepted risk is pending approval or pending renewal.
- Accepted risk is expiring.
- Accepted risk is expired.
- Accepted risk was revoked or rejected.
- Existing exception record carries `missing_support` or fresh-decision-required governance follow-up.
- Accepted risk is missing owner, rationale, or review due date.
- Conservative downstream wording reference exists, but the owner surface still keeps downstream artifacts unchanged in this slice.
- Environment filter is present and matches no accepted-risk records.
- Queue selection points at an out-of-scope or no-longer-visible exception.
- Finding changed after the exception decision only when the repo can prove it; unsupported stale-detection cases must be omitted rather than invented.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-354-001**: The queue and detail owner surfaces MUST derive accepted-risk guidance from existing `FindingException`, `FindingExceptionDecision`, `Finding`, and resolver truth; no new persistence is allowed.
- **FR-354-002**: `FindingExceptionsQueue` MUST show one dominant accepted-risk guidance case with title, reason, impact, one dominant next-step affordance, and only existing repo-backed secondary context or links.
- **FR-354-003**: `ViewFindingException` MUST show one dominant accepted-risk guidance case while preserving the current source-owned approve/reject/renew/revoke lifecycle behavior, current confirmation rules, and current infolist-owned detail hierarchy.
- **FR-354-004**: Guidance MUST distinguish, where repo-backed on existing `FindingException` owner surfaces, between at least these operator-meaningful cases: valid, expiring, expired, revoked, rejected, pending, missing governance support on an existing exception record, fresh decision required, and missing owner/rationale/review support.
- **FR-354-005**: The surface MUST preserve the current repo-real fresh-decision warning path driven by `FindingException::requiresFreshDecisionForFinding()` and `FindingRiskGovernanceResolver`; this slice MUST NOT invent broader stale-governance semantics beyond that existing signal.
- **FR-354-006**: Primary and secondary actions MUST remain repo-backed and truthful. Unsupported auto-fix or synthetic remediation buttons are forbidden.
- **FR-354-007**: Queue and detail guidance MUST preserve current environment/workspace scope and current route ownership. No hidden context, legacy query alias, or cross-tenant shortcut may become authority.
- **FR-354-008**: Customer-safe wording reference used on the owner surfaces MUST remain conservative and MUST NOT expose raw internal rationale, copied context, provider diagnostics, or internal-only reason families by default.
- **FR-354-009**: Existing high-impact actions remain source-owned and MUST continue to use server-side authorization, explicit confirmation, audit logging, and current notification behavior.
- **FR-354-010**: `FindingExceptionResource` MUST remain not globally searchable.
- **FR-354-011**: Queue and detail surfaces SHOULD only expose related context or links that are already present and scope-safe on the current owner surfaces; this slice does not add new downstream review, audit, or operation-proof destinations.
### Non-Functional Requirements
- **NFR-354-001**: No Graph or provider HTTP calls may be introduced during queue/detail render.
- **NFR-354-002**: No database migration, env var, queue family, scheduler, storage, or panel/provider change is allowed in this slice.
- **NFR-354-003**: Guidance copy must stay operator-first and conservative; when certainty is low, the UI must prefer `requires review` over `safe` language.
- **NFR-354-004**: The implementation must reuse the smallest honest test mix: unit for guidance selection, feature/Livewire for queue/detail integration, and one bounded browser smoke for the strategic queue/detail surfaces.
## Acceptance Criteria
### Product
- [ ] Queue renders one dominant accepted-risk guidance case with one dominant next-step affordance.
- [ ] Detail renders one dominant accepted-risk guidance case without replacing current source-owned lifecycle actions.
- [ ] Expiring, expired, missing-support, and incomplete accepted-risk states map to distinct operator guidance where repo-backed.
- [ ] Calm ready state remains calm and does not show urgent language when no action is required.
### Truth / Safety
- [ ] No new persisted accepted-risk truth or workflow engine is introduced.
- [ ] No fake remediation or unsupported action is surfaced.
- [ ] Customer-safe downstream wording remains conservative and does not expose internal-only detail by default.
- [ ] Existing destructive/high-impact action safety posture remains intact.
### RBAC / Isolation
- [ ] Queue and detail remain workspace/environment scoped and deny out-of-scope access as not found.
- [ ] Action visibility and executability continue to follow existing capability and policy boundaries.
- [ ] Secondary proof links remain scope-safe.
### Validation
- [ ] Focused unit tests cover accepted-risk guidance selection cases, including the existing fresh-decision-required signal.
- [ ] Focused feature/Livewire tests cover queue/detail hierarchy, scope, and action truthfulness.
- [ ] One bounded browser smoke proves first-screen hierarchy for queue and detail.
## Risks
- **Risk 1 - Guidance duplication**: queue and detail may drift from Governance Inbox and existing conservative review-output wording. Mitigation: reuse existing resolver/guidance contract and keep downstream review artifacts unchanged in this slice.
- **Risk 2 - Customer-safe leakage**: accepted-risk detail may accidentally expose internal-only rationale too early. Mitigation: keep raw/support detail secondary and test default-visible content explicitly.
- **Risk 3 - Fake remediation pressure**: productization may tempt unsupported "fix now" actions. Mitigation: restrict actions to current repo-backed routes and source-owned lifecycle actions only.
- **Risk 4 - Scope creep into a governance workbench rebuild**: mitigation is to keep queue/detail owner surfaces primary and defer any broader Governance Inbox or dashboard rethink.
## Spec Artifacts *(required for this package)*
- `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/spec.md`
- `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/plan.md`
- `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/tasks.md`
- `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/repo-truth-map.md`
- `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/contracts/accepted-risk-guidance-signal-map.md`
- `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/checklists/requirements.md`
## Follow-Up Candidates
- broader Governance Inbox follow-through after the accepted-risk owner surfaces are calm and decision-first
- customer-facing localization and copy hardening across review-output accepted-risk language
- wider governance artifact lifecycle follow-through once accepted-risk operator guidance is stable
## Assumptions
- Existing `FindingRiskGovernanceResolver` plus current queue/detail state are sufficient to derive the dominant accepted-risk guidance case without new persistence.
- Existing queue/detail actions and current owner-surface related context are the authoritative safe action set for this slice.
- The current fresh-decision-required signal exposed by `FindingException::requiresFreshDecisionForFinding()` is in-scope and must be preserved, but broader stale-governance expansion remains out of scope.
## Open Questions
No blocking preparation questions remain.
Implementation should still confirm one bounded runtime choice during tests-first work:
- whether any owner-surface wording needs a narrower local copy adjustment while leaving downstream review-output artifacts unchanged

View File

@ -0,0 +1,143 @@
# Tasks: Spec 354 - Finding Exceptions / Accepted Risk Resolution Guidance v1
**Input**: `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/spec.md`, `plan.md`, `repo-truth-map.md`, `contracts/accepted-risk-guidance-signal-map.md`, and `checklists/requirements.md`
**Tests**: Required. This spec changes strategic accepted-risk operator guidance on existing queue and detail owner surfaces.
## Test Governance Checklist
- [x] Lane assignment is explicit and narrow: Unit for guidance selection, Feature/Livewire for queue/detail integration, Browser for first-screen hierarchy.
- [x] New or changed tests stay in the smallest honest family, and the browser addition is explicit.
- [x] Shared helpers, factories, seeds, and context defaults stay cheap by default.
- [x] Planned validation commands cover the slice without pulling in unrelated lane cost.
- [x] The changed surfaces are explicit strategic/detail accepted-risk surfaces, not an infra-only refactor.
- [x] No new persisted accepted-risk truth, workflow engine, or provider/platform abstraction is planned.
## Phase 1: Preparation And Repo Truth
**Purpose**: Keep the implementation bounded to the existing accepted-risk owner surfaces and recorded draft-to-repo deviations.
- [x] T001 Re-read `spec.md`, `plan.md`, `tasks.md`, `repo-truth-map.md`, `contracts/accepted-risk-guidance-signal-map.md`, and `checklists/requirements.md`.
- [x] T002 Re-verify the current runtime truth in `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php`, and `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`.
- [x] T003 Re-confirm the current repo constraints recorded in `repo-truth-map.md`: no new accepted-risk model, no new queue family, no global-search change, no standalone customer-facing risk page.
- [x] T004 Confirm no migration, package, env var, queue family, scheduler, storage, panel/provider, or `filament:assets` deployment change is required.
- [x] T005 Keep `repo-truth-map.md` and `contracts/accepted-risk-guidance-signal-map.md` current if runtime inspection proves a narrower or broader safe slice.
## Phase 2: Tests First
**Purpose**: Lock decision hierarchy, scope, and no-fake-action behavior before runtime changes.
- [x] T006 Add `apps/platform/tests/Unit/ResolutionGuidance/Spec354AcceptedRiskResolutionAdapterTest.php`.
- [x] T007 Add unit assertions for `accepted_risk.ready`.
- [x] T008 Add unit assertions for `accepted_risk.expiring`.
- [x] T009 Add unit assertions for `accepted_risk.expired`.
- [x] T010 Add unit assertions for revoked and rejected support.
- [x] T011 Add unit assertions for pending and renewal-requested states.
- [x] T012 Add unit assertions for missing governance support on an existing exception record.
- [x] T013 Add unit assertions for incomplete governance support (missing owner/rationale/review support).
- [x] T014 Add unit assertions for the current fresh-decision-required signal and for conservative owner-surface wording reuse without mutating downstream review-output artifacts.
- [x] T015 Add a guard assertion proving accepted-risk guidance selection stays DB-local and does not require live provider or Graph calls.
- [x] T016 Add `apps/platform/tests/Feature/Monitoring/Spec354FindingExceptionsQueueGuidanceTest.php`.
- [x] T017 Add feature/Livewire assertions that `FindingExceptionsQueue` shows one dominant accepted-risk case with one dominant next-step affordance.
- [x] T018 Add feature/Livewire assertions that only existing repo-backed related context is rendered and unsupported auto-fix buttons are absent.
- [x] T019 Add feature/Livewire assertions that queue links remain workspace/environment scoped, preserve explicit `environment_id` behavior, and keep out-of-scope queue access as 404.
- [x] T020 Add feature/Livewire assertions that the queue keeps current approve/reject action safety intact.
- [x] T021 Add `apps/platform/tests/Feature/Findings/Spec354FindingExceptionDetailGuidanceTest.php`.
- [x] T022 Add feature/Livewire assertions that `ViewFindingException` and its infolist render one dominant accepted-risk guidance case before deeper diagnostics.
- [x] T023 Add feature/Livewire assertions that renew/revoke stay state- and capability-bound and keep existing confirmation behavior.
- [x] T024 Add feature/Livewire assertions that owner/rationale/expiry or review support gaps are visible before decision history and deeper evidence, and that member-but-missing-capability behavior stays aligned with current detail semantics.
- [x] T025 Add a continuity assertion in the narrowest honest family for Governance Inbox `Review accepted risk` routing into the owner surface.
- [x] T026 Add `apps/platform/tests/Browser/Spec354AcceptedRiskGuidanceSmokeTest.php`.
- [x] T027 Browser Flow A: expiring accepted-risk queue state shows one dominant blocker and one dominant next-step affordance.
- [x] T028 Browser Flow B: expired, revoked, or fresh-decision-required accepted-risk state shows a conservative operator affordance and only existing supporting context.
- [x] T029 Browser Flow C: incomplete governance support shows missing owner/rationale/review context before deep diagnostics.
- [x] T030 Browser Flow D: calm valid state stays calm and does not render a competing warning stack.
## Phase 3: Derived Guidance Contract
**Purpose**: Build the narrowest derived accepted-risk payload over existing finding and exception truth.
- [x] T031 Choose the narrowest implementation shape: prefer one bounded accepted-risk adapter or selector under `apps/platform/app/Support/ResolutionGuidance/`.
- [x] T032 Consume existing signals from `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php`, `FindingException`, `FindingExceptionDecision`, and linked `Finding` truth before adding any new helper.
- [x] T033 Derive one accepted-risk guidance payload with `key`, `title`, `status`, `severity`, `reason`, `impact`, `primary_action`, `secondary_actions`, and `technical_details`, while preserving the existing fresh-decision-required signal and avoiding any broader stale-governance invention.
- [x] T034 Keep blocker priority explicit: missing support -> fresh decision required -> expired/revoked/rejected -> expiring -> incomplete governance support -> pending/renewal -> ready.
- [x] T035 Keep the derived guidance DB-local and request-scoped only; no new persistence.
- [x] T036 Do not introduce a new accepted-risk enum family, workflow engine, or review-impact framework in this slice.
## Phase 4: Queue Integration
**Purpose**: Make `FindingExceptionsQueue` read as an accepted-risk decision destination without removing current queue truth.
- [x] T037 Integrate the derived guidance into `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` while preserving explicit inspect/open behavior and current selected-record state.
- [x] T038 Update `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php` and the focused-review partial so the dominant guidance case appears before secondary diagnostics and existing related context.
- [x] T039 Reuse existing repo-backed primary and secondary targets where appropriate: inspect accepted risk, approve/reject current request, open finding, and existing related context only.
- [x] T040 Preserve current destructive/high-impact actions exactly as confirmation-, authorization-, and audit-protected secondary actions.
- [x] T041 Do not let guidance visibility widen action authorization or scope.
## Phase 5: Detail Integration
**Purpose**: Make `ViewFindingException` decision-first while keeping lifecycle ownership on the existing detail page.
- [x] T042 Integrate the derived guidance into `apps/platform/app/Filament/Resources/FindingExceptionResource.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`.
- [x] T043 Keep existing owner/rationale/expiry/review data visible before decision history or deep evidence.
- [x] T044 Reuse current repo-backed actions (`renew_exception`, `revoke_exception`) and keep them source-owned.
- [x] T045 Keep decision history, evidence references, and related context secondary.
- [x] T046 Preserve `FindingExceptionResource` global-search-disabled posture and current action-surface discipline.
## Phase 6: Continuity And Conservative Wording
**Purpose**: Keep downstream accepted-risk continuity honest without turning other surfaces into second owner surfaces.
- [x] T047 Adjust `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` only if accepted-risk label/target continuity is inconsistent after queue/detail guidance becomes decision-first.
- [x] T048 Reuse existing conservative accepted-risk wording as owner-surface reference only and do not mutate `EnvironmentReviewComposer` or current review-output consumers in this slice.
- [x] T049 Keep customer-safe wording reference conservative and avoid exposing raw internal rationale or low-level diagnostics as default-visible summary text.
## Phase 7: Copy, Audit, And Artifacts
**Purpose**: Align user-facing wording and UI audit coverage with the new accepted-risk hierarchy.
- [x] T050 Update only the required copy in `apps/platform/lang/en/localization.php`.
- [x] T051 Update matching copy in `apps/platform/lang/de/localization.php`.
- [x] T052 Update `docs/ui-ux-enterprise-audit/page-reports/ui-012-finding-exceptions-queue.md`.
- [x] T053 Create or update `docs/ui-ux-enterprise-audit/page-reports/ui-036-exception-detail.md`.
- [x] T054 Update `docs/ui-ux-enterprise-audit/route-inventory.md` and `docs/ui-ux-enterprise-audit/unresolved-pages.md` for `UI-036`.
- [x] T055 Save queue and detail screenshots under `specs/354-finding-exceptions-accepted-risk-resolution-guidance-v1/artifacts/screenshots/`, or record the host-visible artifact blocker explicitly if copies cannot be persisted.
## Phase 8: Validation
**Purpose**: Prove the guidance remains bounded, scope-safe, and render-local.
- [x] T056 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/ResolutionGuidance/Spec354AcceptedRiskResolutionAdapterTest.php --compact`.
- [x] T057 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Monitoring/Spec354FindingExceptionsQueueGuidanceTest.php --compact`.
- [x] T058 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Findings/Spec354FindingExceptionDetailGuidanceTest.php --compact`.
- [ ] T059 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec354AcceptedRiskGuidanceSmokeTest.php --compact`.
Attempted twice; the Pest browser harness stalled without yielding output even after the auth/session fixes, so the browser acceptance path was re-verified in the integrated browser and artifact screenshots were saved manually.
- [x] T060 Re-run the narrowest current queue/detail guard and navigation tests that protect scope, state, action-surface discipline, and current fresh-decision signaling.
- [x] T061 Confirm final render paths remain DB-local and do not call `GraphClientInterface` or provider HTTP during page render.
- [x] T062 Run `cd apps/platform && ./vendor/bin/sail pint --dirty`.
- [x] T063 Run `git diff --check`.
- [x] T064 Report unrelated broader-suite or browser-harness issues honestly if they remain outside this slice.
## Non-Goals Checklist
- [x] NT001 Do not add a new accepted-risk table, review-impact projection, or workflow engine.
- [x] NT002 Do not rebuild Governance Inbox, Customer Review Workspace, Environment Dashboard, or review-output architecture.
- [x] NT003 Do not add fake remediation or unsupported auto-fix actions.
- [x] NT004 Do not widen `FindingExceptionResource` global search, panel setup, or routing architecture.
- [x] NT005 Do not introduce live provider calls during render.
- [x] NT006 Do not mutate downstream review-output artifacts (`EnvironmentReviewComposer`, review-pack summaries, customer-review runtime) in this slice.
## Required Final Report Content
When implementation later completes, report:
- changed accepted-risk guidance behavior on queue and detail
- dominant-case selection model
- continuity behavior for Governance Inbox or review-output wording if changed
- safe action set and any disabled or fallback cases
- render-path result for no live provider calls
- UI audit artifact updates and screenshot paths
- files changed
- tests run and results
- explicit no migrations/packages/env/queues/scheduler/storage/panel/global-search change statement
- known gaps or deferred findings