diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 1172cc06..ea0db69c 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -34,6 +34,7 @@ use App\Support\OperationRunLinks; use App\Support\ReviewPackStatus; use App\Support\ReviewPacks\ReviewPackOutputReadiness; +use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -373,6 +374,12 @@ public function latestReviewConsumptionPayload(): ?array $reviewUrl = $this->latestReviewUrl($tenant); $evidenceUrl = $this->evidenceSnapshotUrlForReview($review, $tenant); $outputReadiness = $this->reviewPackOutputReadinessForReview($review); + $outputGuidance = $this->reviewOutputGuidanceForReview( + review: $review, + downloadUrl: $downloadUrl, + reviewUrl: $reviewUrl, + evidenceUrl: $evidenceUrl, + ); $decision = $this->decisionSummaryForReview($review); $acceptedRisks = $this->acceptedRisksForReview($review); $hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); @@ -383,6 +390,7 @@ public function latestReviewConsumptionPayload(): ?array review: $review, packageAvailability: $packageAvailability, outputReadiness: $outputReadiness, + outputGuidance: $outputGuidance, downloadUrl: $downloadUrl, reviewUrl: $reviewUrl, evidenceUrl: $evidenceUrl, @@ -405,7 +413,7 @@ public function latestReviewConsumptionPayload(): ?array 'package_color' => $this->governancePackageAvailabilityColor($tenant), 'package_description' => $packageAvailability['description'], 'primary_action_label' => $downloadUrl !== null - ? __('localization.review.download_review_pack') + ? $outputGuidance['qualified_download_label'] : __('localization.review.open_latest_review'), 'primary_action_url' => $downloadUrl ?? $reviewUrl, 'primary_action_icon' => $downloadUrl !== null @@ -622,6 +630,7 @@ private function reviewScopePayload(ManagedEnvironment $tenant): array /** * @param array{state:string,label:string,description:string} $packageAvailability * @param array $outputReadiness + * @param array $outputGuidance * @return array{ * question:string, * label:string, @@ -634,7 +643,9 @@ private function reviewScopePayload(ManagedEnvironment $tenant): array * primary_action_url:?string, * primary_action_icon:string, * secondary_action_label:?string, - * secondary_action_url:?string + * secondary_action_url:?string, + * secondary_actions:list, + * output_guidance:array * } */ private function reviewReadinessForTenant( @@ -642,6 +653,7 @@ private function reviewReadinessForTenant( EnvironmentReview $review, array $packageAvailability, array $outputReadiness, + array $outputGuidance, ?string $downloadUrl, ?string $reviewUrl, ?string $evidenceUrl, @@ -664,28 +676,62 @@ private function reviewReadinessForTenant( reviewUrl: $reviewUrl, evidenceUrl: $evidenceUrl, ); + $followUpOverride = in_array($reasonCode, ['findings_follow_up_required', 'accepted_risk_follow_up_required'], true); + $secondaryActions = $followUpOverride + ? collect([ + $actions['secondary_url'] !== null && $actions['secondary_label'] !== null + ? [ + 'label' => $actions['secondary_label'], + 'url' => $actions['secondary_url'], + 'kind' => 'environment_link', + 'icon' => 'heroicon-o-arrow-top-right-on-square', + ] + : null, + ])->filter()->values()->all() + : (is_array($outputGuidance['secondary_actions'] ?? null) ? $outputGuidance['secondary_actions'] : []); + $primaryAction = $followUpOverride + ? [ + 'label' => $actions['primary_label'], + 'url' => $actions['primary_url'], + 'icon' => $actions['primary_icon'], + ] + : (is_array($outputGuidance['primary_action'] ?? null) ? $outputGuidance['primary_action'] : null); return [ 'question' => __('localization.review.review_pack_output_status'), - 'label' => $this->workspaceReadinessLabel($effectiveState), - 'color' => $this->workspaceReadinessColor($effectiveState), - 'boundary_label' => $this->workspaceBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')), - 'boundary_color' => $this->workspaceBoundaryColor((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')), - 'reason' => $this->workspaceReadinessReason( - reasonCode: $reasonCode, - outputReadiness: $outputReadiness, - findingPanel: $findingPanel, - packageAvailability: $packageAvailability, - ), - 'impact' => $this->workspaceReadinessImpact( - state: $effectiveState, - reasonCode: $reasonCode, - ), - 'primary_action_label' => $actions['primary_label'], - 'primary_action_url' => $actions['primary_url'], - 'primary_action_icon' => $actions['primary_icon'], - 'secondary_action_label' => $actions['secondary_label'], - 'secondary_action_url' => $actions['secondary_url'], + 'label' => $followUpOverride + ? $this->workspaceReadinessLabel($effectiveState) + : (string) ($outputGuidance['label'] ?? $this->workspaceReadinessLabel($effectiveState)), + 'color' => $followUpOverride + ? $this->workspaceReadinessColor($effectiveState) + : (string) ($outputGuidance['color'] ?? $this->workspaceReadinessColor($effectiveState)), + 'boundary_label' => $followUpOverride + ? $this->workspaceBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')) + : (string) ($outputGuidance['boundary_label'] ?? $this->workspaceBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review'))), + 'boundary_color' => $followUpOverride + ? $this->workspaceBoundaryColor((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')) + : (string) ($outputGuidance['boundary_color'] ?? $this->workspaceBoundaryColor((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review'))), + 'reason' => $followUpOverride + ? $this->workspaceReadinessReason( + reasonCode: $reasonCode, + outputReadiness: $outputReadiness, + findingPanel: $findingPanel, + packageAvailability: $packageAvailability, + ) + : (string) ($outputGuidance['primary_reason'] ?? $packageAvailability['description']), + 'impact' => $followUpOverride + ? $this->workspaceReadinessImpact( + state: $effectiveState, + reasonCode: $reasonCode, + ) + : (string) ($outputGuidance['impact'] ?? $this->workspaceReadinessImpact(state: $effectiveState, reasonCode: $reasonCode)), + 'primary_action_label' => (string) ($primaryAction['label'] ?? $actions['primary_label']), + 'primary_action_url' => $primaryAction['url'] ?? $actions['primary_url'], + 'primary_action_icon' => (string) ($primaryAction['icon'] ?? $actions['primary_icon']), + 'secondary_action_label' => $secondaryActions[0]['label'] ?? null, + 'secondary_action_url' => $secondaryActions[0]['url'] ?? null, + 'secondary_actions' => $secondaryActions, + 'output_guidance' => $outputGuidance, ]; } @@ -2248,29 +2294,33 @@ private function evidenceSnapshotUrlForReview(EnvironmentReview $review, Managed */ private function reviewPackOutputReadinessForReview(EnvironmentReview $review): array { - $review->loadMissing(['sections', 'evidenceSnapshot', 'currentExportReviewPack']); + return ReviewPackOutputResolutionGuidance::readinessForReview($review); + } - $pack = $review->currentExportReviewPack; - $snapshot = $review->evidenceSnapshot; - $summary = is_array($review->summary) ? $review->summary : []; - $sections = $this->reviewPackOutputSections($review, $pack); - $sectionStateCounts = $this->reviewPackSectionStateCounts($sections); - $requiredSections = $sections->filter(static fn (mixed $section): bool => (bool) $section->required)->values(); + /** + * @return array + */ + private function reviewOutputGuidanceForReview( + EnvironmentReview $review, + ?string $downloadUrl, + ?string $reviewUrl, + ?string $evidenceUrl, + ): array { + $operationUrl = null; + $operationRun = $review->currentExportReviewPack?->operationRun ?? $review->operationRun; - return ReviewPackOutputReadiness::derive( - reviewStatus: (string) $review->status, - reviewCompletenessState: (string) $review->completeness_state, - evidenceCompletenessState: $snapshot instanceof EvidenceSnapshot - ? (string) $snapshot->completeness_state - : EnvironmentReviewCompletenessState::Missing->value, - sectionStateCounts: $sectionStateCounts, - requiredSectionCount: $requiredSections->count(), - requiredSectionStateCounts: $this->reviewPackSectionStateCounts($requiredSections), - publishBlockers: is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], - hasReadyExport: $this->reviewPackHasReadyExport($pack), - includePii: (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_pii'] ?? true) : true), - protectedValuesHidden: true, - disclosurePresent: $this->reviewPackDisclosurePresent($review), + if ($operationRun instanceof OperationRun) { + $operationUrl = OperationRunLinks::tenantlessView((int) $operationRun->getKey()); + } + + return ReviewPackOutputResolutionGuidance::fromReadiness( + $this->reviewPackOutputReadinessForReview($review), + [ + 'download' => $downloadUrl, + 'review' => $reviewUrl, + 'evidence' => $evidenceUrl, + 'operation' => $operationUrl, + ], ); } @@ -2291,34 +2341,6 @@ private function reviewPackHasReadyExport(?ReviewPack $pack): bool return filled($pack->file_path) && filled($pack->file_disk); } - private function reviewPackIncludesOperations(?ReviewPack $pack): bool - { - return (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_operations'] ?? true) : true); - } - - private function reviewPackOutputSections(EnvironmentReview $review, ?ReviewPack $pack): Collection - { - return $review->sections - ->filter(fn (mixed $section): bool => $this->reviewPackIncludesOperations($pack) || $section->section_key !== 'operations_health') - ->values(); - } - - /** - * @return array - */ - private function reviewPackSectionStateCounts(Collection $sections): array - { - return $sections - ->countBy(static fn (mixed $section): string => (string) $section->completeness_state) - ->map(static fn (int $count): int => max(0, $count)) - ->all(); - } - - private function reviewPackDisclosurePresent(EnvironmentReview $review): bool - { - return true; - } - /** * @param array $outputReadiness */ diff --git a/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php b/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php index b02dc5a2..ad5017a1 100644 --- a/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php +++ b/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php @@ -33,6 +33,7 @@ use App\Support\Rbac\UiEnforcement; use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReviewPackStatus; +use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -200,6 +201,7 @@ public static function infolist(Schema $schema): Schema Section::make(__('localization.review.review')) ->schema([ TextEntry::make('status') + ->label(__('localization.review.review_status')) ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EnvironmentReviewStatus)) ->color(BadgeRenderer::color(BadgeDomain::EnvironmentReviewStatus)) @@ -237,6 +239,15 @@ public static function infolist(Schema $schema): Schema ]) ->columns(2) ->columnSpanFull(), + Section::make(__('localization.review.output_guidance')) + ->schema([ + ViewEntry::make('output_guidance') + ->hiddenLabel() + ->view('filament.infolists.entries.review-pack-output-guidance') + ->state(fn (EnvironmentReview $record): array => static::outputGuidanceState($record)) + ->columnSpanFull(), + ]) + ->columnSpanFull(), Section::make(__('localization.review.executive_posture')) ->schema([ ViewEntry::make('review_summary') @@ -1032,4 +1043,87 @@ private static function appendQuery(string $url, array $query): string return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); } + + /** + * @return array + */ + public static function outputGuidanceState(EnvironmentReview $record): array + { + $tenant = $record->tenant; + $user = auth()->user(); + $reviewUrl = $tenant instanceof ManagedEnvironment + ? static::environmentScopedUrl('view', ['record' => $record], $tenant) + : null; + $evidenceUrl = null; + $operationUrl = null; + + if (static::isCustomerWorkspaceMode() && $reviewUrl !== null) { + $reviewUrl = static::appendQuery($reviewUrl, [ + CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1, + ]); + } + + if ($record->evidenceSnapshot instanceof EvidenceSnapshot && $tenant instanceof ManagedEnvironment && $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) { + $evidenceUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $tenant); + + if (static::isCustomerWorkspaceMode()) { + $evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($record)); + } + } + + $operationRun = $record->currentExportReviewPack?->operationRun ?? $record->operationRun; + + if ($operationRun instanceof \App\Models\OperationRun) { + $operationUrl = OperationRunLinks::tenantlessView((int) $operationRun->getKey()); + } + + $guidance = ReviewPackOutputResolutionGuidance::fromReview($record, [ + 'download' => static::currentReviewPackDownloadUrlFor($record), + 'review' => $reviewUrl, + 'evidence' => $evidenceUrl, + 'operation' => $operationUrl, + ]); + + if (! static::isCustomerWorkspaceMode()) { + return $guidance; + } + + $guidance['detail_mode'] = true; + $guidance['primary_action'] = null; + $guidance['secondary_actions'] = []; + $guidance['next_step_label'] = __('localization.review.review_limitations_below'); + $guidance['context_note'] = __('localization.review.output_guidance_detail_mode_note'); + + return $guidance; + } + + public static function currentReviewPackDownloadUrlFor(EnvironmentReview $record): ?string + { + $pack = $record->currentExportReviewPack; + $tenant = $record->tenant; + $user = auth()->user(); + + if (! $pack instanceof ReviewPack || ! $tenant instanceof ManagedEnvironment || ! $user instanceof User) { + return null; + } + + if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) { + return null; + } + + if ($pack->status !== ReviewPackStatus::Ready->value) { + return null; + } + + if ($pack->expires_at !== null && $pack->expires_at->isPast()) { + return null; + } + + return app(ReviewPackService::class)->generateDownloadUrl($pack, [ + 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, + 'review_id' => (int) $record->getKey(), + 'tenant_filter_id' => request()->query('tenant_filter_id'), + 'interpretation_version' => $record->controlInterpretationVersion(), + ]); + } } diff --git a/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php b/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php index 5bfeac60..ec5b9b5c 100644 --- a/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php +++ b/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php @@ -355,7 +355,11 @@ private function archiveReviewAction(): Actions\Action private function downloadCurrentReviewPackAction(): Actions\Action { return Actions\Action::make('download_current_review_pack') - ->label(__('localization.review.download_governance_package')) + ->label(function (): string { + $guidance = EnvironmentReviewResource::outputGuidanceState($this->record); + + return (string) ($guidance['qualified_download_label'] ?? __('localization.review.download_governance_package')); + }) ->icon('heroicon-o-arrow-down-tray') ->color('primary') ->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null) @@ -366,32 +370,7 @@ private function downloadCurrentReviewPackAction(): Actions\Action private function currentReviewPackDownloadUrl(): ?string { - $pack = $this->record->currentExportReviewPack; - $tenant = $this->record->tenant; - $user = auth()->user(); - - if (! $pack instanceof ReviewPack || ! $tenant instanceof ManagedEnvironment || ! $user instanceof User) { - return null; - } - - if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) { - return null; - } - - if ($pack->status !== ReviewPackStatus::Ready->value) { - return null; - } - - if ($pack->expires_at !== null && $pack->expires_at->isPast()) { - return null; - } - - return app(ReviewPackService::class)->generateDownloadUrl($pack, [ - 'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE, - 'review_id' => (int) $this->record->getKey(), - 'tenant_filter_id' => request()->query('tenant_filter_id'), - 'interpretation_version' => $this->record->controlInterpretationVersion(), - ]); + return EnvironmentReviewResource::currentReviewPackDownloadUrlFor($this->record); } private function currentReviewPackUnavailableReason(): ?string diff --git a/apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php b/apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php new file mode 100644 index 00000000..f5f6b750 --- /dev/null +++ b/apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php @@ -0,0 +1,576 @@ + + */ + public static function fromReview(EnvironmentReview $review, array $urls = []): array + { + return self::fromReadiness(self::readinessForReview($review), $urls); + } + + /** + * @return array + */ + public static function readinessForReview(EnvironmentReview $review): array + { + $review->loadMissing(['sections', 'evidenceSnapshot', 'currentExportReviewPack']); + + $pack = $review->currentExportReviewPack; + $snapshot = $review->evidenceSnapshot; + $summary = is_array($review->summary) ? $review->summary : []; + $sections = self::outputSections($review, $pack); + $requiredSections = $sections + ->filter(static fn (mixed $section): bool => (bool) $section->required) + ->values(); + + return ReviewPackOutputReadiness::derive( + reviewStatus: (string) $review->status, + reviewCompletenessState: (string) $review->completeness_state, + evidenceCompletenessState: $snapshot instanceof EvidenceSnapshot + ? (string) $snapshot->completeness_state + : EnvironmentReviewCompletenessState::Missing->value, + sectionStateCounts: self::sectionStateCounts($sections), + requiredSectionCount: $requiredSections->count(), + requiredSectionStateCounts: self::sectionStateCounts($requiredSections), + publishBlockers: is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], + hasReadyExport: self::hasReadyExport($pack), + includePii: (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_pii'] ?? true) : true), + protectedValuesHidden: true, + disclosurePresent: true, + ); + } + + /** + * @param array $readiness + * @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls + * @return array{ + * state:string, + * label:string, + * status_label:string, + * color:string, + * severity:string, + * boundary_state:string, + * boundary_label:string, + * boundary_color:string, + * primary_reason:string, + * impact:string, + * qualified_download_label:string, + * limitation_count:int, + * limitation_summary:?string, + * action_help:?string, + * primary_action:array{label:string,url:?string,kind:string,icon:string}|null, + * secondary_actions:list, + * limitations:list}>, + * technical_details:array + * } + */ + public static function fromReadiness(array $readiness, array $urls = []): array + { + $limitations = self::limitations($readiness, $urls); + $state = self::state($readiness, $limitations); + $primaryLimitation = $limitations[0] ?? null; + $primaryAction = self::primaryAction( + state: $state, + primaryLimitationKey: is_array($primaryLimitation) ? (string) $primaryLimitation['key'] : null, + urls: $urls, + ); + $secondaryActions = self::secondaryActions($state, $primaryAction, $urls); + $boundaryState = self::boundaryState($state, $readiness); + + return [ + 'state' => $state, + 'label' => self::label($state), + 'status_label' => self::statusLabel($state), + 'color' => self::color($state), + 'severity' => self::severity($state), + 'boundary_state' => $boundaryState, + 'boundary_label' => self::boundaryLabel($boundaryState), + 'boundary_color' => self::boundaryColor($boundaryState), + 'primary_reason' => is_array($primaryLimitation) + ? self::shortReason((string) $primaryLimitation['key']) + : self::defaultPrimaryReason($state), + 'impact' => self::impact($state), + 'qualified_download_label' => self::qualifiedDownloadLabel($state), + 'limitation_count' => count($limitations), + 'limitation_summary' => $limitations === [] + ? null + : trans_choice('localization.review.output_limitations_summary', count($limitations), ['count' => count($limitations)]), + 'action_help' => self::actionHelp($state), + 'primary_action' => $primaryAction, + 'secondary_actions' => $secondaryActions, + 'limitations' => $limitations, + 'technical_details' => self::technicalDetails($readiness), + ]; + } + + private static function hasReadyExport(?ReviewPack $pack): bool + { + if (! $pack instanceof ReviewPack) { + return false; + } + + if ($pack->status !== ReviewPackStatus::Ready->value) { + return false; + } + + if ($pack->expires_at !== null && $pack->expires_at->isPast()) { + return false; + } + + return filled($pack->file_path) && filled($pack->file_disk); + } + + /** + * @return Collection + */ + private static function outputSections(EnvironmentReview $review, ?ReviewPack $pack): Collection + { + return $review->sections + ->filter(static fn (mixed $section): bool => self::includesOperations($pack) || $section->section_key !== 'operations_health') + ->values(); + } + + private static function includesOperations(?ReviewPack $pack): bool + { + return (bool) (is_array($pack?->options ?? null) ? ($pack->options['include_operations'] ?? true) : true); + } + + /** + * @param Collection $sections + * @return array + */ + private static function sectionStateCounts(Collection $sections): array + { + return $sections + ->countBy(static fn (mixed $section): string => (string) $section->completeness_state) + ->map(static fn (int $count): int => max(0, $count)) + ->all(); + } + + /** + * @param list,priority:int}> $limitations + */ + private static function state(array $readiness, array $limitations): string + { + $limitationKeys = collect($limitations)->pluck('key'); + + return match (true) { + $limitationKeys->contains('publish_blockers_present') => self::STATE_PUBLICATION_BLOCKED, + ! (bool) ($readiness['has_ready_export'] ?? false) => self::STATE_EXPORT_NOT_READY, + (bool) ($readiness['contains_pii'] ?? false) => self::STATE_INTERNAL_ONLY, + $limitations !== [] => self::STATE_PUBLISHED_WITH_LIMITATIONS, + (string) ($readiness['readiness_state'] ?? '') === ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => self::STATE_CUSTOMER_SAFE_READY, + default => self::STATE_UNKNOWN, + }; + } + + /** + * @param array $readiness + * @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls + * @return list,priority:int}> + */ + private static function limitations(array $readiness, array $urls): array + { + $sectionSummary = is_array($readiness['section_summary'] ?? null) ? $readiness['section_summary'] : []; + $sectionCounts = is_array($readiness['section_state_counts'] ?? null) ? $readiness['section_state_counts'] : []; + $limitations = collect(is_array($readiness['limitations'] ?? null) ? $readiness['limitations'] : []) + ->filter(static fn (mixed $limitation): bool => is_array($limitation) && is_string($limitation['code'] ?? null)) + ->map(function (array $limitation) use ($readiness, $sectionSummary, $sectionCounts, $urls): ?array { + $code = (string) $limitation['code']; + + return match ($code) { + 'publish_blockers_present' => [ + 'key' => $code, + 'label' => __('localization.review.publication_blocked'), + 'severity' => 'danger', + 'reason' => __('localization.review.publication_blocked_description'), + 'action' => self::action('resolve_review_blockers', $urls['review'] ?? $urls['evidence'] ?? null), + 'details' => [ + __('localization.review.technical_detail_review_status_value', ['value' => (string) ($readiness['review_status'] ?? __('localization.review.unavailable'))]), + ], + 'priority' => 100, + ], + 'export_not_ready' => [ + 'key' => $code, + 'label' => __('localization.review.export_not_ready'), + 'severity' => 'warning', + 'reason' => __('localization.review.export_not_ready_guidance_reason'), + 'action' => self::action('review_output_limitations', $urls['review'] ?? $urls['evidence'] ?? null), + 'details' => [ + __('localization.review.technical_detail_ready_export_value', ['value' => __('localization.review.no')]), + ], + 'priority' => 90, + ], + 'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => [ + 'key' => $code, + 'label' => __('localization.review.evidence_basis_incomplete_guidance'), + 'severity' => 'warning', + 'reason' => __('localization.review.evidence_basis_incomplete_guidance_reason'), + 'action' => self::action('open_evidence_basis', $urls['evidence'] ?? $urls['review'] ?? null), + 'details' => [ + __('localization.review.technical_detail_evidence_state_value', ['value' => (string) ($readiness['evidence_completeness_state'] ?? __('localization.review.unavailable'))]), + ], + 'priority' => match ($code) { + 'evidence_basis_missing' => 82, + 'evidence_basis_stale' => 81, + default => 80, + }, + ], + 'required_sections_incomplete' => [ + 'key' => $code, + 'label' => __('localization.review.required_review_sections_missing'), + 'severity' => 'warning', + 'reason' => trans_choice( + 'localization.review.required_review_sections_missing_reason', + (int) ($sectionSummary['required_limited'] ?? 0), + ['count' => (int) ($sectionSummary['required_limited'] ?? 0)], + ), + 'action' => self::action('review_section_limitations', $urls['review'] ?? $urls['evidence'] ?? null), + 'details' => [ + __('localization.review.technical_detail_section_counts_value', [ + 'complete' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Complete->value] ?? 0), + 'partial' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Partial->value] ?? 0), + 'missing' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Missing->value] ?? 0), + 'stale' => (int) ($sectionCounts[EnvironmentReviewCompletenessState::Stale->value] ?? 0), + ]), + ], + 'priority' => 70, + ], + 'contains_pii' => [ + 'key' => $code, + 'label' => __('localization.review.internal_package_includes_pii'), + 'severity' => 'warning', + 'reason' => __('localization.review.internal_package_includes_pii_reason'), + 'action' => self::action('review_pii_redaction_state', $urls['review'] ?? $urls['download'] ?? null), + 'details' => [ + __('localization.review.technical_detail_contains_pii_value', ['value' => __('localization.review.yes')]), + ], + 'priority' => 60, + ], + 'disclosure_missing' => [ + 'key' => $code, + 'label' => __('localization.review.output_disclosure_missing'), + 'severity' => 'warning', + 'reason' => __('localization.review.output_disclosure_missing_reason'), + 'action' => self::action('review_output_limitations', $urls['review'] ?? $urls['download'] ?? null), + 'details' => [ + __('localization.review.technical_detail_disclosure_present_value', ['value' => __('localization.review.no')]), + ], + 'priority' => 50, + ], + default => null, + }; + }) + ->filter() + ->sortByDesc('priority') + ->values() + ->all(); + + return $limitations; + } + + /** + * @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls + * @return array{label:string,url:?string,kind:string,icon:string}|null + */ + private static function primaryAction(string $state, ?string $primaryLimitationKey, array $urls): ?array + { + $actionKey = match ($primaryLimitationKey) { + 'publish_blockers_present' => 'resolve_review_blockers', + 'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => 'open_evidence_basis', + 'required_sections_incomplete' => 'review_section_limitations', + 'contains_pii' => 'review_pii_redaction_state', + 'disclosure_missing' => 'review_output_limitations', + 'export_not_ready' => 'review_output_limitations', + default => match ($state) { + self::STATE_CUSTOMER_SAFE_READY => 'download_customer_safe_review_pack', + self::STATE_INTERNAL_ONLY => 'review_pii_redaction_state', + self::STATE_EXPORT_NOT_READY => 'review_output_limitations', + self::STATE_PUBLICATION_BLOCKED => 'resolve_review_blockers', + self::STATE_PUBLISHED_WITH_LIMITATIONS => 'review_output_limitations', + default => 'review_output_limitations', + }, + }; + + return self::action($actionKey, self::primaryActionUrl($actionKey, $urls)); + } + + /** + * @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls + * @param array{label:string,url:?string,kind:string,icon:string}|null $primaryAction + * @return list + */ + private static function secondaryActions(string $state, ?array $primaryAction, array $urls): array + { + $actions = match ($state) { + self::STATE_CUSTOMER_SAFE_READY => [ + self::action('open_review', $urls['review'] ?? null), + ], + self::STATE_INTERNAL_ONLY => [ + self::action('download_internal_review_pack', $urls['download'] ?? null), + self::action('open_review', $urls['review'] ?? null), + ], + self::STATE_EXPORT_NOT_READY => [ + self::action('open_evidence_basis', $urls['evidence'] ?? null), + self::action('open_review', $urls['review'] ?? null), + ], + self::STATE_PUBLICATION_BLOCKED => [ + self::action('download_review_pack_with_limitations', $urls['download'] ?? null), + self::action('open_evidence_basis', $urls['evidence'] ?? null), + self::action('open_operation_proof', $urls['operation'] ?? null), + ], + self::STATE_PUBLISHED_WITH_LIMITATIONS => [ + self::action('download_review_pack_with_limitations', $urls['download'] ?? null), + self::action('open_review', $urls['review'] ?? null), + self::action('open_evidence_basis', $urls['evidence'] ?? null), + ], + default => [ + self::action('open_review', $urls['review'] ?? null), + ], + }; + + return collect($actions) + ->filter(static fn (?array $action): bool => is_array($action) && filled($action['url'])) + ->reject(static fn (array $action): bool => $primaryAction !== null + && $action['label'] === $primaryAction['label'] + && $action['url'] === $primaryAction['url']) + ->unique(static fn (array $action): string => $action['label'].'|'.$action['url']) + ->values() + ->all(); + } + + /** + * @param array{download?:?string,review?:?string,evidence?:?string,operation?:?string} $urls + */ + private static function primaryActionUrl(string $actionKey, array $urls): ?string + { + return match ($actionKey) { + 'download_customer_safe_review_pack', 'download_internal_review_pack', 'download_review_pack_with_limitations' => $urls['download'] ?? null, + 'open_evidence_basis' => $urls['evidence'] ?? $urls['review'] ?? null, + 'review_section_limitations', 'resolve_review_blockers', 'review_output_limitations', 'review_pii_redaction_state', 'open_review' => $urls['review'] ?? $urls['evidence'] ?? $urls['download'] ?? null, + 'open_operation_proof' => $urls['operation'] ?? null, + default => $urls['review'] ?? $urls['download'] ?? $urls['evidence'] ?? null, + }; + } + + /** + * @return array{label:string,url:?string,kind:string,icon:string}|null + */ + private static function action(string $actionKey, ?string $url): ?array + { + return [ + 'label' => match ($actionKey) { + 'download_customer_safe_review_pack' => __('localization.review.download_customer_safe_review_pack'), + 'download_internal_review_pack' => __('localization.review.download_internal_review_pack'), + 'download_review_pack_with_limitations' => __('localization.review.download_review_pack_with_limitations'), + 'open_evidence_basis' => __('localization.review.open_evidence_basis'), + 'review_section_limitations' => __('localization.review.review_section_limitations'), + 'review_pii_redaction_state' => __('localization.review.review_pii_redaction_state'), + 'resolve_review_blockers' => __('localization.review.resolve_review_blockers'), + 'open_operation_proof' => __('localization.review.open_operation_proof'), + 'open_review' => __('localization.review.open_review'), + default => __('localization.review.review_output_limitations'), + }, + 'url' => $url, + 'kind' => str_starts_with($actionKey, 'download_') ? 'download' : 'environment_link', + 'icon' => str_starts_with($actionKey, 'download_') + ? 'heroicon-o-arrow-down-tray' + : 'heroicon-o-arrow-top-right-on-square', + ]; + } + + private static function label(string $state): string + { + return match ($state) { + self::STATE_PUBLICATION_BLOCKED => __('localization.review.output_not_customer_ready'), + self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready'), + self::STATE_INTERNAL_ONLY => __('localization.review.internal_review_package_available'), + self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready'), + self::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.published_with_limitations'), + default => __('localization.review.requires_review'), + }; + } + + private static function statusLabel(string $state): string + { + return match ($state) { + self::STATE_PUBLICATION_BLOCKED => __('localization.review.publication_blocked'), + self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready'), + self::STATE_INTERNAL_ONLY => __('localization.review.internal_review_package_available'), + self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready'), + self::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.published_with_limitations'), + default => __('localization.review.requires_review'), + }; + } + + private static function color(string $state): string + { + return match ($state) { + self::STATE_CUSTOMER_SAFE_READY => 'success', + self::STATE_PUBLICATION_BLOCKED => 'danger', + self::STATE_EXPORT_NOT_READY => 'gray', + default => 'warning', + }; + } + + private static function severity(string $state): string + { + return match ($state) { + self::STATE_CUSTOMER_SAFE_READY => 'success', + self::STATE_PUBLICATION_BLOCKED => 'danger', + self::STATE_EXPORT_NOT_READY => 'warning', + default => 'warning', + }; + } + + /** + * @param array $readiness + */ + private static function boundaryState(string $state, array $readiness): string + { + return match ($state) { + self::STATE_CUSTOMER_SAFE_READY => 'customer_safe_ready', + self::STATE_INTERNAL_ONLY => 'internal_only', + self::STATE_EXPORT_NOT_READY => 'not_ready', + self::STATE_PUBLICATION_BLOCKED, self::STATE_PUBLISHED_WITH_LIMITATIONS => 'requires_review', + default => (string) ($readiness['customer_safe_state'] ?? 'requires_review'), + }; + } + + private static function boundaryLabel(string $state): string + { + return match ($state) { + 'customer_safe_ready' => __('localization.review.customer_safe'), + 'internal_only' => __('localization.review.internal_only'), + 'not_ready' => __('localization.review.not_ready'), + default => __('localization.review.requires_review'), + }; + } + + private static function boundaryColor(string $state): string + { + return match ($state) { + 'customer_safe_ready' => 'success', + 'internal_only', 'requires_review' => 'warning', + default => 'gray', + }; + } + + private static function defaultPrimaryReason(string $state): string + { + return match ($state) { + self::STATE_PUBLICATION_BLOCKED => __('localization.review.publication_blocked_description'), + self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready_guidance_reason'), + self::STATE_INTERNAL_ONLY => __('localization.review.internal_package_includes_pii_reason'), + self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready_reason'), + default => __('localization.review.review_pack_with_limitations_description'), + }; + } + + private static function shortReason(string $limitationKey): string + { + return match ($limitationKey) { + 'publish_blockers_present' => __('localization.review.publication_blocked_short_reason'), + 'export_not_ready' => __('localization.review.export_not_ready_short_reason'), + 'evidence_basis_missing', 'evidence_basis_stale', 'evidence_basis_incomplete' => __('localization.review.evidence_basis_incomplete_short_reason'), + 'required_sections_incomplete' => __('localization.review.required_review_sections_missing_short_reason'), + 'contains_pii' => __('localization.review.internal_package_includes_pii_short_reason'), + 'disclosure_missing' => __('localization.review.output_disclosure_missing_short_reason'), + default => __('localization.review.review_output_limitations'), + }; + } + + private static function impact(string $state): string + { + return match ($state) { + self::STATE_PUBLICATION_BLOCKED => __('localization.review.publication_blocked_impact'), + self::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready_impact'), + self::STATE_INTERNAL_ONLY => __('localization.review.internal_review_package_available_impact'), + self::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready_impact'), + default => __('localization.review.published_with_limitations_impact'), + }; + } + + private static function actionHelp(string $state): ?string + { + return match ($state) { + self::STATE_PUBLICATION_BLOCKED => __('localization.review.output_action_help_publication_blocked'), + self::STATE_PUBLISHED_WITH_LIMITATIONS => __('localization.review.output_action_help_published_with_limitations'), + self::STATE_INTERNAL_ONLY => __('localization.review.output_action_help_internal_only'), + self::STATE_EXPORT_NOT_READY => __('localization.review.output_action_help_export_not_ready'), + self::STATE_CUSTOMER_SAFE_READY => __('localization.review.output_action_help_customer_safe_ready'), + default => null, + }; + } + + private static function qualifiedDownloadLabel(string $state): string + { + return match ($state) { + self::STATE_CUSTOMER_SAFE_READY => __('localization.review.download_customer_safe_review_pack'), + self::STATE_INTERNAL_ONLY => __('localization.review.download_internal_review_pack'), + default => __('localization.review.download_review_pack_with_limitations'), + }; + } + + /** + * @param array $readiness + * @return array + */ + private static function technicalDetails(array $readiness): array + { + $sectionSummary = is_array($readiness['section_summary'] ?? null) ? $readiness['section_summary'] : []; + + return [ + __('localization.review.review_status') => (string) ($readiness['review_status'] ?? __('localization.review.unavailable')), + __('localization.review.output_readiness') => self::statusLabel(self::state($readiness, self::limitations($readiness, []))), + __('localization.review.publication_sharing_state') => self::boundaryLabel(self::boundaryState( + self::state($readiness, self::limitations($readiness, [])), + $readiness, + )), + __('localization.review.has_ready_export') => (bool) ($readiness['has_ready_export'] ?? false) + ? __('localization.review.yes') + : __('localization.review.no'), + __('localization.review.evidence_basis_state') => (string) ($readiness['evidence_completeness_state'] ?? __('localization.review.unavailable')), + __('localization.review.section_completeness') => __('localization.review.technical_detail_required_sections_value', [ + 'complete' => (int) ($sectionSummary['required_complete'] ?? 0), + 'total' => (int) ($sectionSummary['required_total'] ?? 0), + 'limited' => (int) ($sectionSummary['required_limited'] ?? 0), + ]), + __('localization.review.pii_state') => (bool) ($readiness['contains_pii'] ?? false) + ? __('localization.review.yes') + : __('localization.review.no'), + __('localization.review.protected_values') => (bool) ($readiness['protected_values_hidden'] ?? true) + ? __('localization.review.protected_values_hidden') + : __('localization.review.unavailable'), + __('localization.review.disclosure') => (bool) ($readiness['disclosure_present'] ?? false) + ? __('localization.review.disclosure_present') + : __('localization.review.no'), + ]; + } +} diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index aeb99329..9434acda 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -506,6 +506,20 @@ 'tenant' => 'Umgebung', 'latest_review' => 'Letztes Review', 'review_status' => 'Review-Status', + 'output_guidance' => 'Output-Guidance', + 'output_readiness' => 'Output-Bereitschaft', + 'publication_sharing_state' => 'Publikations-/Freigabestatus', + 'output_not_customer_ready' => 'Output nicht kundenbereit', + 'publication_blocked' => 'Publikation blockiert', + 'publication_blocked_description' => 'Review-Blocker müssen aufgelöst werden, bevor dieser Output als kundenbereit behandelt werden kann.', + 'publication_blocked_short_reason' => 'Für diesen Output sind weiterhin Review-Blocker erfasst.', + 'publication_blocked_impact' => 'Behandeln Sie diese Review-Ausgabe erst als kundenbereit, wenn die Blocker aufgelöst sind.', + 'output_limitations' => 'Output-Einschränkungen', + 'output_limitations_summary' => '{1} 1 Einschränkung benötigt Prüfung|[2,*] :count Einschränkungen benötigen Prüfung', + 'technical_details' => 'Technische Details', + 'has_ready_export' => 'Bereiter Export', + 'yes' => 'Ja', + 'no' => 'Nein', 'status' => 'Status', 'control' => 'Control', 'control_interpretation' => 'Control-Readiness-Interpretation', @@ -554,7 +568,32 @@ 'download_governance_package' => 'Governance-Paket herunterladen', 'review_package_contents' => 'Paketinhalt prüfen', 'review_output_limitations' => 'Output-Einschränkungen prüfen', + 'review_limitations_below' => 'Einschränkungen unten prüfen', + 'review_section_limitations' => 'Abschnittseinschränkungen prüfen', + 'review_pii_redaction_state' => 'PII-/Redaktionsstatus prüfen', + 'resolve_review_blockers' => 'Review-Blocker prüfen', + 'open_operation_proof' => 'Operationsnachweis öffnen', 'open_evidence_basis' => 'Evidence-Basis öffnen', + 'output_guidance_detail_mode_note' => 'Sie befinden sich bereits auf der Review-Detailseite für diesen Output. Nutzen Sie die Einschränkungen und technischen Details unten, um Blocker, Evidence-Status und den aktuellen Export zu prüfen.', + 'output_action_help_publication_blocked' => 'Die primäre Aktion öffnet die Review-Detailseite mit Blockern, Evidence-Status und nächsten Schritten. Die sekundären Aktionen laden das aktuelle Paket herunter oder springen zum Evidence-Snapshot und Operationsnachweis.', + 'output_action_help_published_with_limitations' => 'Die primäre Aktion öffnet die Review-Detailseite zur aktuellen Einschränkung. Die sekundären Aktionen laden das aktuelle Paket herunter oder springen zur Evidence-Basis.', + 'output_action_help_internal_only' => 'Die primäre Aktion öffnet die Review-Detailseite für PII- und Redaktionsprüfungen. Die sekundären Aktionen erlauben den Download des internen Pakets oder öffnen den veröffentlichten Datensatz.', + 'output_action_help_export_not_ready' => 'Die primäre Aktion öffnet die Review-Detailseite mit den aktuellen Output-Einschränkungen. Die sekundären Aktionen springen zur Evidence-Basis oder zum veröffentlichten Review.', + 'output_action_help_customer_safe_ready' => 'Die primäre Aktion lädt das kundensichere Paket herunter. Über die sekundäre Aktion öffnen Sie die Detailseite des veröffentlichten Reviews.', + 'evidence_basis_incomplete_guidance' => 'Evidence-Basis unvollständig', + 'evidence_basis_incomplete_guidance_reason' => 'Das Review-Paket ist an einen Evidence-Snapshot mit fehlenden oder unvollständigen Nachweisen gebunden.', + 'evidence_basis_incomplete_short_reason' => 'Die Evidence-Basis ist unvollständig.', + 'required_review_sections_missing' => 'Erforderliche Review-Abschnitte fehlen', + 'required_review_sections_missing_reason' => '{1} 1 erforderlicher Abschnitt ist teilweise, fehlend oder veraltet.|[2,*] :count erforderliche Abschnitte sind teilweise, fehlend oder veraltet.', + 'required_review_sections_missing_short_reason' => 'Erforderliche Review-Abschnitte fehlen oder sind unvollständig.', + 'internal_package_includes_pii' => 'Internes Paket enthält PII', + 'internal_package_includes_pii_reason' => 'Dieser Export enthält PII-tragende Details und sollte vor externer Weitergabe geprüft werden.', + 'internal_package_includes_pii_short_reason' => 'Dieser Export enthält interne oder PII-tragende Details.', + 'export_not_ready_guidance_reason' => 'Das Review-Paket existiert, aber der Export-Bereitschaftsvertrag ist noch nicht erfüllt.', + 'export_not_ready_short_reason' => 'Der aktuelle Export ist noch nicht bereit.', + 'output_disclosure_missing' => 'Erforderliche Offenlegung fehlt', + 'output_disclosure_missing_reason' => 'Die kundensichere Offenlegung fehlt im aktuellen Output-Paket.', + 'output_disclosure_missing_short_reason' => 'Die erforderliche Offenlegung fehlt im aktuellen Output.', 'governance_package' => 'Governance-Paket', 'governance_decisions' => 'Governance-Entscheidungen', 'governance_decisions_requiring_awareness' => 'Governance-Entscheidungen mit Aufmerksamkeitsbedarf', @@ -690,6 +729,13 @@ 'outcome' => 'Ergebnis', 'export' => 'Export', 'next_step' => 'Nächster Schritt', + 'technical_detail_review_status_value' => 'Review-Status: :value', + 'technical_detail_ready_export_value' => 'Bereiter Export: :value', + 'technical_detail_evidence_state_value' => 'Evidence-Status: :value', + 'technical_detail_section_counts_value' => 'Abschnitte - vollständig: :complete, teilweise: :partial, fehlend: :missing, veraltet: :stale', + 'technical_detail_contains_pii_value' => 'Enthält PII: :value', + 'technical_detail_disclosure_present_value' => 'Offenlegung vorhanden: :value', + 'technical_detail_required_sections_value' => ':complete von :total erforderlich vollständig, :limited eingeschränkt', 'workspace_next_step_evidence_review' => 'Nachweise prüfen', 'workspace_next_step_review_open' => 'Review öffnen', 'workspace_next_step_package_review' => 'Paket prüfen', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index de3842cb..5155fbee 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -506,6 +506,20 @@ 'tenant' => 'Environment', 'latest_review' => 'Latest review', 'review_status' => 'Review status', + 'output_guidance' => 'Output guidance', + 'output_readiness' => 'Output readiness', + 'publication_sharing_state' => 'Publication/sharing state', + 'output_not_customer_ready' => 'Output not customer-ready', + 'publication_blocked' => 'Publication blocked', + 'publication_blocked_description' => 'Review blockers must be resolved before this output can be treated as customer-ready.', + 'publication_blocked_short_reason' => 'Review blockers are still recorded for this output.', + 'publication_blocked_impact' => 'Do not present this review output as customer-ready until the blockers are resolved.', + 'output_limitations' => 'Output limitations', + 'output_limitations_summary' => '{1} 1 limitation requires review|[2,*] :count limitations require review', + 'technical_details' => 'Technical details', + 'has_ready_export' => 'Ready export', + 'yes' => 'Yes', + 'no' => 'No', 'status' => 'Status', 'control' => 'Control', 'control_interpretation' => 'Control readiness interpretation', @@ -554,7 +568,32 @@ 'download_governance_package' => 'Download governance package', 'review_package_contents' => 'Review package contents', 'review_output_limitations' => 'Review output limitations', + 'review_limitations_below' => 'Review limitations below', + 'review_section_limitations' => 'Review section limitations', + 'review_pii_redaction_state' => 'Review PII/redaction state', + 'resolve_review_blockers' => 'Inspect review blockers', + 'open_operation_proof' => 'Open operation proof', 'open_evidence_basis' => 'Open evidence basis', + 'output_guidance_detail_mode_note' => 'You are already on the review detail for this output. Use the limitations and technical details below to inspect blockers, evidence state, and the current export.', + 'output_action_help_publication_blocked' => 'The primary action opens the review detail with blockers, evidence status, and next steps. The secondary actions download the current package or jump to the evidence snapshot and operation proof.', + 'output_action_help_published_with_limitations' => 'The primary action opens the review detail for the current limitation. The secondary actions download the current package or jump to the evidence basis.', + 'output_action_help_internal_only' => 'The primary action opens the review detail for PII and redaction checks. The secondary actions let you download the internal package or review the released record.', + 'output_action_help_export_not_ready' => 'The primary action opens the review detail with the current output limitations. The secondary actions jump to the evidence basis or the released review.', + 'output_action_help_customer_safe_ready' => 'The primary action downloads the customer-safe package. Use the secondary action to open the released review detail.', + 'evidence_basis_incomplete_guidance' => 'Evidence basis incomplete', + 'evidence_basis_incomplete_guidance_reason' => 'The review pack is anchored to an evidence snapshot with missing or incomplete evidence.', + 'evidence_basis_incomplete_short_reason' => 'Evidence basis is incomplete.', + 'required_review_sections_missing' => 'Required review sections missing', + 'required_review_sections_missing_reason' => '{1} 1 required section is partial, missing, or stale.|[2,*] :count required sections are partial, missing, or stale.', + 'required_review_sections_missing_short_reason' => 'Required review sections are missing or incomplete.', + 'internal_package_includes_pii' => 'Internal package includes PII', + 'internal_package_includes_pii_reason' => 'This export includes PII-bearing detail and should be reviewed before external sharing.', + 'internal_package_includes_pii_short_reason' => 'This export includes internal or PII-bearing detail.', + 'export_not_ready_guidance_reason' => 'The review package exists, but the export-readiness contract has not passed.', + 'export_not_ready_short_reason' => 'The current export is not ready yet.', + 'output_disclosure_missing' => 'Required disclosure missing', + 'output_disclosure_missing_reason' => 'The customer-safe disclosure is missing from the current output package.', + 'output_disclosure_missing_short_reason' => 'Required disclosure is missing from the current output.', 'governance_package' => 'Governance package', 'governance_decisions' => 'Governance decisions', 'governance_decisions_requiring_awareness' => 'Governance decisions requiring awareness', @@ -690,6 +729,13 @@ 'outcome' => 'Outcome', 'export' => 'Export', 'next_step' => 'Next step', + 'technical_detail_review_status_value' => 'Review status: :value', + 'technical_detail_ready_export_value' => 'Ready export: :value', + 'technical_detail_evidence_state_value' => 'Evidence state: :value', + 'technical_detail_section_counts_value' => 'Sections - complete: :complete, partial: :partial, missing: :missing, stale: :stale', + 'technical_detail_contains_pii_value' => 'Contains PII: :value', + 'technical_detail_disclosure_present_value' => 'Disclosure present: :value', + 'technical_detail_required_sections_value' => ':complete of :total required complete, :limited limited', 'workspace_next_step_evidence_review' => 'Review evidence', 'workspace_next_step_review_open' => 'Open review', 'workspace_next_step_package_review' => 'Review package', diff --git a/apps/platform/resources/views/filament/infolists/entries/review-pack-output-guidance.blade.php b/apps/platform/resources/views/filament/infolists/entries/review-pack-output-guidance.blade.php new file mode 100644 index 00000000..574281a2 --- /dev/null +++ b/apps/platform/resources/views/filament/infolists/entries/review-pack-output-guidance.blade.php @@ -0,0 +1,153 @@ +@php + $state = $getState(); + $state = is_array($state) ? $state : []; + $limitations = is_array($state['limitations'] ?? null) ? $state['limitations'] : []; + $technicalDetails = is_array($state['technical_details'] ?? null) ? $state['technical_details'] : []; + $secondaryActions = is_array($state['secondary_actions'] ?? null) ? $state['secondary_actions'] : []; + $detailMode = (bool) ($state['detail_mode'] ?? false); + $contextNote = is_string($state['context_note'] ?? null) ? $state['context_note'] : null; + $nextStepLabel = is_string($state['next_step_label'] ?? null) + ? $state['next_step_label'] + : data_get($state, 'primary_action.label', __('localization.review.review_output_limitations')); +@endphp + +
+
+
+ + {{ $state['label'] ?? __('localization.review.requires_review') }} + + + {{ $state['boundary_label'] ?? __('localization.review.requires_review') }} + +
+ +
+
+
+ {{ __('localization.review.output_readiness') }} +
+
+ {{ $state['status_label'] ?? __('localization.review.requires_review') }} +
+
+ +
+
+ {{ __('localization.review.publication_sharing_state') }} +
+
+ {{ $state['boundary_label'] ?? __('localization.review.requires_review') }} +
+
+ +
+
+ {{ __('localization.review.next_step') }} +
+
+ {{ $nextStepLabel }} +
+
+
+ +
+

+ {{ $state['primary_reason'] ?? __('localization.review.review_pack_with_limitations_description') }} +

+

+ {{ $state['impact'] ?? __('localization.review.published_with_limitations_impact') }} +

+ @if (filled($contextNote)) +

+ {{ $contextNote }} +

+ @endif +
+ + @unless ($detailMode) +
+ @if (filled(data_get($state, 'primary_action.url'))) + + {{ $state['primary_action']['label'] }} + + @endif + + @foreach ($secondaryActions as $secondaryAction) + @continue(! filled($secondaryAction['url'] ?? null)) + + + {{ $secondaryAction['label'] }} + + @endforeach +
+ @endunless +
+ + @if ($limitations !== []) +
+ + {{ __('localization.review.output_limitations') }} + + {{ $state['limitation_summary'] ?? '' }} + + + +
+ @foreach ($limitations as $limitation) +
+
+
+ {{ $limitation['label'] }} +
+ + {{ \Illuminate\Support\Str::headline($limitation['severity']) }} + +
+ +

+ {{ $limitation['reason'] }} +

+ + @if (($limitation['details'] ?? []) !== []) +
    + @foreach ($limitation['details'] as $detail) +
  • {{ $detail }}
  • + @endforeach +
+ @endif +
+ @endforeach +
+
+ @endif + + @if ($technicalDetails !== []) +
+ + {{ __('localization.review.technical_details') }} + + +
+ @foreach ($technicalDetails as $label => $value) +
+
{{ $label }}
+
{{ $value }}
+
+ @endforeach +
+
+ @endif +
diff --git a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php index 8ba176e9..e7ac271f 100644 --- a/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php +++ b/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php @@ -118,18 +118,113 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit @endif - @if ($readiness['secondary_action_url']) + @foreach ($readiness['secondary_actions'] as $secondaryAction) - {{ $readiness['secondary_action_label'] }} + {{ $secondaryAction['label'] }} - @endif + @endforeach + + @php + $outputGuidance = $readiness['output_guidance'] ?? []; + $actionHelp = is_string($outputGuidance['action_help'] ?? null) ? $outputGuidance['action_help'] : null; + $outputLimitations = is_array($outputGuidance['limitations'] ?? null) ? $outputGuidance['limitations'] : []; + $technicalDetails = is_array($outputGuidance['technical_details'] ?? null) ? $outputGuidance['technical_details'] : []; + @endphp + + @if (filled($actionHelp)) +

+ {{ $actionHelp }} +

+ @endif + + @if ($outputLimitations !== []) +
+ + {{ __('localization.review.output_limitations') }} + + {{ $outputGuidance['limitation_summary'] }} + + + +
+ @foreach ($outputLimitations as $limitation) +
+
+
+ {{ $limitation['label'] }} +
+ + {{ \Illuminate\Support\Str::headline($limitation['severity']) }} + +
+ +

+ {{ $limitation['reason'] }} +

+ + @if (($limitation['details'] ?? []) !== []) +
    + @foreach ($limitation['details'] as $detail) +
  • {{ $detail }}
  • + @endforeach +
+ @endif + + @if (filled(data_get($limitation, 'action.url'))) +
+ + {{ $limitation['action']['label'] }} + +
+ @endif +
+ @endforeach +
+
+ @endif + + @if ($technicalDetails !== []) +
+ + {{ __('localization.review.technical_details') }} + + +
+ @foreach ($technicalDetails as $detailLabel => $detailValue) +
+
+ {{ $detailLabel }} +
+
+ {{ $detailValue }} +
+
+ @endforeach +
+
+ @endif diff --git a/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php index 3cbb25d4..210067d3 100644 --- a/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php +++ b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php @@ -116,7 +116,7 @@ ->assertSee('Kunden-Workspace öffnen') ->assertNoJavaScriptErrors() ->assertNoConsoleLogs() - ->click('Kunden-Workspace öffnen') + ->click('a.fi-link[href*="/admin/reviews/workspace?environment_id="]') ->waitForText('Kundensichere Review-Pakete') ->assertSee('Filter löschen') ->assertSee('Review öffnen') diff --git a/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php b/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php index a4125973..2616a6b1 100644 --- a/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php +++ b/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php @@ -108,8 +108,8 @@ $page = visit(CustomerReviewWorkspace::environmentFilterUrl($notReadyEnvironment)) ->resize(1236, 900) - ->waitForText('Published with limitations') - ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->waitForText('Output not customer-ready') + ->assertSee('Review blockers are still recorded for this output.') ->assertSee('Needs review') ->assertSee('Download review pack with limitations') ->assertSee('Review consumption flow') diff --git a/apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php b/apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php index 4e4028b6..4ea4f159 100644 --- a/apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php +++ b/apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php @@ -92,8 +92,8 @@ spec347CopyBrowserScreenshot('01-customer-safe-ready'); $page = visit(CustomerReviewWorkspace::environmentFilterUrl($limitedEnvironment)) - ->waitForText('Published with limitations') - ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->waitForText('Output not customer-ready') + ->assertSee('Review blockers are still recorded for this output.') ->assertSee('Download review pack with limitations') ->assertSee('Requires review') ->assertNoJavaScriptErrors() @@ -104,6 +104,7 @@ $page = visit(CustomerReviewWorkspace::environmentFilterUrl($internalEnvironment)) ->waitForText('Internal review package available') ->assertSee('Contains PII') + ->assertSee('Review PII/redaction state') ->assertSee('Download internal review pack') ->assertSee('Internal only') ->assertNoJavaScriptErrors() diff --git a/apps/platform/tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php b/apps/platform/tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php new file mode 100644 index 00000000..ea475acf --- /dev/null +++ b/apps/platform/tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php @@ -0,0 +1,267 @@ +browser()->timeout(60_000); + +beforeEach(function (): void { + Storage::fake('exports'); +}); + +it('Spec349 smokes output resolution guidance states and collapsed disclosures', function (): void { + [$user, $readyEnvironment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + $readyEnvironment->forceFill(['name' => 'Spec349 Browser Ready'])->save(); + $blockedEnvironment = spec349BrowserEnvironmentFor($user, $readyEnvironment, 'Spec349 Browser Blocked'); + $internalEnvironment = spec349BrowserEnvironmentFor($user, $readyEnvironment, 'Spec349 Browser Internal'); + + spec349BrowserCreatePublishedReviewWithPack( + $readyEnvironment, + $user, + seedEnvironmentReviewEvidence($readyEnvironment, findingCount: 0, driftCount: 0), + [], + [ + 'include_pii' => false, + 'include_operations' => true, + ], + 'review-packs/spec349-browser-ready.zip', + markReady: true, + ); + + spec349BrowserCreatePublishedReviewWithPack( + $blockedEnvironment, + $user, + seedPartialEnvironmentReviewEvidence($blockedEnvironment, findingCount: 0, driftCount: 0), + [ + 'governance_package' => [ + 'decision_summary' => [ + 'status' => 'incomplete', + 'evidence_state' => EnvironmentReviewCompletenessState::Partial->value, + 'decision_data_state' => 'incomplete', + 'total_count' => 1, + 'summary' => 'Decision evidence is incomplete for this released review.', + 'next_action' => 'Review the evidence basis before relying on the decision summary.', + 'entries' => [], + ], + ], + ], + [ + 'include_pii' => false, + 'include_operations' => true, + ], + 'review-packs/spec349-browser-blocked.zip', + markReady: false, + ); + + spec349BrowserCreatePublishedReviewWithPack( + $internalEnvironment, + $user, + seedEnvironmentReviewEvidence($internalEnvironment, findingCount: 0, driftCount: 0), + [], + [ + 'include_pii' => true, + 'include_operations' => true, + ], + 'review-packs/spec349-browser-internal.zip', + markReady: true, + ); + + spec349AuthenticateBrowser($this, $user, $readyEnvironment); + + $page = visit(CustomerReviewWorkspace::environmentFilterUrl($blockedEnvironment)) + ->resize(1236, 900) + ->waitForText('Output not customer-ready') + ->assertSee('Inspect review blockers') + ->assertSee('Download review pack with limitations') + ->assertSee('The primary action opens the review detail with blockers, evidence status, and next steps.') + ->assertScript('Array.from(document.querySelectorAll("[data-testid=\"customer-review-decision-card\"] [data-testid=\"customer-review-secondary-action\"]")).some((element) => element.innerText.includes("Open review")) === false', true) + ->assertSee('Requires review') + ->assertScript('document.querySelector("[data-testid=\"customer-review-output-limitations\"]")?.open === false', true) + ->assertScript('document.querySelector("[data-testid=\"customer-review-technical-details\"]")?.open === false', true) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + $page->screenshot(true, spec349BrowserScreenshotName('01-output-blocked')); + spec349CopyBrowserScreenshot('01-output-blocked'); + + $page = visit(CustomerReviewWorkspace::environmentFilterUrl($internalEnvironment)) + ->waitForText('Internal review package available') + ->assertSee('Review PII/redaction state') + ->assertSee('Download internal review pack') + ->assertSee('Internal only') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + $page->screenshot(true, spec349BrowserScreenshotName('02-internal-only')); + spec349CopyBrowserScreenshot('02-internal-only'); + + $page = visit(CustomerReviewWorkspace::environmentFilterUrl($readyEnvironment)) + ->waitForText('Customer-safe review pack ready') + ->assertSee('Download customer-safe review pack') + ->assertDontSee('Ready to share') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + $page->screenshot(true, spec349BrowserScreenshotName('03-customer-safe-ready')); + spec349CopyBrowserScreenshot('03-customer-safe-ready'); +}); + +function spec349BrowserScreenshotName(string $name): string +{ + return 'spec349-output-resolution-guidance-'.$name; +} + +function spec349CopyBrowserScreenshot(string $name): void +{ + $filename = spec349BrowserScreenshotName($name).'.png'; + $source = base_path('tests/Browser/Screenshots/'.$filename); + $targetDirectory = repo_path('specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots'); + + if (! is_dir($targetDirectory)) { + @mkdir($targetDirectory, 0755, true); + } + + if (! is_file($source)) { + $source = \Pest\Browser\Support\Screenshot::path($filename); + } + + for ($attempt = 0; $attempt < 10 && ! is_file($source); $attempt++) { + usleep(100_000); + clearstatcache(true, $source); + } + + if (is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) { + @copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png'); + } +} + +function spec349AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void +{ + $workspaceId = (int) $environment->workspace_id; + + $test->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [ + (string) $workspaceId => (int) $environment->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [ + (string) $workspaceId => (int) $environment->getKey(), + ]); + + setAdminPanelContext($environment); +} + +function spec349BrowserEnvironmentFor(User $user, ManagedEnvironment $baseEnvironment, string $name): ManagedEnvironment +{ + $environment = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $baseEnvironment->workspace_id, + 'name' => $name, + ]); + + createUserWithTenant(tenant: $environment, user: $user, role: 'owner', workspaceRole: 'manager'); + + return $environment; +} + +/** + * @param array $summaryOverrides + * @param array $packOptions + * @return array{0: EnvironmentReview, 1: ReviewPack} + */ +function spec349BrowserCreatePublishedReviewWithPack( + ManagedEnvironment $environment, + User $user, + EvidenceSnapshot $snapshot, + array $summaryOverrides = [], + array $packOptions = [], + string $filePath = 'review-packs/spec349-browser-review-pack.zip', + bool $markReady = true, +): array { + $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); + $summary = array_replace_recursive( + is_array($review->summary) ? $review->summary : [], + [ + 'control_interpretation' => [ + 'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY, + 'controls' => [ + [ + 'control_key' => 'customer-output', + 'title' => 'Customer output', + 'readiness_bucket' => $markReady ? 'evidence_on_record' : 'review_recommended', + 'readiness_label' => $markReady ? 'Evidence on record' : 'Review recommended', + 'primary_reason' => $markReady ? 'Evidence path is complete.' : 'Evidence basis needs review.', + 'recommended_next_action' => $markReady ? 'Open the current customer review pack.' : 'Review the evidence basis before sharing.', + ], + ], + ], + 'governance_package' => [ + 'decision_summary' => [ + 'status' => $markReady ? 'none' : 'incomplete', + 'evidence_state' => $markReady ? EnvironmentReviewCompletenessState::Complete->value : EnvironmentReviewCompletenessState::Partial->value, + 'decision_data_state' => $markReady ? 'complete' : 'incomplete', + 'total_count' => $markReady ? 0 : 1, + 'summary' => $markReady + ? 'No governance decisions require customer awareness.' + : 'Decision evidence is incomplete for this released review.', + 'next_action' => $markReady + ? 'Open the current customer review pack.' + : 'Review the evidence basis before relying on the decision summary.', + 'entries' => [], + ], + ], + ], + $summaryOverrides, + ); + + Storage::disk('exports')->put($filePath, 'PK-spec349-browser-test'); + + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'completeness_state' => $markReady + ? EnvironmentReviewCompletenessState::Complete->value + : (string) $review->completeness_state, + 'summary' => $summary, + 'generated_at' => now()->subMinutes(5), + 'published_at' => now()->subMinutes(3), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + if ($markReady) { + $review = markEnvironmentReviewCustomerSafeReady($review); + } + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $environment->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => array_replace([ + 'include_pii' => false, + 'include_operations' => true, + ], $packOptions), + 'file_path' => $filePath, + 'file_disk' => 'exports', + 'generated_at' => now()->subMinutes(4), + ]); + + $review->forceFill([ + 'current_export_review_pack_id' => (int) $pack->getKey(), + ])->save(); + + return [$review->refresh(), $pack->refresh()]; +} diff --git a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php index aa61a7a4..568e1cd1 100644 --- a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php +++ b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php @@ -221,7 +221,16 @@ function environmentReviewContractHeaderActions(Testable $component): array ->assertActionDoesNotExist('export_executive_pack') ->assertActionDoesNotExist('archive_review'); - $component->assertActionExists('download_current_review_pack', fn (Action $action): bool => $action->getLabel() === 'Download governance package'); + $component->assertActionExists('download_current_review_pack', function (Action $action): bool { + $label = $action->getLabel(); + + return $label !== 'Download governance package' + && in_array($label, [ + 'Download customer-safe review pack', + 'Download review pack with limitations', + 'Download internal review pack', + ], true); + }); $topLevelActionNames = collect(environmentReviewContractHeaderActions($component)) ->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null) diff --git a/apps/platform/tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php b/apps/platform/tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php new file mode 100644 index 00000000..9339b9be --- /dev/null +++ b/apps/platform/tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php @@ -0,0 +1,161 @@ +create(); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + $summary = array_replace_recursive(is_array($review->summary) ? $review->summary : [], [ + 'publish_blockers' => ['Operator approval note is still missing.'], + ]); + + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $owner->getKey(), + 'summary' => $summary, + ])->save(); + + Storage::disk('exports')->put('review-packs/spec349-detail-blocked.zip', 'PK-spec349-detail-blocked'); + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $owner->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], + 'file_path' => 'review-packs/spec349-detail-blocked.zip', + 'file_disk' => 'exports', + 'generated_at' => now()->subMinutes(3), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + setAdminEnvironmentContext($tenant); + + $this->actingAs($owner) + ->get(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant)) + ->assertOk() + ->assertSee('Review status') + ->assertSee('Output readiness') + ->assertSee('Publication/sharing state') + ->assertSee('Publication blocked') + ->assertSee('Inspect review blockers') + ->assertSee('Technical details') + ->assertDontSee('Ready to share'); +}); + +it('removes the repeated action rail from the customer-workspace detail context', function (): void { + $tenant = ManagedEnvironment::factory()->create(); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + $summary = array_replace_recursive(is_array($review->summary) ? $review->summary : [], [ + 'publish_blockers' => ['Operator approval note is still missing.'], + ]); + + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $owner->getKey(), + 'summary' => $summary, + ])->save(); + + Storage::disk('exports')->put('review-packs/spec349-detail-context-blocked.zip', 'PK-spec349-detail-context-blocked'); + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $owner->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], + 'file_path' => 'review-packs/spec349-detail-context-blocked.zip', + 'file_disk' => 'exports', + 'generated_at' => now()->subMinutes(3), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + setAdminEnvironmentContext($tenant); + + $this->actingAs($owner) + ->get(EnvironmentReviewResource::environmentScopedUrl('view', [ + 'record' => $review, + CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1, + ], $tenant)) + ->assertOk() + ->assertSee('Output guidance') + ->assertSee('Review limitations below') + ->assertSee('You are already on the review detail for this output.') + ->assertDontSee('Inspect review blockers') + ->assertDontSee('Open evidence basis') + ->assertDontSee('Open operation proof'); +}); + +it('qualifies the customer-workspace detail download action instead of using a generic package label', function (): void { + $tenant = ManagedEnvironment::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + $review = markEnvironmentReviewCustomerSafeReady($review); + + Storage::disk('exports')->put('review-packs/spec349-detail-internal.zip', 'PK-spec349-detail-internal'); + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => true, + 'include_operations' => true, + ], + 'file_path' => 'review-packs/spec349-detail-internal.zip', + 'file_disk' => 'exports', + 'generated_at' => now()->subMinutes(3), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + setAdminEnvironmentContext($tenant); + + Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1]) + ->actingAs($user) + ->test(ViewEnvironmentReview::class, ['record' => $review->getKey()]) + ->assertActionVisible('download_current_review_pack') + ->assertActionEnabled('download_current_review_pack') + ->assertActionExists('download_current_review_pack', fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'Download internal review pack'); +}); diff --git a/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php b/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php index 39c390ca..7bd59f17 100644 --- a/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php +++ b/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php @@ -149,8 +149,8 @@ $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); $component = spec342WorkspaceComponent($user, $environment) - ->assertSee('Published with limitations') - ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->assertSee('Output not customer-ready') + ->assertSee('Review blockers are still recorded for this output.') ->assertSee('Customer-safe output') ->assertSee('Needs review') ->assertSee('Diagnostics') diff --git a/apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php b/apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php index e8ac3de6..d2eeeea1 100644 --- a/apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php +++ b/apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php @@ -47,7 +47,7 @@ ->assertDontSee('Ready to share'); }); -it('shows published-with-limitations when evidence is incomplete even if a pack exists', function (): void { +it('shows blocked output guidance when evidence is incomplete and review blockers remain recorded', function (): void { $environment = ManagedEnvironment::factory()->create(['name' => 'Spec347 Limitations']); [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'readonly'); $snapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0); @@ -72,8 +72,8 @@ $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); spec347WorkspaceComponent($user, $environment) - ->assertSee('Published with limitations') - ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->assertSee('Output not customer-ready') + ->assertSee('Review blockers are still recorded for this output.') ->assertSee('Requires review') ->assertSee('Download review pack with limitations') ->assertDontSee('Ready to share'); @@ -108,7 +108,7 @@ ->assertSee('Internal review package available') ->assertSee('Internal only') ->assertSee('Contains PII') - ->assertSee('Review package contents') + ->assertSee('Review PII/redaction state') ->assertSee('Download internal review pack') ->assertDontSee('Customer-safe review pack ready'); }); diff --git a/apps/platform/tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php b/apps/platform/tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php new file mode 100644 index 00000000..0bd7b562 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php @@ -0,0 +1,127 @@ +create(['name' => 'Spec349 Blocked']); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager'); + $snapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); + $summary = array_replace_recursive(is_array($review->summary) ? $review->summary : [], [ + 'publish_blockers' => ['Operator approval note is still missing.'], + ]); + + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + 'summary' => $summary, + ])->save(); + + Storage::disk('exports')->put('review-packs/spec349-blocked.zip', 'PK-spec349-blocked'); + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $environment->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => true, + 'include_operations' => true, + ], + 'file_path' => 'review-packs/spec349-blocked.zip', + 'file_disk' => 'exports', + 'generated_at' => now()->subMinutes(3), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + $component = spec349WorkspaceComponent($user, $environment) + ->assertSee('Output not customer-ready') + ->assertSee('Inspect review blockers') + ->assertSee('Evidence basis incomplete') + ->assertSee('Required review sections missing') + ->assertSee('Internal package includes PII') + ->assertSee('The primary action opens the review detail with blockers, evidence status, and next steps.') + ->assertSee('Technical details'); + + $html = $component->html(); + + expect(substr_count($html, 'data-testid="customer-review-primary-action"'))->toBe(1) + ->and($html)->toContain('data-testid="customer-review-output-limitations"') + ->and($html)->not->toContain('data-testid="customer-review-output-limitations" open') + ->and($html)->toContain('data-testid="customer-review-action-help"') + ->and($html)->toContain('data-testid="customer-review-technical-details"') + ->and($html)->not->toContain('data-testid="customer-review-technical-details" open') + ->and($html)->not->toContain('Ready to share'); +}); + +it('keeps the visible environment_id workspace filter contract while qualifying the internal download label', function (): void { + $environment = ManagedEnvironment::factory()->create(['name' => 'Spec349 Internal']); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'readonly'); + $snapshot = seedEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + $review = markEnvironmentReviewCustomerSafeReady($review); + + Storage::disk('exports')->put('review-packs/spec349-internal.zip', 'PK-spec349-internal'); + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $environment->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => true, + 'include_operations' => true, + ], + 'file_path' => 'review-packs/spec349-internal.zip', + 'file_disk' => 'exports', + 'generated_at' => now()->subMinutes(3), + ]); + + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + Livewire::withQueryParams([ + 'environment_id' => (int) $environment->getKey(), + ]) + ->actingAs($user) + ->test(CustomerReviewWorkspace::class) + ->assertSee('Environment filter:') + ->assertSee('Spec349 Internal') + ->assertSee('Internal review package available') + ->assertSee('Download internal review pack') + ->assertDontSee('Download governance package') + ->assertDontSee('Ready to share'); +}); + +function spec349WorkspaceComponent(User $user, ManagedEnvironment $environment): mixed +{ + session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id); + setAdminPanelContext(); + + return Livewire::actingAs($user) + ->test(CustomerReviewWorkspace::class); +} diff --git a/apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php b/apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php new file mode 100644 index 00000000..c657b4c9 --- /dev/null +++ b/apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php @@ -0,0 +1,143 @@ +value, + hasReadyExport: false, + includePii: true, + publishBlockers: ['Operator approval note is still missing.'], + requiredSectionStates: [ + EnvironmentReviewCompletenessState::Complete->value => 3, + EnvironmentReviewCompletenessState::Missing->value => 2, + ], + ); + + $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [ + 'download' => '/review-packs/1/download', + 'review' => '/environment-reviews/1', + 'evidence' => '/evidence/1', + 'operation' => '/operations/1', + ]); + + expect($guidance['state'])->toBe('publication_blocked') + ->and($guidance['label'])->toBe('Output not customer-ready') + ->and($guidance['primary_action']['label'])->toBe('Inspect review blockers') + ->and($guidance['action_help'])->toContain('opens the review detail with blockers, evidence status, and next steps') + ->and($guidance['limitations'])->toHaveCount(5) + ->and($guidance['limitations'][0]['key'])->toBe('publish_blockers_present') + ->and(collect($guidance['secondary_actions'])->pluck('label')->all())->not->toContain('Open review') + ->and(collect($guidance['limitations'])->pluck('key')->all())->toEqual([ + 'publish_blockers_present', + 'export_not_ready', + 'evidence_basis_incomplete', + 'required_sections_incomplete', + 'contains_pii', + ]); +}); + +it('maps incomplete evidence to a published-with-limitations guidance item', function (): void { + $readiness = spec349Readiness( + evidenceState: EnvironmentReviewCompletenessState::Missing->value, + hasReadyExport: true, + ); + + $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [ + 'review' => '/environment-reviews/1', + 'evidence' => '/evidence/1', + ]); + + expect($guidance['state'])->toBe('published_with_limitations') + ->and($guidance['primary_reason'])->toBe('Evidence basis is incomplete.') + ->and($guidance['primary_action']['label'])->toBe('Open evidence basis') + ->and($guidance['limitations'])->toHaveCount(1) + ->and($guidance['limitations'][0]['label'])->toBe('Evidence basis incomplete'); +}); + +it('renders required section limitation reasons through pluralization instead of raw translation ranges', function (): void { + $readiness = spec349Readiness( + hasReadyExport: true, + requiredSectionStates: [ + EnvironmentReviewCompletenessState::Complete->value => 1, + EnvironmentReviewCompletenessState::Partial->value => 4, + EnvironmentReviewCompletenessState::Missing->value => 2, + ], + ); + + $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [ + 'review' => '/environment-reviews/1', + ]); + + expect($guidance['limitations'])->toHaveCount(1) + ->and($guidance['limitations'][0]['key'])->toBe('required_sections_incomplete') + ->and($guidance['limitations'][0]['reason'])->toBe('6 required sections are partial, missing, or stale.') + ->and($guidance['limitations'][0]['reason'])->not->toContain('{1}') + ->and($guidance['limitations'][0]['reason'])->not->toContain('[2,*]'); +}); + +it('marks pii-bearing ready exports as internal-only and qualifies the download action', function (): void { + $readiness = spec349Readiness( + includePii: true, + hasReadyExport: true, + ); + + $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [ + 'download' => '/review-packs/1/download', + 'review' => '/environment-reviews/1', + ]); + + expect($guidance['state'])->toBe('internal_only') + ->and($guidance['label'])->toBe('Internal review package available') + ->and($guidance['primary_action']['label'])->toBe('Review PII/redaction state') + ->and(collect($guidance['secondary_actions'])->pluck('label')->all())->toContain('Download internal review pack'); +}); + +it('marks complete non-pii exports as customer-safe ready', function (): void { + $readiness = spec349Readiness( + hasReadyExport: true, + ); + + $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [ + 'download' => '/review-packs/1/download', + 'review' => '/environment-reviews/1', + ]); + + expect($guidance['state'])->toBe('customer_safe_ready') + ->and($guidance['label'])->toBe('Customer-safe review pack ready') + ->and($guidance['primary_action']['label'])->toBe('Download customer-safe review pack') + ->and($guidance['limitations'])->toBeEmpty(); +}); + +/** + * @param array $requiredSectionStates + * @param list $publishBlockers + * @return array + */ +function spec349Readiness( + string $evidenceState = EnvironmentReviewCompletenessState::Complete->value, + bool $hasReadyExport = true, + bool $includePii = false, + array $publishBlockers = [], + array $requiredSectionStates = [ + EnvironmentReviewCompletenessState::Complete->value => 5, + ], +): array { + return ReviewPackOutputReadiness::derive( + reviewStatus: 'published', + reviewCompletenessState: EnvironmentReviewCompletenessState::Complete->value, + evidenceCompletenessState: $evidenceState, + sectionStateCounts: $requiredSectionStates, + requiredSectionCount: array_sum($requiredSectionStates), + requiredSectionStateCounts: $requiredSectionStates, + publishBlockers: $publishBlockers, + hasReadyExport: $hasReadyExport, + includePii: $includePii, + protectedValuesHidden: true, + disclosurePresent: true, + ); +} diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php index 8b88325e..8dbe0d1a 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php @@ -200,8 +200,8 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) ->assertSee(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review->fresh()], $tenant), false) - ->assertSee('Published with limitations') - ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->assertSee('Output not customer-ready') + ->assertSee('Review blockers are still recorded for this output.') ->assertSee('Download review pack with limitations') ->assertSee('Available'); }); diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php index c89e72d6..68244d56 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php @@ -364,8 +364,8 @@ Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) ->assertSee('What is the current review pack output state?') - ->assertSee('Published with limitations') - ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->assertSee('Output not customer-ready') + ->assertSee('Review blockers are still recorded for this output.') ->assertSee('No operation proof linked') ->assertSee('Export ready') ->assertDontSee('Ready to share') diff --git a/docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md b/docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md index 51b04463..3dadc7bf 100644 --- a/docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md +++ b/docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md @@ -73,3 +73,37 @@ ### Browser proof ### Deferred - The review-pack detail resource and surrounding environment-review detail copy remain intentionally narrow; Spec 347 only touches the workspace/readiness path and supporting handoff copy where needed for contract consistency. + +## Spec 349 Follow-up + +Spec 349 productizes the raw Spec-347 readiness semantics into bounded operator guidance instead of exposing a warning wall. + +- The top decision card now resolves to one dominant output state and one dominant next action: + - `Output not customer-ready` + - `Published with limitations` + - `Internal review package available` + - `Customer-safe review pack ready` +- Multiple readiness limitations are grouped behind one compact disclosure instead of competing as peer alerts. +- Technical details stay collapsed by default and remain available as secondary proof. +- Download labels are now readiness-qualified across workspace and customer-workspace detail surfaces: + - `Download customer-safe review pack` + - `Download internal review pack` + - `Download review pack with limitations` +- Environment Review detail now separates: + - `Review status` + - `Output readiness` + - `Publication/sharing state` + +### Browser proof + +- Spec349 screenshots: `specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/` +- Verified states: + - output blocked / publication-blocked guidance + - internal-only / PII-bearing export + - customer-safe ready + - limitations and technical-details disclosures collapsed by default + +### Repo-truth note + +- The user-draft audit-doc target `ui-009-review-pack-output-contract.md` conflicts with repo truth. +- `ui-009` is already reserved for Provider Connections, so Spec 349 keeps the durable audit update on `ui-006-customer-review-workspace.md`. diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/01-output-blocked.png b/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/01-output-blocked.png new file mode 100644 index 00000000..f32c4663 Binary files /dev/null and b/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/01-output-blocked.png differ diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/02-internal-only.png b/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/02-internal-only.png new file mode 100644 index 00000000..d140e50b Binary files /dev/null and b/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/02-internal-only.png differ diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/03-customer-safe-ready.png b/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/03-customer-safe-ready.png new file mode 100644 index 00000000..36e51d78 Binary files /dev/null and b/specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/03-customer-safe-ready.png differ diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/checklists/requirements.md b/specs/349-customer-review-workspace-output-resolution-guidance/checklists/requirements.md new file mode 100644 index 00000000..4c549540 --- /dev/null +++ b/specs/349-customer-review-workspace-output-resolution-guidance/checklists/requirements.md @@ -0,0 +1,43 @@ +# Requirements Checklist: Spec 349 - Customer Review Workspace Output Resolution Guidance + +**Purpose**: Validate that Spec 349 is bounded, repo-based, constitution-aligned, and ready for later implementation. +**Created**: 2026-06-03 +**Feature**: `specs/349-customer-review-workspace-output-resolution-guidance/spec.md` + +## Candidate Selection And Scope + +- [x] CHK001 The package names the direct user-provided candidate source and its roadmap alignment with Customer Review Workspace v1 completion. +- [x] CHK002 Completed adjacent specs are treated as historical context only and are not reopened or normalized. +- [x] CHK003 The scope is narrowed to output-guidance productization over existing readiness truth, workspace, and review detail surfaces only. +- [x] CHK004 Explicit non-goals block a workflow engine, persistence, portal, renderer, and broad surface redesign. + +## Repo Truth And Architecture + +- [x] CHK005 The spec and plan anchor the work to the existing `ReviewPackOutputReadiness` truth and current scoped routes. +- [x] CHK006 The artifacts state that any new guidance state remains derived-only and not a persisted domain state. +- [x] CHK007 The plan avoids inventing a new route family, hidden shell context, or second raw-readiness dialect. +- [x] CHK008 The user-draft conflict around `ui-009-review-pack-output-contract.md` is corrected explicitly in the spec and repo-truth map. + +## UI/Productization Coverage + +- [x] CHK009 UI Surface Impact is explicit and consistent with the intended workspace/detail changes. +- [x] CHK010 UI/Productization Coverage identifies the existing strategic workspace page report and avoids inventing new route/archetype coverage unnecessarily. +- [x] CHK011 The spec requires one dominant output state and one dominant next action rather than many equal-weight warnings. +- [x] CHK012 Audience-aware disclosure keeps technical details secondary and customer-safe wording conservative. + +## Testing And Validation + +- [x] CHK013 Planned tests cover workspace guidance, review-pack resolution mapping, detail-surface separation, and one bounded browser smoke. +- [x] CHK014 The validation commands explicitly rerun Spec 347 regressions plus filtered workspace/review-pack coverage. +- [x] CHK015 The artifacts name `pint --dirty` and `git diff --check` as final prep-aligned validation steps. + +## Review Outcome + +- [x] CHK016 Review outcome class: `acceptable-special-case` +- [x] CHK017 Workflow outcome: `keep` +- [x] CHK018 Final note location is the active feature PR close-out entry `Guardrail / Smoke Coverage`. + +## Notes + +- This checklist validates preparation readiness only. No application implementation has been performed. +- The remaining implementation choice is intentionally narrow: extend `ReviewPackOutputReadiness` directly or wrap it with one bounded guidance adapter. diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/plan.md b/specs/349-customer-review-workspace-output-resolution-guidance/plan.md new file mode 100644 index 00000000..ab6763de --- /dev/null +++ b/specs/349-customer-review-workspace-output-resolution-guidance/plan.md @@ -0,0 +1,247 @@ +# Implementation Plan: Spec 349 - Customer Review Workspace Output Resolution Guidance + +**Branch**: `349-customer-review-workspace-output-resolution-guidance` | **Date**: 2026-06-03 | **Spec**: `specs/349-customer-review-workspace-output-resolution-guidance/spec.md` +**Input**: User-provided Spec 349 draft + repo truth from current Spec 347 readiness work, current Customer Review Workspace runtime, and current Environment Review detail surface. + +## Summary + +Translate existing Review Pack output-readiness truth into calmer operator guidance without changing the underlying workflow model. + +This slice should: + +- reuse current `ReviewPackOutputReadiness` truth +- convert the highest-priority limitation into one dominant blocker and one dominant next action +- group remaining limitations into compact disclosure +- qualify download wording honestly +- surface PII/internal-only boundaries explicitly +- separate review publication/completeness from output readiness on Environment Review detail + +This slice must not: + +- create persistence +- create a new workflow engine or state machine +- reopen Review Pack generation semantics beyond the already-completed Spec 347 contract +- build a portal or renderer +- weaken current authorization, workspace isolation, or signed-download safety + +## Technical Context + +- **Language/Version**: PHP 8.4.15, Laravel 12.52.x +- **Primary Dependencies**: Filament 5.2.x, Livewire 4.1.x, Pest 4, Tailwind CSS 4 +- **Storage**: PostgreSQL; no schema change expected +- **Testing**: Pest Feature/Livewire tests plus one bounded Pest Browser smoke file +- **Validation Lanes**: confidence + browser +- **Target Platform**: `apps/platform` Laravel monolith; Sail-first locally; Dokploy posture unchanged +- **Project Type**: web application with server-rendered Filament/Blade surfaces +- **Performance Goals**: no new remote calls during render, no new queue family, and no duplicate read-model layer beyond a bounded derived guidance adapter +- **Constraints**: no false customer-safe wording, no warning wall, no hidden shell-scope behavior, no new route family, no new persisted guidance state, and no detail-surface redesign outside the output-guidance slice +- **Scale/Scope**: one strategic workspace surface, one review detail surface, existing review-pack proof/download path, focused Feature coverage, and one Browser smoke + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: material change to an existing strategic customer-safe review surface plus an existing review detail surface +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: + - `/admin/reviews/workspace` + - `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` + - `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` + - `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` + - `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php` + - existing review-pack download wording as reached from those surfaces +- **No-impact class, if applicable**: N/A +- **Native vs custom classification summary**: native Filament page/resource plus existing Blade/infolist composition; no new route or panel/provider +- **Shared-family relevance**: status messaging, next-action guidance, disclosure, proof links, qualified download wording +- **State layers in scope**: page payload, detail payload, URL-query filter on workspace, derived output-guidance payload only +- **Audience modes in scope**: operator-MSP, customer-safe review consumer, support where authorized +- **Decision/diagnostic/raw hierarchy plan**: one output verdict first, grouped limitations second, technical details third +- **Raw/support gating plan**: keep technical details collapsed or clearly secondary; keep support/raw detail capability-gated where already applicable +- **One-primary-action / duplicate-truth control**: preserve one dominant next action and remove repeated blocker summaries from lower panels +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory because this is a strategic trust surface +- **Special surface test profiles**: `global-context-shell` + `shared-detail-family` +- **Required tests or manual smoke**: functional-core + browser smoke +- **Exception path and spread control**: one bounded guidance adapter is allowed; no cross-domain framework +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **UI/Productization coverage decision**: update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`; add a second report only if implementation proves the existing identity cannot absorb the detail-surface notes cleanly +- **Coverage artifacts to update**: existing workspace page report only unless repo truth later proves more is required +- **Navigation / Filament provider-panel handling**: N/A; no panel/provider change expected +- **Screenshot or page-report need**: yes, because this is a strategic customer-safe surface and one bounded browser smoke will produce proof artifacts + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: + - `App\Support\ReviewPacks\ReviewPackOutputReadiness` + - `App\Filament\Pages\Reviews\CustomerReviewWorkspace` + - `App\Filament\Resources\EnvironmentReviewResource` + - `App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview` + - existing review-pack and evidence link helpers +- **Shared abstractions reused**: + - current limitation codes and readiness fields from `ReviewPackOutputReadiness` + - current workspace link/action helper paths + - current Environment Review artifact-truth and summary presentation +- **New abstraction introduced? why?**: maybe one narrow `ReviewPackOutputResolutionGuidance`-style adapter if direct extension of `ReviewPackOutputReadiness` would blur raw output truth and UI guidance responsibilities +- **Why the existing abstraction was sufficient or insufficient**: current readiness truth is sufficient as the source, but it is not yet shaped for grouped operator guidance across multiple surfaces +- **Bounded deviation / spread control**: any new guidance adapter must remain local to review output guidance and must not become a generic workflow-resolution framework + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: existing proof-link usage only +- **Central contract reused**: existing Review Pack / Environment Review proof links +- **Delegated UX behaviors**: unchanged +- **Surface-owned behavior kept local**: one-blocker ranking, grouped limitation copy, and qualified next-action wording +- **Queued DB-notification policy**: unchanged +- **Terminal notification path**: unchanged +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no new provider seam +- **Provider-owned seams**: N/A +- **Platform-core seams**: output readiness, customer-safe/internal-only/blocked guidance vocabulary +- **Neutral platform terms / contracts preserved**: review pack, evidence basis, limitation, customer-safe, internal-only, next action +- **Retained provider-specific semantics and why**: only where current review/evidence text already carries provider-backed content +- **Bounded extraction or follow-up path**: none + +## Current Repo Truth Summary + +- `App\Support\ReviewPacks\ReviewPackOutputReadiness` already derives: + - `readiness_state` + - `customer_safe_state` + - `primary_reason` + - `primary_action` + - `limitations` + - `section_summary` +- `CustomerReviewWorkspace` already folds that truth into: + - `effectiveWorkspaceReadinessState()` + - `workspaceReadinessLabel()` + - `workspaceReadinessReason()` + - `workspaceReadinessImpact()` + - `workspaceReadinessActions()` + - current decision card and proof-panel payloads +- Current workspace gaps: + - no grouped limitation list tied to one dominant blocker + - no explicit technical-details disclosure contract for output guidance + - existing primary/secondary action mapping is still spread across multiple helper methods +- `EnvironmentReviewResource` / `ViewEnvironmentReview` already own the detail surface: + - infolist sections for outcome summary, review, executive posture, and sections + - customer-workspace mode that narrows header actions + - no explicit summary block separating review publication/completeness from output-readiness/sharing state +- Route truth is already stable and workspace/environment scoped; no new route family is needed +- Existing page audit identity is `ui-006-customer-review-workspace.md` + +## Implementation Approach + +### Phase 0 - Repo Truth Gate + +1. Re-read the prepared `spec.md`, `plan.md`, `tasks.md`, `repo-truth-map.md`, and `checklists/requirements.md` before runtime edits. +2. Re-check current runtime truth in: + - `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php` + - `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` + - `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` + - `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` + - `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php` +3. Keep `specs/349-customer-review-workspace-output-resolution-guidance/repo-truth-map.md` current if runtime inspection reveals additional bounded truth. + +### Phase 1 - Tests First + +1. Add focused guidance tests before runtime refactor: + - `apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php` + - `apps/platform/tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php` + - `apps/platform/tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php` + - `apps/platform/tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php` +2. Lock the following before runtime changes: + - one primary output state + - one primary next action + - grouped limitation disclosure + - qualified download wording + - explicit PII/internal-only warning + - collapsed technical details by default + - review detail separation of status dimensions +3. Reuse current review/evidence/review-pack fixtures; do not widen default helper cost. + +### Phase 2 - Bounded Guidance Adapter + +1. Choose the narrowest implementation home: + - extend `ReviewPackOutputReadiness` with derived presentation fields only if raw truth remains legible, or + - add a small `ReviewPackOutputResolutionGuidance` companion under `app/Support/ReviewPacks/` +2. Keep the guidance layer derived-only: + - display state + - label + - severity + - primary reason + - impact + - primary action + - grouped limitations + - secondary actions + - technical-details payload +3. If additional display states such as `publication_blocked` are needed, keep them presentation-only and map them from current limitation codes or publish-blocker truth. + +### Phase 3 - Action Mapping And Copy + +1. Map limitation codes to plain-language guidance: + - evidence basis incomplete -> open evidence basis + - required sections incomplete -> review section limitations + - mapping/control limitations -> review unmapped evidence or control interpretation + - publish blockers -> resolve review blockers + - contains PII -> review package contents / PII state + - export not ready -> review output limitations +2. Prefer existing route helpers and scoped resource URLs. +3. Keep button and notification vocabulary aligned to current localization patterns: `Verb + Object`, conservative sharing language, no false-ready wording. + +### Phase 4 - Customer Review Workspace Update + +1. Update `CustomerReviewWorkspace` payload building to consume the bounded guidance object instead of scattered reason/action logic. +2. Update the Blade view to show: + - one output-guidance label + - one primary reason + - one impact statement + - one primary action + - compact grouped limitations + - qualified secondary download/action wording + - collapsed technical details +3. Preserve current acknowledgement, accepted-risk, findings, and proof sections unless a minimal copy/order change is required to support the one-blocker hierarchy. + +### Phase 5 - Environment Review Detail Update + +1. Update `EnvironmentReviewResource` / `ViewEnvironmentReview` so the detail surface clearly separates: + - review status + - output readiness + - publication/sharing state +2. Keep fingerprint and raw proof detail secondary or hidden in customer-workspace mode. +3. Preserve customer-workspace-mode access, download safety, and current lifecycle-action behavior outside that mode. + +### Phase 6 - Localization, Audit, And Browser Proof + +1. Update only the required output-guidance keys in: + - `apps/platform/lang/en/localization.php` + - `apps/platform/lang/de/localization.php` +2. Update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md` with the new guidance model, one-primary-action rule, grouped limitation behavior, and repo-truth note about the missing `ui-009-review-pack-output-contract.md`. +3. Capture screenshots under `specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/`. + +### Phase 7 - Validation And Close-Out + +1. Run focused Feature tests for the new guidance layer and the current Spec 347 regressions. +2. Run the bounded Browser smoke for representative states. +3. Run `pint --dirty` and `git diff --check`. +4. Record any unrelated failures separately without widening scope. + +## Validation Plan + +```bash +cd apps/platform +./vendor/bin/sail artisan test tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php --compact +./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php --compact +./vendor/bin/sail artisan test --compact --filter=Spec347 +./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace +./vendor/bin/sail artisan test --compact --filter=ReviewPack +./vendor/bin/sail pint --dirty +git diff --check +``` + +## Deployment Impact + +- **Env vars**: none expected +- **Migrations**: none +- **Queues / scheduler**: none +- **Storage**: none +- **Assets**: no new Filament asset registration expected; `filament:assets` is not newly required by this change diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/repo-truth-map.md b/specs/349-customer-review-workspace-output-resolution-guidance/repo-truth-map.md new file mode 100644 index 00000000..14587772 --- /dev/null +++ b/specs/349-customer-review-workspace-output-resolution-guidance/repo-truth-map.md @@ -0,0 +1,82 @@ +# Spec 349 - Repo Truth Map + +Status: implemented +Created: 2026-06-03 +Scope: Customer Review Workspace output resolution guidance and Environment Review detail output-guidance separation + +This map records the repo-backed truth that Spec 349 is allowed to harden. It must be updated if runtime inspection during implementation reveals a narrower or broader bounded truth boundary. + +## Implementation Update + +- Shared derived guidance now lives in `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php`. +- Workspace top-card rendering consumes that shared mapping in `CustomerReviewWorkspace.php` and `customer-review-workspace.blade.php`. +- Environment Review detail consumes the same mapping through `EnvironmentReviewResource::outputGuidanceState()` and `filament/infolists/entries/review-pack-output-guidance.blade.php`. + +## Classification Vocabulary + +- `repo-verified`: directly observed in runtime code, tests, routes, or completed adjacent specs +- `derived from existing truth`: can be computed safely from current models or payloads +- `gap`: no current operator-guidance contract exists even though raw truth exists +- `deferred`: intentionally out of scope for Spec 349 + +## Current Output-Readiness Truth + +| Data point | Classification | Repo evidence | Spec 349 handling | +|---|---|---|---| +| `ReviewPackOutputReadiness` exists | repo-verified | `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php` | Reuse as the raw readiness source | +| Current readiness states are `customer_safe_ready`, `published_with_limitations`, `internal_review_package_available`, `export_not_ready` | repo-verified | same class constants | Reuse; any extra display state must remain derived-only | +| Current limitation codes include `export_not_ready`, evidence-basis codes, `required_sections_incomplete`, `publish_blockers_present`, `contains_pii`, and `disclosure_missing` | repo-verified | same class | Reuse as source for grouped guidance | +| Current readiness already exposes `primary_reason`, `primary_action`, `limitations`, and `section_summary` | repo-verified | same class return payload | Prefer reuse before adding new fields | +| Current readiness is derived-only and not persisted | repo-verified | same class plus current data model | Preserve; no new persisted truth | + +## Current Customer Review Workspace Truth + +| Data point | Classification | Repo evidence | Spec 349 handling | +|---|---|---|---| +| Strategic decision card already exists | repo-verified | `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` | Keep as the first decision surface | +| Workspace already derives label, reason, impact, primary action, and secondary action from readiness truth | repo-verified | `workspaceReadinessLabel()`, `workspaceReadinessReason()`, `workspaceReadinessImpact()`, `workspaceReadinessActions()` in `CustomerReviewWorkspace.php` | Consolidate into calmer grouped guidance | +| Effective state already folds finding follow-up and accepted-risk follow-up into `published_with_limitations` | repo-verified | `effectiveWorkspaceReadinessState()` | Preserve honest customer-safe logic | +| Workspace already has proof/detail panels and diagnostics sections | repo-verified | Blade view + page payload methods | Keep proof secondary; do not redesign the page broadly | +| Workspace does not yet expose a compact grouped limitation list tied to one dominant blocker | gap | current decision card and proof panel structure | Primary gap to close | +| Workspace does not yet expose a dedicated technical-details disclosure contract for output guidance | gap | current payload/view structure | Add bounded disclosure only | + +## Current Environment Review Detail Truth + +| Data point | Classification | Repo evidence | Spec 349 handling | +|---|---|---|---| +| Detail surface is Filament infolist-driven | repo-verified | `EnvironmentReviewResource::infolist()` | Do not invent a custom detail page unless repo truth later forces it | +| Customer-workspace mode narrows header actions on review detail | repo-verified | `ViewEnvironmentReview::getHeaderActions()` | Preserve current access/handoff behavior | +| Detail surface already shows artifact truth, review fields, executive posture, and sections | repo-verified | `EnvironmentReviewResource::infolist()` | Reorder or refine only as needed for clearer status separation | +| Detail surface does not yet expose an explicit "review status vs output readiness vs sharing/publication state" summary | gap | current infolist sections and labels | Primary gap to close | + +## Current Route And Scope Truth + +| Data point | Classification | Repo evidence | Spec 349 handling | +|---|---|---|---| +| Workspace route is `/admin/reviews/workspace` | repo-verified | route list | Keep unchanged | +| Environment Review detail route is workspace/environment scoped | repo-verified | route list for `environment-reviews/{record}` | Keep unchanged | +| Review Pack routes are workspace/environment scoped plus one signed download route | repo-verified | route list for `review-packs` and `admin/review-packs/{reviewPack}/download` | Keep unchanged | +| Evidence overview and detail routes already exist | repo-verified | route list for `/admin/evidence/overview` and scoped evidence detail | Reuse for guidance links where needed | +| Workspace surface uses explicit `environment_id` query filtering | repo-verified | `CustomerReviewWorkspace::environmentFilterUrl()` and current tests | Preserve; no hidden shell-state fallback | + +## Current Documentation Truth + +| Data point | Classification | Repo evidence | Spec 349 handling | +|---|---|---|---| +| Durable workspace page report exists as `ui-006-customer-review-workspace.md` | repo-verified | docs audit file | Update this report | +| No `ui-009-review-pack-output-contract.md` exists | repo-verified | docs audit inventory; `ui-009` is Provider Connections | Record this user-draft conflict explicitly; do not invent or overwrite `ui-009` | + +## Current Test Truth + +| Test surface | Classification | Repo evidence | Spec 349 handling | +|---|---|---|---| +| Output-readiness contract and workspace wording already have Spec 347 coverage | repo-verified | `Spec347ReviewPackOutputContractTest.php`, `Spec347ReviewPackReadinessSemanticsTest.php`, `Spec347CustomerReviewWorkspaceOutputReadinessTest.php` | Reuse as regression base | +| Customer Review Workspace already has broader page and pack-access tests | repo-verified | `CustomerReviewWorkspacePageTest.php`, `CustomerReviewWorkspacePackAccessTest.php` | Extend or reuse proportionally | +| Environment Review detail already has UI contract coverage | repo-verified | `EnvironmentReviewUiContractTest.php` | Extend or reuse proportionally | + +## Primary Repo-Truth Gaps To Close + +1. No grouped output-resolution guidance object exists yet over the raw readiness truth. +2. No one-primary-blocker + one-primary-action contract is enforced across workspace and detail surfaces. +3. Review detail does not yet distinguish review publication/completeness from output-readiness/sharing truth clearly enough. +4. The user-draft audit-doc target conflicts with existing repo numbering and must be corrected explicitly. diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/spec.md b/specs/349-customer-review-workspace-output-resolution-guidance/spec.md new file mode 100644 index 00000000..2f81890a --- /dev/null +++ b/specs/349-customer-review-workspace-output-resolution-guidance/spec.md @@ -0,0 +1,394 @@ +# Feature Specification: Spec 349 - Customer Review Workspace Output Resolution Guidance + +**Feature Branch**: `349-customer-review-workspace-output-resolution-guidance` +**Created**: 2026-06-03 +**Status**: Draft +**Type**: Platform productization / operator guidance / output-readiness resolution UX +**Runtime posture**: Narrow runtime hardening over existing Review Pack output-readiness truth, Customer Review Workspace guidance, and Environment Review detail surfaces. No new persistence, no workflow engine, no portal, and no PDF/HTML renderer. +**Input**: User-provided full Spec 349 draft + repo truth from current Customer Review Workspace, Environment Review detail, Review Pack routes, and completed Spec 347 output-readiness work. + +## Dependencies And Historical Context + +This spec is a bounded follow-up over already repo-real review, evidence, and customer-safe productization work: + +- Spec 258 - Customer Review Workspace Productization +- Spec 308 - Decision Register Summary / Review Pack Inclusion +- Spec 311 - Workspace / Environment Surface Scope Contract +- Spec 326 - Customer Review Workspace v1 Productization +- Spec 342 - Customer Review Workspace Final Consumption Productization +- Spec 343 - Customer Review Attestation / Accepted Risk Lifecycle +- Spec 344 - Customer Review Workspace Density / Audience Polish +- Spec 347 - Review Pack Output Contract & Readiness Semantics + +Repo-truth adjustments against the user draft: + +- The runtime already has a bounded derived readiness layer in `App\Support\ReviewPacks\ReviewPackOutputReadiness`; Spec 349 should extend or adapt that truth rather than invent a second raw-readiness dialect. +- `CustomerReviewWorkspace` already maps readiness into a primary label, reason, impact, and primary/secondary action, but it does not yet expose a grouped resolution-guidance object with one dominant blocker and compact limitation disclosure. +- The review detail surface is Filament infolist-driven through `EnvironmentReviewResource` and `ViewEnvironmentReview.php`; there is no dedicated custom Blade detail page to redesign. +- The existing durable audit report for this surface is `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`; the user-draft reference to `ui-009-review-pack-output-contract.md` conflicts with repo truth because `ui-009` is already Provider Connections. +- Exact primary runtime routes are already repo-backed: + - `/admin/reviews/workspace` + - `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews` + - `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}` + - `/admin/workspaces/{workspace}/environments/{environment}/review-packs` + - `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}` + - `/admin/review-packs/{reviewPack}/download` + - `/admin/evidence/overview` + - `/admin/workspaces/{workspace}/environments/{environment}/evidence/{record}` + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Spec 347 made Review Pack output truth more explicit, but the operator still has to translate readiness codes, limitation flags, and related proof surfaces into one next action. +- **Today's failure**: Customer Review Workspace and Environment Review detail can already show qualified output states such as `Published with limitations`, but the operator still has to infer which limitation matters most, where to go next, and whether the package is customer-safe, internal-only, or simply blocked. +- **User-visible improvement**: The product surfaces expose one clear output-guidance state, one dominant next action, a compact grouped limitation list, honest download wording, and collapsed technical details. +- **Smallest enterprise-capable version**: Reuse the existing output-readiness truth and current surface routes, add one bounded guidance layer over it, update Customer Review Workspace and Review Detail disclosure, and add focused tests plus browser smoke. +- **Explicit non-goals**: No new table, no persisted resolution records, no new queue family, no workflow engine, no portal, no PDF/HTML renderer, no PSA/ITSM handoff, no review-pack generator rewrite, no broad Governance Inbox redesign, no localization overhaul beyond touched output-guidance copy. +- **Permanent complexity imported**: One repo-truth map, one requirements checklist, focused plan/tasks artifacts, and likely one bounded guidance presenter/helper or an extension of the existing readiness helper. No new persisted entity, no new public state machine, and no new panel or route family. +- **Why now**: Spec 347 solved contract truth first. The next bottleneck is operator comprehension and calm decision-making on the current sellable review-consumption path. +- **Why not local**: Copy-only tweaks would leave workspace and detail surfaces free to interpret the same readiness codes differently. This needs one bounded mapping from current readiness truth to operator guidance. +- **Approval class**: Workflow Compression. +- **Red flags triggered**: Strategic customer-safe surface, status/next-action semantics, and shared interaction-family reuse. Defense: the slice is explicitly derived-only, reuses current routes and readiness truth, and forbids new persistence or a 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 349 draft + - roadmap lane: Customer Review Workspace v1 Completion / customer-safe review consumption + - spec-candidate alignment: bounded follow-up under `customer-review-workspace-v1-completion` +- **Completed-spec guardrail result**: + - no `specs/349-*` package existed before this prep + - Specs 258, 308, 311, 326, 342, 343, 344, and 347 carry completed-task, close-out, validation, or historical implementation signals and are treated as context only + - no completed spec is being reopened or normalized by this prep +- **Close alternatives deferred**: + - Localization v1 customer-facing surfaces + - Decision-Based Governance Inbox v1 + - Provider readiness / onboarding productization + - Review Pack PDF/HTML renderer and Customer Portal boundary work +- **Smallest viable implementation slice**: existing output-readiness truth plus Customer Review Workspace and Environment Review detail guidance only: one dominant blocker, one primary action, grouped limitations, qualified download labels, collapsed technical details, and focused validation. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace canonical-view plus environment-owned review/evidence/review-pack artifact surfaces. +- **Primary Routes**: + - `/admin/reviews/workspace` + - `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}` + - `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}` + - `/admin/review-packs/{reviewPack}/download` + - `/admin/evidence/overview` + - `/admin/workspaces/{workspace}/environments/{environment}/evidence/{record}` +- **Data Ownership**: + - `EnvironmentReview` remains review publication/completeness truth + - `EnvironmentReviewSection` remains section truth + - `EvidenceSnapshot` remains evidence-basis truth + - `ReviewPack` remains export artifact truth + - `ReviewPackOutputReadiness` remains derived output-readiness truth + - any new guidance state remains derived presentation only; no new persisted entity is introduced +- **RBAC**: + - existing workspace membership and managed-environment entitlement remain mandatory + - existing capabilities remain authoritative, especially `ENVIRONMENT_REVIEW_VIEW`, `REVIEW_PACK_VIEW`, and `EVIDENCE_VIEW` + - non-members and cross-workspace or cross-environment access remain deny-as-not-found + - no new public route family, query alias, or hidden shell-context fallback may be introduced + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: keep `environment_id` as the explicit page-local filter on `/admin/reviews/workspace`; do not revive hidden shell/session environment fallback. +- **Explicit entitlement checks preventing cross-tenant leakage**: workspace, environment-review, review-pack, download, and evidence links must continue to resolve only through current scoped routes, policies, and signed-download checks. + +## UI Surface Impact *(mandatory - UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [ ] No UI surface impact +- [x] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [ ] New modal/drawer/wizard/action added +- [x] New table/form/state added +- [x] Customer-facing surface changed +- [ ] Dangerous action changed +- [x] Status/evidence/review presentation changed +- [x] Workspace/environment context presentation changed + +## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")* + +- **Route/page/surface**: + - `CustomerReviewWorkspace` + - Environment Review detail (`ViewEnvironmentReview` / `EnvironmentReviewResource` infolist) + - Review Pack download wording as reached from those surfaces +- **Current or new page archetype**: existing strategic customer-safe review surface plus existing detail/proof surfaces; no new route archetype. +- **Design depth**: Strategic Surface for `CustomerReviewWorkspace`; Domain Pattern Surface for Environment Review detail. +- **Repo-truth level**: repo-verified existing runtime surface plus repo-verified completed Spec 347 readiness truth. +- **Existing pattern reused**: current decision card, current readiness proof panel, current detail infolist, current diagnostics/disclosure collapse pattern. +- **New pattern required**: one bounded output-resolution guidance adapter over current readiness truth; no new cross-domain UX framework. +- **Screenshot required**: yes, under `specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/`. +- **Page audit required**: update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`. If implementation later proves a second durable page report is needed for detail/proof coverage, it must use the next repo-real identity instead of overwriting `ui-009`. +- **Customer-safe review required**: yes. This spec directly governs customer-safe, internal-only, limited, and blocked output messaging. +- **Dangerous-action review required**: no new destructive action is added. Existing acknowledgement, publish, regenerate, and download safety remain authoritative. +- **Coverage files updated or explicitly not needed**: + - [ ] `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` + - [ ] `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-action guidance, action links, proof surfaces, customer-safe disclosure, download wording. +- **Systems touched**: + - `App\Support\ReviewPacks\ReviewPackOutputReadiness` + - `App\Filament\Pages\Reviews\CustomerReviewWorkspace` + - `App\Filament\Resources\EnvironmentReviewResource` + - `App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview` + - existing review-pack and evidence link helpers/routes +- **Existing pattern(s) to extend**: + - current output-readiness derivation + - current workspace decision card and proof panel + - current Environment Review artifact-truth and executive-posture sections +- **Shared contract / presenter / builder / renderer to reuse**: current `ReviewPackOutputReadiness` output, existing workspace link helpers, and current detail truth presentation before adding any new formatter. +- **Why the existing shared path is sufficient or insufficient**: current readiness truth is sufficient as the raw source, but it is not yet shaped into one grouped operator-guidance object that multiple surfaces can consume without copy drift. +- **Allowed deviation and why**: one bounded `ReviewPackOutputResolutionGuidance`-style companion or equivalent page-local formatter is allowed if extending `ReviewPackOutputReadiness` directly would blur raw truth and UI guidance too far. +- **Consistency impact**: limitation code -> label/reason/impact/action mapping must stay consistent across workspace, review detail, and qualified download wording. +- **Review focus**: block any second readiness dialect, any persisted resolution entity, or any generic workflow engine disguised as guidance. + +## OperationRun UX Impact *(mandatory)* + +- **Touches OperationRun start/completion/link UX?**: existing proof-link and artifact handoff only +- **Shared OperationRun UX contract/layer reused**: existing operation proof links and current review-pack generation lifecycle +- **Delegated start/completion UX behaviors**: unchanged +- **Local surface-owned behavior that remains**: blocker ranking, grouped limitation disclosure, and qualified next-action wording +- **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 output guidance over existing review/evidence/export truth +- **Seams affected**: review/output vocabulary only +- **Neutral platform terms preserved or introduced**: review pack, evidence basis, output readiness, publication blocked, internal-only, customer-safe, limitation, next action +- **Provider-specific semantics retained and why**: only where existing review/evidence content already contains provider-backed wording +- **Why this does not deepen provider coupling accidentally**: no Graph, provider contract, identifier, or platform-core taxonomy change is introduced +- **Follow-up path**: none + +## UI / Surface Guardrail Impact + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Customer Review Workspace decision card and limitation summary | yes | Native Filament page plus existing Blade composition | readiness, next action, disclosure, download wording | page, URL-query, derived payload | no | Existing route only | +| Customer Review Workspace review-pack proof panel | yes | Native Filament page plus existing Blade composition | proof details, evidence basis, section completeness, PII visibility | page payload | no | Derived only | +| Environment Review detail summary and posture sections | yes | Native Filament resource/infolist | review status vs output readiness vs sharing state | detail payload | no | Existing detail route only | +| Review Pack detail/download handoff wording | no by default | existing Filament resource/detail | artifact handoff wording only if contradiction fix becomes unavoidable | none unless repo truth forces a minimal consistency patch | yes if unexpectedly touched | keep out of scope unless a direct contradiction is proven | + +## Decision-First Surface Role + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Customer Review Workspace | Primary Decision Surface | Operator decides whether the current package is customer-safe, limited, internal-only, or blocked | one status, one reason, one next action, limitation count, qualified download state | detailed limitation list, evidence link, review detail, operation proof | Primary because it is the first customer-safe consumption screen | follows review handoff workflow | removes interpretation work across multiple panels and labels | +| Environment Review detail | Secondary Context | Operator verifies why the review output is blocked or limited before acting | review status, output readiness, publication/sharing state, next action | sections, evidence, review-pack proof, raw artifact truth | Secondary because it supports the first-screen decision | follows inspect-and-resolve workflow | keeps proof secondary and avoids another equal-weight warning wall | +| Review Pack detail/download | Secondary Context | Operator verifies artifact truth and authorized download path | artifact availability and qualified download meaning | file metadata, operation proof, download audit | Secondary because it is artifact proof, not the first decision surface | supports export verification | prevents detail-only semantics from leaking into the primary screen | + +## Audience-Aware Disclosure + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Customer Review Workspace | operator-MSP, customer-safe review consumer, support where authorized | output status, primary blocker, impact, next action, limitation count, qualified download label | limitation list, section summary, evidence basis state, PII/redaction notes | raw payloads, fingerprints, lower-level proof details | one primary resolution/download action | technical details collapsed or secondary | top card states the blocker once; lower sections add evidence only | +| Environment Review detail | operator-MSP, support where authorized | review status, output readiness, publication/sharing state, next action | section counts, publish blockers, evidence basis, current export truth | raw metadata, fingerprints, support-only proof details | open the primary blocker destination | raw/support detail stays secondary and capability-scoped | detail view distinguishes status dimensions instead of restating them ambiguously | + +## UI/UX Surface Classification + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Customer Review Workspace | Utility / Workspace Decision | Read-only strategic review hub | resolve the highest-priority blocker or download the qualified pack | explicit primary action in the decision card | forbidden | secondary links remain visually secondary | none in scope | `/admin/reviews/workspace` | existing review/evidence/pack routes only | workspace shell plus explicit `environment_id` filter | Customer Review Workspace | one output state, one blocker, one next action | none | +| Environment Review detail | Detail / Governance Artifact Detail | Read-only review detail | inspect the output blocker and open the linked proof surface | existing detail route | current repo-real behavior only | contextual detail links and secondary proof stay inside sections | existing lifecycle actions remain outside customer-workspace mode | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews` | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}` | workspace + environment route scope | Review detail | review status, output readiness, sharing state | none | + +## Operator Surface Contract + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Customer Review Workspace | MSP/workspace operator | decide whether current output can be shared and what to resolve first | workspace review hub | Can I use or share this package, and what do I do next? | output status, blocker, impact, next action, limitation count, qualified download state | limitation details, section/evidence proof, operation proof | review publication, review completeness, output readiness, customer-safe boundary | none by default | review/open/download qualified package | none in scope | +| Environment Review detail | MSP/operator reviewer | inspect why a review is blocked or limited and open the right supporting context | read-only detail | Why is this review not customer-ready, and which proof surface explains it? | review status, output readiness, sharing/publication state, next action | sections, evidence, artifact proof, fingerprint and raw metadata | review publication, completeness, output readiness, artifact availability | existing lifecycle mutations remain unchanged and out of customer-workspace mode | open proof/evidence/review-pack links | existing publish/archive/export actions remain unchanged and already confirmation-gated where applicable | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: maybe one bounded presentation adapter over current readiness truth +- **New enum/state/reason family?**: no persisted family; any added `publication_blocked` or similar state must remain derived presentation only +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: the runtime knows why output is limited, but the operator still has to assemble the right conclusion and next action manually. +- **Existing structure is insufficient because**: current readiness truth is optimized for contract/data correctness, not for grouped operator guidance across workspace and detail surfaces. +- **Narrowest correct implementation**: reuse the current readiness helper, derive one compact guidance object, and update only the current workspace/detail surfaces plus wording. +- **Ownership cost**: one bounded helper or formatter, focused tests, browser smoke, and one page-report update. +- **Alternative intentionally rejected**: new workflow engine, persisted resolution items, portal, or broad cross-domain guidance framework. +- **Release truth**: current-release truth. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility shims, legacy route aliases, old audit-report renumbering, and compatibility-specific UI states are out of scope unless repo truth later proves they are required. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature, Browser +- **Validation lane(s)**: confidence, browser +- **Why this classification and these lanes are sufficient**: the change is a deterministic mapping and disclosure hardening over existing server-rendered surfaces. Focused Feature tests can prove grouped limitation behavior, one-primary-action rules, qualified download labels, and detail-surface separation. One bounded Browser smoke is required because this is a strategic customer-safe first-screen surface. +- **New or expanded test families**: + - `apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php` + - `apps/platform/tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php` + - `apps/platform/tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php` + - `apps/platform/tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php` +- **Relevant existing regressions to rerun**: + - `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php` + - `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php` + - `apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php` + - `apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php` + - `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` + - `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` + - `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php` +- **Fixture / helper cost impact**: reuse current review/evidence/review-pack helpers and avoid widening default browser or workspace fixtures. +- **Heavy-family visibility / justification**: one explicit browser smoke only +- **Special surface test profile**: `global-context-shell` + `shared-detail-family` + strategic customer-safe review surface +- **Standard-native relief or required special coverage**: special coverage required for no-false-share wording, one-primary-action discipline, grouped limitations, and detail-surface status separation +- **Reviewer handoff**: reviewers must confirm no new persistence, no second readiness dialect, no weakened signed-download behavior, and no equal-weight warning wall on the workspace/detail surfaces +- **Budget / baseline / trend impact**: none expected beyond one explicit browser smoke addition +- **Escalation needed**: `document-in-feature` if a minor detail-surface contradiction must be recorded; `follow-up-spec` only if repo truth reveals a broader output-guidance framework need +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Planned validation commands**: + +```bash +cd apps/platform +./vendor/bin/sail artisan test tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php --compact +./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php --compact +./vendor/bin/sail artisan test --compact --filter=Spec347 +./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace +./vendor/bin/sail artisan test --compact --filter=ReviewPack +./vendor/bin/sail pint --dirty +git diff --check +``` + +## Summary + +Spec 347 already established the output-readiness contract. Spec 349 must not reopen that truth. The bounded job here is to productize it: + +- one primary output state +- one dominant next action +- grouped limitations +- qualified download wording +- explicit internal-only / customer-safe boundary +- collapsed technical details +- distinct review status vs output readiness vs sharing/publication state on the detail surface + +The implementation should extend existing readiness truth and current routes only. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See one clear blocker on the workspace (Priority: P1) + +As an MSP operator, I need the Customer Review Workspace to translate current output-readiness limitations into one dominant blocker and one next action so I can decide quickly whether the package is ready, limited, internal-only, or blocked. + +**Why this priority**: This is the first customer-safe consumption surface and the main sellability gap after Spec 347. + +**Independent Test**: Can be fully tested by loading the workspace with ready, limited, blocked, and internal-only fixtures and asserting one state, one primary action, and honest download wording. + +**Acceptance Scenarios**: + +1. **Given** the current review output has publish blockers or missing evidence, **When** the workspace loads, **Then** it shows one dominant blocker, one primary next action, and grouped supporting limitations instead of many equal-weight warnings. +2. **Given** the output is customer-safe and export-ready, **When** the workspace loads, **Then** it shows a customer-safe state with a qualified download action. + +--- + +### User Story 2 - Inspect limitations without a warning wall (Priority: P1) + +As an operator, I need limitation details and technical metadata available on demand without dominating the default screen so I can verify the proof without losing the first decision. + +**Why this priority**: Spec 347 increased truth density; the next step is progressive disclosure, not more visible warnings. + +**Independent Test**: Can be tested by asserting grouped limitation disclosure, collapsed technical details, and visible PII/internal-only warnings where applicable. + +**Acceptance Scenarios**: + +1. **Given** multiple output limitations exist, **When** the workspace or review detail renders, **Then** the limitations appear as a compact grouped list or disclosure and not as a flat wall of badges or alerts. +2. **Given** the output includes PII or internal-only context, **When** the surface renders, **Then** the operator sees that warning before any customer-safe share wording. + +--- + +### User Story 3 - Distinguish review status from output readiness on detail (Priority: P2) + +As an operator opening a released review, I need the detail page to separate review publication/completeness from output-readiness/sharing state so I do not mistake "published" for "customer-safe". + +**Why this priority**: This is the main semantic trap left after Spec 347. + +**Independent Test**: Can be tested by opening a published-but-limited review and asserting distinct labels/rows for review status, output readiness, and sharing/publication state. + +**Acceptance Scenarios**: + +1. **Given** a review is published but has output blockers, **When** the detail page renders, **Then** publication status remains visible but separate from output-readiness and sharing-state messaging. +2. **Given** the detail page is opened from Customer Review Workspace context, **When** the page renders, **Then** it preserves existing scoped access and handoff behavior while showing the clearer output-guidance separation. + +## Functional Requirements + +- **FR-349-001**: The product MUST derive output resolution guidance from existing review/output-readiness truth; no new persistence is allowed. +- **FR-349-002**: Customer Review Workspace MUST show one dominant output-guidance state and one primary next action. +- **FR-349-003**: Multiple limitations MUST be grouped into a compact list or disclosure instead of many equal-weight warnings. +- **FR-349-004**: The primary blocker MUST map to one plain-language reason and one supporting impact statement. +- **FR-349-005**: Qualified download wording MUST reflect whether the package is customer-safe, internal-only, limited, or not ready. +- **FR-349-006**: PII/internal-only state MUST be visible before any customer-safe/share wording is shown. +- **FR-349-007**: Technical details, section counts, evidence state, and related diagnostics MUST remain available but secondary. +- **FR-349-008**: Environment Review detail MUST distinguish review status, output readiness, and publication/sharing state. +- **FR-349-009**: Existing authorization, signed-download safety, acknowledgement behavior, and scope semantics MUST remain intact. +- **FR-349-010**: The workspace must remain workspace-scoped with visible `environment_id` filtering and no hidden topbar context behavior. + +## Non-Functional Requirements + +- **NFR-349-001**: The default UI must reduce attention load by showing one dominant output state and one dominant next action. +- **NFR-349-002**: Wording must remain operator-first, customer-safe, and conservative; when uncertain, the UI must prefer "requires review" over "customer-safe". +- **NFR-349-003**: The guidance layer must not add unbounded queries and should reuse already-loaded summary metadata where practical. +- **NFR-349-004**: Status, warning, and disclosure cues must remain accessible via text, keyboard-reachable controls, and non-color-only signaling. +- **NFR-349-005**: Any new display state must remain derived-only and must not become a persisted domain state or workflow state machine. + +## Explicit Non-Goals + +- No new table, persisted record, queue family, or workflow engine +- No Customer Portal or customer-facing route family +- No Review Pack PDF or HTML renderer +- No Review Pack generation rewrite +- No change to EvidenceSnapshot generation or OperationRun lifecycle semantics +- No broad Customer Review Workspace redesign beyond output guidance +- No Governance Inbox scope expansion +- No broad localization pass beyond touched output-guidance copy +- No billing, entitlement, PSA, or provider-onboarding work + +## Success Criteria + +- Customer Review Workspace shows one clear output-guidance state +- Highest-priority blocker becomes one clear next action +- Limitations are grouped and honest +- Download labels do not overpromise customer-safe sharing +- Review detail clearly separates review status from output readiness +- Technical details remain available without dominating the screen +- No new workflow engine or persisted resolution truth is introduced + +## Risks + +- **Guidance drift**: workspace and detail could interpret limitation codes differently if the mapping is not centralized. +- **Scope creep**: detail-surface hardening could spill into broader review resource redesign if not kept bounded. +- **False reassurance**: customer-safe wording could still overpromise if findings or accepted-risk follow-up are not folded into the effective state honestly. +- **Warning-wall regression**: a naive implementation could surface every limitation at equal weight and defeat the operator-guidance goal. + +## Assumptions + +- Spec 347 readiness truth remains the raw source of limitation codes and section/evidence signals. +- Existing scoped routes, signed-download behavior, and review/evidence helpers remain authoritative. +- `ui-006-customer-review-workspace.md` remains the durable page-report identity for this workspace surface unless repo truth later proves otherwise. + +## Open Questions + +- None blocking prep. Final label phrasing and whether the guidance adapter extends `ReviewPackOutputReadiness` directly or wraps it should be decided during implementation based on the narrowest code change. diff --git a/specs/349-customer-review-workspace-output-resolution-guidance/tasks.md b/specs/349-customer-review-workspace-output-resolution-guidance/tasks.md new file mode 100644 index 00000000..3f269d13 --- /dev/null +++ b/specs/349-customer-review-workspace-output-resolution-guidance/tasks.md @@ -0,0 +1,119 @@ +# Tasks: Spec 349 - Customer Review Workspace Output Resolution Guidance + +**Input**: `specs/349-customer-review-workspace-output-resolution-guidance/spec.md`, `plan.md`, `repo-truth-map.md`, and `checklists/requirements.md` + +**Tests**: Required. This is a runtime guidance and trust-surface change on existing review-pack, workspace, and review-detail paths. + +## Test Governance Checklist + +- [x] Lane assignment is explicit and narrow: Feature for mapping and surface behavior, Browser for first-screen trust proof. +- [x] New or changed tests stay in the smallest honest family, and the browser addition is explicit. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] The declared surface profiles (`global-context-shell` and `shared-detail-family`) are explicit. +- [x] Any derived-state expansion remains presentation-only and does not create a hidden new domain state family. + +## Phase 1: Preparation And Repo Truth + +**Purpose**: Keep the implementation bounded to existing readiness truth and current workspace/detail surfaces. + +- [x] T001 Re-read `spec.md`, `plan.md`, `repo-truth-map.md`, and `checklists/requirements.md` before runtime changes. +- [x] T002 Re-read related historical context only: Specs 258, 308, 311, 326, 342, 343, 344, and 347. Do not modify their artifacts. +- [x] T003 Re-verify current runtime truth in: + - `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php` + - `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` + - `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` + - `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` + - `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php` +- [x] T004 Keep `specs/349-customer-review-workspace-output-resolution-guidance/repo-truth-map.md` updated if implementation-time inspection reveals a narrower or broader bounded truth. +- [x] T005 Confirm no migration, package, env var, queue family, scheduler change, storage-topology change, panel/provider change, or global-search change is required. +- [x] T006 Confirm Filament v5 / Livewire v4.0+ compliance and avoid legacy Filament or Livewire APIs. +- [x] T007 Confirm panel provider registration remains `apps/platform/bootstrap/providers.php`. + +## Phase 2: Tests First + +**Purpose**: Lock the operator-guidance behavior before runtime refactor. + +- [x] T008 Add `apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`. +- [x] T009 Add `apps/platform/tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php`. +- [x] T010 Add `apps/platform/tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php`. +- [x] T011 Add `apps/platform/tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php`. +- [x] T012 Add assertions that the workspace shows exactly one dominant output state and exactly one primary next action. +- [x] T013 Add assertions that grouped limitations appear compactly and technical details stay collapsed/secondary by default. +- [x] T014 Add assertions that PII/internal-only output shows an explicit warning before customer-safe wording. +- [x] T015 Add assertions that download labels are qualified honestly for customer-safe, internal-only, limited, and not-ready states. +- [x] T016 Add assertions that Environment Review detail separates review status, output readiness, and publication/sharing state. +- [x] T017 Reuse or extend current regressions such as: + - `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php` + - `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php` + - `apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php` + - `apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php` + - `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` + - `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` + - `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewUiContractTest.php` + +## Phase 3: Guidance Mapping + +**Purpose**: Build one bounded derived mapping from readiness truth to operator guidance. + +- [x] T018 Choose the narrowest implementation home: + - extend `apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php`, or + - add `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php` +- [x] T019 Derive display state, label, severity, primary reason, impact, primary action, grouped limitations, secondary actions, and technical details from existing readiness truth only. +- [x] T020 Keep any added states such as `publication_blocked` or `internal_only` presentation-only; do not create new persisted enums or workflow states. +- [x] T021 Map existing limitation codes to plain-language operator guidance and repo-real destinations using current scoped route helpers. +- [x] T022 Keep the mapping shared enough to prevent workspace/detail wording drift, but bounded enough to avoid a generic workflow-resolution framework. + +## Phase 4: Customer Review Workspace Update + +**Purpose**: Turn the current readiness truth into one calm first-screen decision. + +- [x] T023 Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` to consume the bounded guidance mapping instead of scattered reason/action logic. +- [x] T024 Update `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` so the decision card shows one output state, one reason, one impact statement, and one primary action. +- [x] T025 Add a compact grouped limitations disclosure with supporting actions and collapsed technical details. +- [x] T026 Qualify review-pack download labels and surrounding copy without weakening existing signed-download safety or authorization. +- [x] T027 Preserve current acknowledgement, findings, accepted-risk, and proof sections unless a minimal hierarchy adjustment is required to support the one-blocker rule. +- [x] T028 Preserve the visible `environment_id` workspace filter contract and avoid reintroducing hidden topbar or shell-context behavior. +- [x] T029 Ensure grouped limitation disclosure, details toggles, and status text remain text-backed, keyboard-reachable, and not color-only. + +## Phase 5: Environment Review Detail Update + +**Purpose**: Separate review publication truth from output-readiness truth on the detail surface. + +- [x] T030 Update `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` infolist presentation if needed so the detail summary exposes distinct status dimensions cleanly. +- [x] T031 Update `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php` or the supporting infolist state so the detail surface shows review status, output readiness, and publication/sharing state separately. +- [x] T032 Keep fingerprint, raw proof metadata, and support-only detail secondary or hidden in customer-workspace mode. +- [x] T033 Preserve existing customer-workspace-mode access, header-action narrowing, and lifecycle-action behavior outside customer-workspace mode. + +## Phase 6: Copy, Audit, And Browser Proof + +**Purpose**: Align wording and audit artifacts with the bounded guidance model. + +- [x] T034 Update only the required output-guidance localization keys in: + - `apps/platform/lang/en/localization.php` + - `apps/platform/lang/de/localization.php` +- [x] T035 Update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md` with the one-primary-action rule, grouped limitation disclosure, and repo-truth note about the user-draft `ui-009` conflict. +- [x] T036 If implementation proves a second durable page report is required, create it under the next repo-real identity instead of reusing `ui-009`. +- [x] T037 Capture browser screenshots under `specs/349-customer-review-workspace-output-resolution-guidance/artifacts/screenshots/`. + +## Phase 7: Validation + +**Purpose**: Prove the guidance mapping and preserve existing safety. + +- [x] T038 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php tests/Feature/Filament/Spec349CustomerReviewWorkspaceOutputGuidanceTest.php tests/Feature/EnvironmentReview/Spec349EnvironmentReviewOutputGuidanceTest.php --compact`. +- [x] T039 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec349OutputResolutionGuidanceSmokeTest.php --compact`. +- [x] T040 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec347`. +- [x] T041 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace`. +- [x] T042 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ReviewPack`. +- [x] T043 Run `cd apps/platform && ./vendor/bin/sail pint --dirty`. +- [x] T044 Run `git diff --check`. +- [x] T045 Report any unrelated broader-suite failures honestly if they remain out of scope. + +## Non-Goals Checklist + +- [x] NT001 Do not create a new persisted resolution entity, table, or status family. +- [x] NT002 Do not add a workflow engine, approval engine, or queue family. +- [x] NT003 Do not build a Customer Portal or Review Pack PDF/HTML renderer. +- [x] NT004 Do not rewrite Review Pack generation or reopen Spec 347 contract truth broadly. +- [x] NT005 Do not redesign Governance Inbox or broadly redesign Customer Review Workspace outside the output-guidance slice. +- [x] NT006 Do not weaken existing workspace/environment scope, policies, or signed-download safety.