diff --git a/apps/platform/app/Filament/Pages/ChooseEnvironment.php b/apps/platform/app/Filament/Pages/ChooseEnvironment.php index 39cc1f33..99865735 100644 --- a/apps/platform/app/Filament/Pages/ChooseEnvironment.php +++ b/apps/platform/app/Filament/Pages/ChooseEnvironment.php @@ -17,6 +17,7 @@ use Filament\Pages\Page; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class ChooseEnvironment extends Page { @@ -30,6 +31,8 @@ class ChooseEnvironment extends Page protected string $view = 'filament.pages.choose-environment'; + public string $search = ''; + public function getTitle(): string { return __('localization.shell.choose_environment'); @@ -66,6 +69,35 @@ public function getTenants(): Collection return app(TenantOperabilityService::class)->filterSelectable(collect($tenants)); } + /** + * @return Collection + */ + public function getVisibleTenants(?Collection $tenants = null): Collection + { + $tenants ??= $this->getTenants(); + $search = Str::of($this->search)->trim()->lower()->toString(); + + if ($search === '') { + return $tenants; + } + + return $tenants + ->filter(function (ManagedEnvironment $tenant) use ($search): bool { + $presentation = $this->tenantLifecyclePresentation($tenant); + + return collect([ + $tenant->name, + $tenant->domain, + $tenant->environment, + $presentation->label, + $presentation->shortDescription, + ]) + ->filter(fn (mixed $value): bool => is_string($value) && $value !== '') + ->contains(fn (string $value): bool => Str::contains(Str::lower($value), $search)); + }) + ->values(); + } + public function selectEnvironment(int $tenantId): void { $user = auth()->user(); diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 51dd7fdd..1172cc06 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -33,6 +33,7 @@ use App\Support\Navigation\WorkspaceHubNavigation; use App\Support\OperationRunLinks; use App\Support\ReviewPackStatus; +use App\Support\ReviewPacks\ReviewPackOutputReadiness; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -370,14 +371,22 @@ public function latestReviewConsumptionPayload(): ?array $packageAvailability = $this->governancePackageAvailability($tenant); $downloadUrl = $this->reviewPackDownloadUrl($review, $tenant); $reviewUrl = $this->latestReviewUrl($tenant); + $evidenceUrl = $this->evidenceSnapshotUrlForReview($review, $tenant); + $outputReadiness = $this->reviewPackOutputReadinessForReview($review); $decision = $this->decisionSummaryForReview($review); $acceptedRisks = $this->acceptedRisksForReview($review); $hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); $findingPanel = $this->findingPanelForReview($tenant); - $canShowSecondaryReviewLink = $downloadUrl !== null - && ! $hasAcceptedRiskFollowUp - && $findingPanel['open_count'] === 0; $evidencePath = $this->evidencePathForReview($review, $tenant, $packageAvailability, $downloadUrl, $decision, $acceptedRisks); + $readiness = $this->reviewReadinessForTenant( + tenant: $tenant, + review: $review, + packageAvailability: $packageAvailability, + outputReadiness: $outputReadiness, + downloadUrl: $downloadUrl, + reviewUrl: $reviewUrl, + evidenceUrl: $evidenceUrl, + ); return [ 'scope' => $this->reviewScopePayload($tenant), @@ -402,25 +411,21 @@ public function latestReviewConsumptionPayload(): ?array 'primary_action_icon' => $downloadUrl !== null ? 'heroicon-o-arrow-down-tray' : 'heroicon-o-arrow-top-right-on-square', - 'secondary_action_label' => $canShowSecondaryReviewLink - ? __('localization.review.open_review') - : null, - 'secondary_action_url' => $canShowSecondaryReviewLink - ? $reviewUrl - : null, + 'secondary_action_label' => $readiness['secondary_action_label'], + 'secondary_action_url' => $readiness['secondary_action_url'], 'secondary_action_icon' => 'heroicon-o-arrow-top-right-on-square', ], - 'readiness' => $this->reviewReadinessForTenant($tenant, $review, $packageAvailability, $downloadUrl, $reviewUrl), - 'readiness_flow' => $this->reviewConsumptionFlowForReview($tenant, $review, $packageAvailability, $downloadUrl), + 'readiness' => $readiness, + 'readiness_flow' => $this->reviewConsumptionFlowForReview($tenant, $review, $packageAvailability, $downloadUrl, $outputReadiness), 'finding_panel' => $findingPanel, 'acknowledgement' => $this->reviewAcknowledgementPayloadForReview($tenant, $review, $packageAvailability, $downloadUrl), 'decision' => $decision, 'accepted_risks' => $acceptedRisks, 'accepted_risk_panel' => $this->acceptedRiskPanelForReview($review, $tenant), - 'evidence_basis' => $this->evidenceBasisForReview($review, $packageAvailability), + 'evidence_basis' => $this->evidenceBasisForReview($review, $packageAvailability, $outputReadiness), 'evidence_path' => $evidencePath, 'aside_evidence_path' => $this->asideEvidencePath($evidencePath), - 'review_pack_panel' => $this->reviewPackPanelForReview($review, $tenant, $packageAvailability, $downloadUrl), + 'review_pack_panel' => $this->reviewPackPanelForReview($review, $tenant, $packageAvailability, $downloadUrl, $outputReadiness), 'follow_ups' => $this->customerSafeFollowUpsForReview($decision), 'diagnostics' => $this->diagnosticsDisclosureForReview(), 'disclosure_rules' => $this->disclosureRuleRows(), @@ -616,74 +621,77 @@ private function reviewScopePayload(ManagedEnvironment $tenant): array /** * @param array{state:string,label:string,description:string} $packageAvailability - * @return array{question:string,label:string,color:string,reason:string,impact:string,primary_action_label:string,primary_action_url:?string,primary_action_icon:string} + * @param array $outputReadiness + * @return array{ + * question:string, + * label:string, + * color:string, + * boundary_label:string, + * boundary_color:string, + * reason:string, + * impact:string, + * primary_action_label:string, + * primary_action_url:?string, + * primary_action_icon:string, + * secondary_action_label:?string, + * secondary_action_url:?string + * } */ private function reviewReadinessForTenant( ManagedEnvironment $tenant, EnvironmentReview $review, array $packageAvailability, + array $outputReadiness, ?string $downloadUrl, ?string $reviewUrl, + ?string $evidenceUrl, ): array { $hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); $findingPanel = $this->findingPanelForReview($tenant); $hasFindingFollowUp = $findingPanel['open_count'] > 0; - $hasReadyPackage = $packageAvailability['state'] === 'available' && $downloadUrl !== null; - $hasAvailableEvidence = $this->evidenceStatusState($tenant) === 'available'; - $hasMappedReviewData = $this->primaryControlSummary($tenant) !== null; - $hasCustomerSafeProof = $this->primaryControlSummary($tenant) !== null - && $hasAvailableEvidence; - $isReadyToShare = ! $hasAcceptedRiskFollowUp - && $findingPanel['open_count'] === 0 - && $hasReadyPackage - && $hasAvailableEvidence - && $hasMappedReviewData; - $isShareableWithFollowUp = $hasAcceptedRiskFollowUp && ! $hasFindingFollowUp && $hasReadyPackage && $hasCustomerSafeProof; - $primaryActionShouldOpenReview = $hasFindingFollowUp || $isShareableWithFollowUp; + $effectiveState = $this->effectiveWorkspaceReadinessState( + $outputReadiness, + $hasFindingFollowUp, + $hasAcceptedRiskFollowUp, + ); + $reasonCode = $hasFindingFollowUp + ? 'findings_follow_up_required' + : ($hasAcceptedRiskFollowUp ? 'accepted_risk_follow_up_required' : (string) ($outputReadiness['primary_reason'] ?? 'customer_safe_ready')); + $actions = $this->workspaceReadinessActions( + state: $effectiveState, + reasonCode: $reasonCode, + downloadUrl: $downloadUrl, + reviewUrl: $reviewUrl, + evidenceUrl: $evidenceUrl, + ); return [ - 'question' => __('localization.review.is_review_ready_to_share'), - 'label' => match (true) { - $isReadyToShare => __('localization.review.ready_to_share'), - $isShareableWithFollowUp => __('localization.review.shareable_with_follow_up'), - default => __('localization.review.follow_up_required_before_sharing'), - }, - 'color' => match (true) { - $isReadyToShare => 'success', - $isShareableWithFollowUp => 'warning', - default => $this->latestReviewStateColor($tenant), - }, - 'reason' => match (true) { - $isReadyToShare => __('localization.review.ready_to_share_reason'), - $isShareableWithFollowUp => __('localization.review.accepted_risk_follow_up_required_reason'), - $hasFindingFollowUp => __('localization.review.findings_follow_up_required_reason', [ - 'summary' => $findingPanel['summary'], - ]), - default => $this->customerSafeText( - $this->reviewOutcomeDescription($tenant) ?? $packageAvailability['description'], - __('localization.review.follow_up_required_before_sharing_reason'), - ), - }, - 'impact' => match (true) { - $isReadyToShare => __('localization.review.ready_to_share_impact'), - $isShareableWithFollowUp => __('localization.review.accepted_risk_follow_up_required_impact'), - $hasFindingFollowUp => __('localization.review.findings_follow_up_required_impact'), - default => __('localization.review.follow_up_required_before_sharing_impact'), - }, - 'primary_action_label' => $primaryActionShouldOpenReview - ? __('localization.review.open_review') - : ($downloadUrl !== null ? __('localization.review.download_review_pack') : __('localization.review.open_latest_review')), - 'primary_action_url' => $primaryActionShouldOpenReview - ? ($reviewUrl ?? $downloadUrl) - : ($downloadUrl ?? $reviewUrl), - 'primary_action_icon' => $primaryActionShouldOpenReview || $downloadUrl === null - ? 'heroicon-o-arrow-top-right-on-square' - : 'heroicon-o-arrow-down-tray', + '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'], ]; } /** * @param array{state:string,label:string,description:string} $packageAvailability + * @param array $outputReadiness * @return list */ private function reviewConsumptionFlowForReview( @@ -691,6 +699,7 @@ private function reviewConsumptionFlowForReview( EnvironmentReview $review, array $packageAvailability, ?string $downloadUrl, + array $outputReadiness, ): array { $evidenceState = $this->evidenceStatusState($tenant); $findingPanel = $this->findingPanelForReview($tenant); @@ -698,18 +707,26 @@ private function reviewConsumptionFlowForReview( $hasAcceptedRiskFollowUp = $this->acceptedRiskFollowUpRequiredForReview($review); $hasReadyPackage = $packageAvailability['state'] === 'available' && $downloadUrl !== null; $hasMappedReviewData = $this->primaryControlSummary($tenant) !== null; + $workspaceState = $this->effectiveWorkspaceReadinessState( + $outputReadiness, + $findingPanel['open_count'] > 0, + $hasAcceptedRiskFollowUp, + ); $hasBlockingAttention = $findingPanel['open_count'] > 0 || $hasAcceptedRiskFollowUp || $evidenceState !== 'available' - || ! $hasMappedReviewData; + || ! $hasMappedReviewData + || $workspaceState !== ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY; $customerOutputLabel = match (true) { $hasReadyPackage && ! $hasBlockingAttention => __('localization.review.ready'), + $workspaceState === ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY || ! $hasReadyPackage => __('localization.review.not_ready'), $hasReadyPackage => __('localization.review.needs_review'), default => __('localization.review.not_ready'), }; $customerOutputColor = match (true) { $hasReadyPackage && ! $hasBlockingAttention => 'success', + $workspaceState === ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY || ! $hasReadyPackage => 'gray', $hasReadyPackage => 'warning', default => 'gray', }; @@ -783,7 +800,6 @@ private function reviewPackDimensionDescription(array $packageAvailability): str return match ($packageAvailability['state']) { 'available' => __('localization.review.review_pack_dimension_available_description'), 'not_available' => __('localization.review.review_pack_dimension_not_generated_description'), - 'evidence_incomplete' => __('localization.review.review_pack_dimension_needs_refresh_description'), 'preparing' => __('localization.review.review_pack_dimension_preparing_description'), 'expired' => __('localization.review.review_pack_dimension_expired_description'), default => __('localization.review.review_pack_dimension_unavailable_description'), @@ -945,10 +961,7 @@ private function evidenceSnapshotProofForReview(EnvironmentReview $review, Manag { $snapshot = $review->evidenceSnapshot; $state = $this->evidenceStatusState($tenant); - $user = auth()->user(); - $url = $snapshot instanceof EvidenceSnapshot && $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant) - ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin') - : null; + $url = $this->evidenceSnapshotUrlForReview($review, $tenant); return [ 'key' => 'evidence_snapshot', @@ -977,7 +990,7 @@ private function reviewPackProofForReview(array $packageAvailability, ?string $d 'label' => $packageAvailability['label'], 'color' => match ($packageAvailability['state']) { 'available' => 'success', - 'evidence_incomplete', 'preparing' => 'warning', + 'preparing' => 'warning', 'expired', 'unavailable' => 'danger', default => 'gray', }, @@ -1324,33 +1337,97 @@ private function acceptedRiskPanelForReview(EnvironmentReview $review, ManagedEn /** * @param array{state:string,label:string,description:string} $packageAvailability - * @return array{status_label:string,status_color:string,description:string,last_generated_label:string,evidence_snapshot_label:string,export_label:string,operation_label:string,download_url:?string} + * @param array $outputReadiness + * @return array{ + * status_label:string, + * status_color:string, + * description:string, + * detail_rows:list, + * download_url:?string + * } */ private function reviewPackPanelForReview( EnvironmentReview $review, ManagedEnvironment $tenant, array $packageAvailability, ?string $downloadUrl, + array $outputReadiness, ): array { $pack = $review->currentExportReviewPack; $snapshot = $review->evidenceSnapshot; + $evidenceBasis = $this->evidenceBasisForReview($review, $packageAvailability, $outputReadiness); + $sectionSummary = is_array($outputReadiness['section_summary'] ?? null) ? $outputReadiness['section_summary'] : []; return [ 'status_label' => $packageAvailability['label'], 'status_color' => $this->governancePackageAvailabilityColor($tenant), - 'description' => $packageAvailability['description'], - 'last_generated_label' => $pack instanceof ReviewPack && $pack->generated_at !== null - ? $pack->generated_at->format('M j, Y H:i') - : __('localization.review.unavailable'), - 'evidence_snapshot_label' => $snapshot instanceof EvidenceSnapshot && $snapshot->generated_at !== null - ? $snapshot->generated_at->format('M j, Y H:i') - : __('localization.review.unavailable'), - 'export_label' => $downloadUrl !== null - ? __('localization.review.export_ready') - : __('localization.review.export_not_ready'), - 'operation_label' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun - ? OperationRunLinks::identifier($pack->operationRun) - : __('localization.review.operation_proof_unavailable'), + 'description' => $this->reviewPackPanelDescription($packageAvailability, $outputReadiness), + 'detail_rows' => [ + [ + 'label' => __('localization.review.last_generated'), + 'value' => $pack instanceof ReviewPack && $pack->generated_at !== null + ? $pack->generated_at->format('M j, Y H:i') + : __('localization.review.unavailable'), + 'color' => 'gray', + ], + [ + 'label' => __('localization.review.evidence_source'), + 'value' => $snapshot instanceof EvidenceSnapshot && $snapshot->generated_at !== null + ? $snapshot->generated_at->format('M j, Y H:i') + : __('localization.review.unavailable'), + 'color' => 'gray', + ], + [ + 'label' => __('localization.review.export_availability'), + 'value' => $downloadUrl !== null + ? __('localization.review.export_ready') + : __('localization.review.export_not_ready'), + 'color' => $downloadUrl !== null ? 'success' : 'gray', + ], + [ + 'label' => __('localization.review.evidence_basis_state'), + 'value' => $evidenceBasis['label'], + 'color' => $evidenceBasis['color'], + ], + [ + 'label' => __('localization.review.section_completeness'), + 'value' => $this->sectionCompletenessLabel($sectionSummary), + 'color' => ((int) ($sectionSummary['required_limited'] ?? 0)) > 0 ? 'warning' : 'success', + ], + [ + 'label' => __('localization.review.sharing_boundary'), + 'value' => $this->workspaceBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')), + 'color' => $this->workspaceBoundaryColor((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')), + ], + [ + 'label' => __('localization.review.pii_state'), + 'value' => (bool) ($outputReadiness['contains_pii'] ?? false) + ? __('localization.review.contains_pii') + : __('localization.review.pii_excluded'), + 'color' => (bool) ($outputReadiness['contains_pii'] ?? false) ? 'warning' : 'success', + ], + [ + 'label' => __('localization.review.protected_values'), + 'value' => (bool) ($outputReadiness['protected_values_hidden'] ?? true) + ? __('localization.review.protected_values_hidden') + : __('localization.review.unavailable'), + 'color' => (bool) ($outputReadiness['protected_values_hidden'] ?? true) ? 'success' : 'warning', + ], + [ + 'label' => __('localization.review.disclosure'), + 'value' => (bool) ($outputReadiness['disclosure_present'] ?? false) + ? __('localization.review.disclosure_present') + : __('localization.review.unavailable'), + 'color' => (bool) ($outputReadiness['disclosure_present'] ?? false) ? 'success' : 'warning', + ], + [ + 'label' => __('localization.review.operation_proof'), + 'value' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun + ? OperationRunLinks::identifier($pack->operationRun) + : __('localization.review.operation_proof_unavailable'), + 'color' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun ? 'info' : 'gray', + ], + ], 'download_url' => $downloadUrl, ]; } @@ -1755,9 +1832,11 @@ private function latestReviewStateLabel(ManagedEnvironment $tenant): string return __('localization.review.no_published_review'); } - return $this->workspaceReviewNeedsAttention($tenant) - ? __('localization.review.review_needed') - : __('localization.review.ready_to_share'); + return match ($this->workspaceCustomerOutputState($tenant)) { + 'ready' => __('localization.review.ready'), + 'not_ready' => __('localization.review.not_ready'), + default => __('localization.review.needs_review'), + }; } private function latestReviewStateColor(ManagedEnvironment $tenant): string @@ -1768,15 +1847,13 @@ private function latestReviewStateColor(ManagedEnvironment $tenant): string return 'gray'; } - $packageState = $this->governancePackageAvailability($tenant)['state']; - - if (! $this->workspaceReviewNeedsAttention($tenant)) { - return 'success'; - } - - return in_array($packageState, ['expired', 'unavailable'], true) - ? 'danger' - : 'warning'; + return match ($this->workspaceCustomerOutputState($tenant)) { + 'ready' => 'success', + 'not_ready' => in_array($this->governancePackageAvailability($tenant)['state'], ['expired', 'unavailable'], true) + ? 'danger' + : 'gray', + default => 'warning', + }; } private function latestReviewStateIcon(ManagedEnvironment $tenant): ?string @@ -1871,16 +1948,6 @@ private function governancePackageAvailability(ManagedEnvironment $tenant): arra $pack = $review->currentExportReviewPack; $user = auth()->user(); - $decisionSummary = data_get($this->governancePackageSummaryForReview($review), 'decision_summary'); - $isPartialReview = is_array($decisionSummary) && ( - in_array((string) ($decisionSummary['status'] ?? ''), ['unavailable', 'incomplete'], true) - || (string) ($decisionSummary['decision_data_state'] ?? '') === 'incomplete' - || in_array((string) ($decisionSummary['evidence_state'] ?? ''), [ - EnvironmentReviewCompletenessState::Partial->value, - EnvironmentReviewCompletenessState::Stale->value, - EnvironmentReviewCompletenessState::Missing->value, - ], true) - ); if (! $pack instanceof ReviewPack) { return [ @@ -1930,14 +1997,6 @@ private function governancePackageAvailability(ManagedEnvironment $tenant): arra ]; } - if ($isPartialReview) { - return [ - 'state' => 'evidence_incomplete', - 'label' => __('localization.review.review_pack_evidence_incomplete'), - 'description' => __('localization.review.review_pack_evidence_incomplete_description'), - ]; - } - return [ 'state' => 'available', 'label' => __('localization.review.available'), @@ -1949,7 +2008,6 @@ private function governancePackageAvailabilityLabel(ManagedEnvironment $tenant): { return match ($this->governancePackageAvailability($tenant)['state']) { 'available' => __('localization.review.available'), - 'evidence_incomplete' => __('localization.review.review_pack_evidence_incomplete'), 'not_available' => __('localization.review.review_pack_not_available_yet'), 'preparing' => __('localization.review.review_pack_preparing'), 'expired' => __('localization.review.expired'), @@ -1961,7 +2019,7 @@ private function governancePackageAvailabilityColor(ManagedEnvironment $tenant): { return match ($this->governancePackageAvailability($tenant)['state']) { 'available' => 'success', - 'evidence_incomplete', 'preparing' => 'warning', + 'preparing' => 'warning', 'expired', 'unavailable' => 'danger', default => 'gray', }; @@ -2109,18 +2167,22 @@ private function acceptedRiskStateLabel(?string $state): string /** * @param array{state:string,label:string,description:string} $packageAvailability + * @param array $outputReadiness * @return array */ - private function evidenceBasisForReview(EnvironmentReview $review, array $packageAvailability): array + private function evidenceBasisForReview(EnvironmentReview $review, array $packageAvailability, array $outputReadiness): array { $package = $this->governancePackageSummaryForReview($review); $decision = $this->decisionSummaryForReview($review); $pack = $review->currentExportReviewPack; + $evidenceState = (string) ($outputReadiness['evidence_completeness_state'] ?? ''); $state = match (true) { $package === [] => 'unavailable', ! $pack instanceof ReviewPack => 'not_generated', - $packageAvailability['state'] === 'evidence_incomplete' || $decision['status'] === 'incomplete' => 'incomplete', + $evidenceState === EnvironmentReviewCompletenessState::Missing->value => 'missing', + $evidenceState === EnvironmentReviewCompletenessState::Stale->value => 'stale', + $evidenceState === EnvironmentReviewCompletenessState::Partial->value || $decision['status'] === 'incomplete' => 'incomplete', $decision['status'] === 'unavailable' => 'unavailable', $decision['status'] === 'none' => 'no_awareness_required', default => 'complete', @@ -2139,6 +2201,8 @@ private function evidenceBasisLabel(string $state): string return match ($state) { 'complete' => __('localization.review.evidence_basis_complete'), 'no_awareness_required' => __('localization.review.evidence_basis_no_awareness_required'), + 'missing' => __('localization.review.evidence_basis_missing'), + 'stale' => __('localization.review.evidence_basis_stale'), 'incomplete' => __('localization.review.evidence_basis_incomplete'), 'not_generated' => __('localization.review.evidence_basis_not_generated'), default => __('localization.review.evidence_basis_unavailable'), @@ -2150,6 +2214,8 @@ private function evidenceBasisSummary(string $state): string return match ($state) { 'complete' => __('localization.review.evidence_basis_complete_description'), 'no_awareness_required' => __('localization.review.evidence_basis_no_awareness_required_description'), + 'missing' => __('localization.review.evidence_basis_missing_description'), + 'stale' => __('localization.review.evidence_basis_stale_description'), 'incomplete' => __('localization.review.evidence_basis_incomplete_description'), 'not_generated' => __('localization.review.evidence_basis_not_generated_description'), default => __('localization.review.evidence_basis_unavailable_description'), @@ -2160,11 +2226,344 @@ private function evidenceBasisColor(string $state): string { return match ($state) { 'complete', 'no_awareness_required' => 'success', - 'incomplete' => 'warning', + 'missing', 'stale', 'incomplete' => 'warning', default => 'gray', }; } + private function evidenceSnapshotUrlForReview(EnvironmentReview $review, ManagedEnvironment $tenant): ?string + { + $snapshot = $review->evidenceSnapshot; + $user = auth()->user(); + + if (! $snapshot instanceof EvidenceSnapshot || ! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) { + return null; + } + + return EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin'); + } + + /** + * @return array + */ + private function reviewPackOutputReadinessForReview(EnvironmentReview $review): array + { + $review->loadMissing(['sections', 'evidenceSnapshot', 'currentExportReviewPack']); + + $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 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), + ); + } + + private function reviewPackHasReadyExport(?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); + } + + 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 + */ + private function effectiveWorkspaceReadinessState(array $outputReadiness, bool $hasFindingFollowUp, bool $hasAcceptedRiskFollowUp): string + { + $state = (string) ($outputReadiness['readiness_state'] ?? ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY); + + if ($state === ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY && ($hasFindingFollowUp || $hasAcceptedRiskFollowUp)) { + return ReviewPackOutputReadiness::STATE_PUBLISHED_WITH_LIMITATIONS; + } + + return $state; + } + + private function workspaceReadinessLabel(string $state): string + { + return match ($state) { + ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready'), + ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => __('localization.review.internal_review_package_available'), + ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready'), + default => __('localization.review.published_with_limitations'), + }; + } + + private function workspaceReadinessColor(string $state): string + { + return match ($state) { + ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => 'success', + ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => 'gray', + default => 'warning', + }; + } + + private function workspaceBoundaryLabel(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 function workspaceBoundaryColor(string $state): string + { + return match ($state) { + 'customer_safe_ready' => 'success', + 'internal_only', 'requires_review' => 'warning', + default => 'gray', + }; + } + + /** + * @param array $outputReadiness + * @param array{state:string,label:string,description:string} $packageAvailability + * @param array{summary:string} $findingPanel + */ + private function workspaceReadinessReason( + string $reasonCode, + array $outputReadiness, + array $findingPanel, + array $packageAvailability, + ): string { + return match ($reasonCode) { + 'findings_follow_up_required' => __('localization.review.findings_follow_up_required_reason', [ + 'summary' => $findingPanel['summary'], + ]), + 'accepted_risk_follow_up_required' => __('localization.review.accepted_risk_follow_up_required_reason'), + 'export_not_ready' => __('localization.review.export_not_ready_reason'), + 'evidence_basis_missing' => __('localization.review.evidence_basis_missing_reason'), + 'evidence_basis_stale' => __('localization.review.evidence_basis_stale_reason'), + 'evidence_basis_incomplete' => __('localization.review.evidence_basis_incomplete_reason'), + 'required_sections_incomplete' => __('localization.review.required_sections_incomplete_reason', [ + 'complete' => (int) data_get($outputReadiness, 'section_summary.required_complete', 0), + 'total' => (int) data_get($outputReadiness, 'section_summary.required_total', 0), + 'limited' => (int) data_get($outputReadiness, 'section_summary.required_limited', 0), + ]), + 'publish_blockers_present' => __('localization.review.publish_blockers_present_reason'), + 'contains_pii' => __('localization.review.contains_pii_reason'), + 'customer_safe_ready' => __('localization.review.customer_safe_review_pack_ready_reason'), + default => $packageAvailability['description'], + }; + } + + private function workspaceReadinessImpact(string $state, string $reasonCode): string + { + return match ($reasonCode) { + 'findings_follow_up_required' => __('localization.review.findings_follow_up_required_impact'), + 'accepted_risk_follow_up_required' => __('localization.review.accepted_risk_follow_up_required_impact'), + default => match ($state) { + ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_safe_review_pack_ready_impact'), + ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => __('localization.review.internal_review_package_available_impact'), + ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.export_not_ready_impact'), + default => __('localization.review.published_with_limitations_impact'), + }, + }; + } + + /** + * @return array{ + * primary_label:string, + * primary_url:?string, + * primary_icon:string, + * secondary_label:?string, + * secondary_url:?string + * } + */ + private function workspaceReadinessActions( + string $state, + string $reasonCode, + ?string $downloadUrl, + ?string $reviewUrl, + ?string $evidenceUrl, + ): array { + if (in_array($reasonCode, ['findings_follow_up_required', 'accepted_risk_follow_up_required'], true)) { + return [ + 'primary_label' => __('localization.review.open_review'), + 'primary_url' => $reviewUrl ?? $evidenceUrl ?? $downloadUrl, + 'primary_icon' => 'heroicon-o-arrow-top-right-on-square', + 'secondary_label' => match ($state) { + ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => $downloadUrl !== null + ? __('localization.review.download_internal_review_pack') + : null, + default => $downloadUrl !== null + ? __('localization.review.download_review_pack_with_limitations') + : null, + }, + 'secondary_url' => $downloadUrl, + ]; + } + + return match ($state) { + ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => [ + 'primary_label' => $downloadUrl !== null + ? __('localization.review.download_customer_safe_review_pack') + : __('localization.review.open_latest_review'), + 'primary_url' => $downloadUrl ?? $reviewUrl, + 'primary_icon' => $downloadUrl !== null + ? 'heroicon-o-arrow-down-tray' + : 'heroicon-o-arrow-top-right-on-square', + 'secondary_label' => $downloadUrl !== null && $reviewUrl !== null + ? __('localization.review.open_review') + : null, + 'secondary_url' => $downloadUrl !== null ? $reviewUrl : null, + ], + ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => [ + 'primary_label' => __('localization.review.review_package_contents'), + 'primary_url' => $reviewUrl ?? $downloadUrl, + 'primary_icon' => 'heroicon-o-arrow-top-right-on-square', + 'secondary_label' => $downloadUrl !== null + ? __('localization.review.download_internal_review_pack') + : null, + 'secondary_url' => $downloadUrl, + ], + ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => [ + 'primary_label' => __('localization.review.open_evidence_basis'), + 'primary_url' => $evidenceUrl ?? $reviewUrl, + 'primary_icon' => 'heroicon-o-arrow-top-right-on-square', + 'secondary_label' => $reviewUrl !== null && $reviewUrl !== $evidenceUrl + ? __('localization.review.open_review') + : null, + 'secondary_url' => $reviewUrl !== $evidenceUrl ? $reviewUrl : null, + ], + default => [ + 'primary_label' => __('localization.review.review_output_limitations'), + 'primary_url' => $reviewUrl ?? $evidenceUrl ?? $downloadUrl, + 'primary_icon' => 'heroicon-o-arrow-top-right-on-square', + 'secondary_label' => $downloadUrl !== null + ? __('localization.review.download_review_pack_with_limitations') + : null, + 'secondary_url' => $downloadUrl, + ], + }; + } + + /** + * @param array{state:string,label:string,description:string} $packageAvailability + * @param array $outputReadiness + */ + private function reviewPackPanelDescription(array $packageAvailability, array $outputReadiness): string + { + if ($packageAvailability['state'] !== 'available') { + return $packageAvailability['description']; + } + + return match ((string) ($outputReadiness['readiness_state'] ?? ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY)) { + ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.review_pack_customer_safe_ready_description'), + ReviewPackOutputReadiness::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => __('localization.review.review_pack_internal_review_description'), + ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.review_pack_export_not_ready_description'), + default => __('localization.review.review_pack_with_limitations_description'), + }; + } + + /** + * @param array $sectionSummary + */ + private function sectionCompletenessLabel(array $sectionSummary): string + { + $requiredTotal = (int) ($sectionSummary['required_total'] ?? 0); + $requiredComplete = (int) ($sectionSummary['required_complete'] ?? 0); + $requiredLimited = (int) ($sectionSummary['required_limited'] ?? 0); + + if ($requiredTotal <= 0) { + return __('localization.review.unavailable'); + } + + if ($requiredLimited > 0) { + return __('localization.review.section_completeness_limited', [ + 'complete' => $requiredComplete, + 'total' => $requiredTotal, + 'limited' => $requiredLimited, + ]); + } + + return __('localization.review.section_completeness_complete', [ + 'complete' => $requiredComplete, + 'total' => $requiredTotal, + ]); + } + + private function workspaceCustomerOutputState(ManagedEnvironment $tenant): string + { + $review = $this->latestPublishedReview($tenant); + + if (! $review instanceof EnvironmentReview) { + return 'not_ready'; + } + + if ($this->primaryControlSummary($tenant) === null || $this->evidenceStatusState($tenant) !== 'available') { + return 'not_ready'; + } + + $effectiveState = $this->effectiveWorkspaceReadinessState( + $this->reviewPackOutputReadinessForReview($review), + $this->findingPanelForReview($tenant)['open_count'] > 0, + $this->acceptedRiskFollowUpRequiredForReview($review), + ); + + return match ($effectiveState) { + ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => 'ready', + ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => 'not_ready', + default => 'needs_review', + }; + } + private function customerSafeText(mixed $value, string $fallback, int $limit = 220): string { if (! is_string($value) || trim($value) === '') { @@ -2246,34 +2645,28 @@ private function controlRecommendedNextAction(ManagedEnvironment $tenant): strin return __('localization.review.workspace_next_step_evidence_review'); } - return match ($this->governancePackageAvailability($tenant)['state']) { - 'available' => __('localization.review.workspace_next_step_package_review'), - 'evidence_incomplete' => __('localization.review.workspace_next_step_evidence_review'), + $review = $this->latestPublishedReview($tenant); + + if (! $review instanceof EnvironmentReview) { + return __('localization.review.workspace_next_step_review_open'); + } + + $readinessState = $this->effectiveWorkspaceReadinessState( + $this->reviewPackOutputReadinessForReview($review), + $this->findingPanelForReview($tenant)['open_count'] > 0, + $this->acceptedRiskFollowUpRequiredForReview($review), + ); + + return match (true) { + $readinessState === ReviewPackOutputReadiness::STATE_EXPORT_NOT_READY => __('localization.review.workspace_next_step_evidence_review'), + $this->governancePackageAvailability($tenant)['state'] === 'available' => __('localization.review.workspace_next_step_package_review'), default => __('localization.review.workspace_next_step_review_open'), }; } private function workspaceReviewNeedsAttention(ManagedEnvironment $tenant): bool { - $review = $this->latestPublishedReview($tenant); - - if (! $review instanceof EnvironmentReview) { - return true; - } - - if ($this->primaryControlSummary($tenant) === null) { - return true; - } - - if ($this->evidenceStatusState($tenant) !== 'available') { - return true; - } - - if ($this->acceptedRiskFollowUpRequiredForReview($review)) { - return true; - } - - return $this->governancePackageAvailability($tenant)['state'] !== 'available'; + return $this->workspaceCustomerOutputState($tenant) !== 'ready'; } private function evidenceStatusState(ManagedEnvironment $tenant): string diff --git a/apps/platform/app/Jobs/GenerateReviewPackJob.php b/apps/platform/app/Jobs/GenerateReviewPackJob.php index 32560521..c543c371 100644 --- a/apps/platform/app/Jobs/GenerateReviewPackJob.php +++ b/apps/platform/app/Jobs/GenerateReviewPackJob.php @@ -17,8 +17,10 @@ use App\Support\OperationRunStatus; use App\Support\RedactionIntegrity; use App\Support\ReviewPackStatus; +use App\Support\ReviewPacks\ReviewPackOutputReadiness; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Throwable; @@ -226,12 +228,14 @@ private function executeReviewDerivedGeneration( $includePii = (bool) ($options['include_pii'] ?? true); $includeOperations = (bool) ($options['include_operations'] ?? true); $generatedAt = now(); + $sections = $this->reviewDerivedSections($review, $includeOperations); $fileMap = $this->buildReviewDerivedFileMap( reviewPack: $reviewPack, review: $review, tenant: $tenant, snapshot: $snapshot, + sections: $sections, includePii: $includePii, includeOperations: $includeOperations, generatedAt: $generatedAt, @@ -279,28 +283,15 @@ private function executeReviewDerivedGeneration( $fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options); $reviewSummary = is_array($review->summary) ? $review->summary : []; - $governancePackage = is_array($reviewSummary['governance_package'] ?? null) - ? $this->redactReportPayload($reviewSummary['governance_package'], $includePii) - : []; - $summary = [ - 'environment_review_id' => (int) $review->getKey(), - 'review_status' => (string) $review->status, - 'review_completeness_state' => (string) $review->completeness_state, - 'section_count' => $review->sections->count(), - 'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0), - 'report_count' => (int) ($reviewSummary['report_count'] ?? 0), - 'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0, - 'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [], - 'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [], - 'governance_package' => $governancePackage, - 'delivery_bundle' => $this->deliveryBundleSummary($review), - 'evidence_resolution' => [ - 'outcome' => 'resolved', - 'snapshot_id' => (int) $snapshot->getKey(), - 'snapshot_fingerprint' => (string) $snapshot->fingerprint, - 'completeness_state' => (string) $snapshot->completeness_state, - ], - ]; + $summary = $this->reviewDerivedSummaryPayload( + reviewPack: $reviewPack, + review: $review, + snapshot: $snapshot, + sections: $sections, + includePii: $includePii, + includeOperations: $includeOperations, + hasReadyExport: true, + ); $retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90); $reviewPack->update([ @@ -660,22 +651,36 @@ private function buildReviewDerivedFileMap( EnvironmentReview $review, ManagedEnvironment $tenant, EvidenceSnapshot $snapshot, + Collection $sections, bool $includePii, bool $includeOperations, \Carbon\CarbonInterface $generatedAt, ): array { $reviewSummary = is_array($review->summary) ? $review->summary : []; + $sectionFiles = $sections + ->map(fn (mixed $section): string => $this->reviewDerivedSectionFilename($section)) + ->values() + ->all(); + $summaryPayload = $this->reviewDerivedSummaryPayload( + reviewPack: $reviewPack, + review: $review, + snapshot: $snapshot, + sections: $sections, + includePii: $includePii, + includeOperations: $includeOperations, + hasReadyExport: true, + ); $deliveryMetadata = $this->deliveryBundleMetadata( reviewPack: $reviewPack, review: $review, snapshot: $snapshot, generatedAt: $generatedAt, + sectionFiles: $sectionFiles, + outputReadiness: is_array($summaryPayload['output_readiness'] ?? null) + ? $summaryPayload['output_readiness'] + : [], ); - $sections = $review->sections - ->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health') - ->values(); - $files = [ 'metadata.json' => json_encode([ 'version' => '1.0', @@ -700,22 +705,16 @@ private function buildReviewDerivedFileMap( 'include_pii' => $includePii, 'include_operations' => $includeOperations, ], + 'output_readiness' => data_get($summaryPayload, 'output_readiness', []), 'redaction_integrity' => [ 'protected_values_hidden' => true, 'note' => RedactionIntegrity::protectedValueNote(), ], ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), - 'summary.json' => json_encode($this->redactReportPayload(array_merge( - [ - 'environment_review_id' => (int) $review->getKey(), - 'review_status' => (string) $review->status, - 'review_completeness_state' => (string) $review->completeness_state, - ], - $reviewSummary, - [ - 'delivery_bundle' => $this->deliveryBundleSummary($review), - ], - ), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), + 'summary.json' => json_encode( + $this->redactReportPayload($summaryPayload, $includePii), + JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, + ), 'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array { $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; $renderPayload = is_array($section->render_payload) ? $section->render_payload : []; @@ -735,6 +734,9 @@ private function buildReviewDerivedFileMap( tenant: $tenant, snapshot: $snapshot, reviewSummary: $reviewSummary, + outputReadiness: is_array($summaryPayload['output_readiness'] ?? null) + ? $summaryPayload['output_readiness'] + : [], includePii: $includePii, generatedAt: $generatedAt, ), @@ -743,10 +745,13 @@ private function buildReviewDerivedFileMap( foreach ($sections as $section) { $renderPayload = is_array($section->render_payload) ? $section->render_payload : []; $summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : []; - $filename = sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key); + $filename = $this->reviewDerivedSectionFilename($section); $files[$filename] = json_encode([ + 'section_key' => (string) $section->section_key, 'title' => (string) $section->title, + 'sort_order' => (int) $section->sort_order, + 'required' => (bool) $section->required, 'completeness_state' => (string) $section->completeness_state, 'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii), 'render_payload' => $this->redactReportPayload($renderPayload, $includePii), @@ -759,12 +764,16 @@ private function buildReviewDerivedFileMap( /** * @return array */ - private function deliveryBundleSummary(EnvironmentReview $review): array + private function deliveryBundleSummary(EnvironmentReview $review, Collection $sections): array { return [ 'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT, 'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME, 'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'], + 'section_files' => $sections + ->map(fn (mixed $section): string => $this->reviewDerivedSectionFilename($section)) + ->values() + ->all(), 'interpretation_version' => $review->controlInterpretationVersion(), ]; } @@ -777,7 +786,35 @@ private function deliveryBundleMetadata( EnvironmentReview $review, EvidenceSnapshot $snapshot, \Carbon\CarbonInterface $generatedAt, + array $sectionFiles, + array $outputReadiness, ): array { + $appendix = [ + [ + 'file' => 'metadata.json', + 'role' => 'bundle_metadata', + 'description' => 'Structured delivery metadata and artifact role map.', + ], + [ + 'file' => 'summary.json', + 'role' => 'review_summary_appendix', + 'description' => 'Structured released-review summary truth.', + ], + [ + 'file' => 'sections.json', + 'role' => 'section_detail_appendix', + 'description' => 'Structured released-review section detail.', + ], + ]; + + foreach ($sectionFiles as $sectionFile) { + $appendix[] = [ + 'file' => $sectionFile, + 'role' => 'section_appendix_entry', + 'description' => 'Structured appendix entry for a released-review section.', + ]; + } + return [ 'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT, 'artifact_family' => 'review_pack', @@ -802,23 +839,9 @@ private function deliveryBundleMetadata( 'audience' => 'executive', 'format' => 'text/markdown', ], - 'appendix' => [ - [ - 'file' => 'metadata.json', - 'role' => 'bundle_metadata', - 'description' => 'Structured delivery metadata and artifact role map.', - ], - [ - 'file' => 'summary.json', - 'role' => 'review_summary_appendix', - 'description' => 'Structured released-review summary truth.', - ], - [ - 'file' => 'sections.json', - 'role' => 'section_detail_appendix', - 'description' => 'Structured released-review section detail.', - ], - ], + 'appendix' => $appendix, + 'section_file_semantics' => 'Section completeness_state describes source and evidence completeness. A section appendix file may still exist when the section state is missing.', + 'output_readiness' => $outputReadiness, ]; } @@ -830,6 +853,7 @@ private function buildExecutiveEntrypoint( ManagedEnvironment $tenant, EvidenceSnapshot $snapshot, array $reviewSummary, + array $outputReadiness, bool $includePii, \Carbon\CarbonInterface $generatedAt, ): string { @@ -851,6 +875,7 @@ private function buildExecutiveEntrypoint( ? $decisionSummary['entries'] : (is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : []); $nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : []; + $limitations = $this->executiveLimitationsLines($outputReadiness); $lines = [ '# Executive summary', @@ -871,6 +896,12 @@ private function buildExecutiveEntrypoint( sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state), ), '', + ...($limitations === [] ? [] : [ + '## Limitations', + '', + ...$limitations, + '', + ]), '## Key findings', '', ...$this->entryBullets($topFindings, 'No key findings are listed for this released review.'), @@ -929,6 +960,163 @@ private function decisionSummaryLines(array $decisionSummary, array $entries): a ]; } + private function reviewDerivedSections(EnvironmentReview $review, bool $includeOperations): Collection + { + return $review->sections + ->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health') + ->values(); + } + + private function reviewDerivedSectionFilename(mixed $section): string + { + return sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key); + } + + /** + * @return array + */ + private function reviewDerivedSummaryPayload( + ReviewPack $reviewPack, + EnvironmentReview $review, + EvidenceSnapshot $snapshot, + Collection $sections, + bool $includePii, + bool $includeOperations, + bool $hasReadyExport, + ): array { + $reviewSummary = is_array($review->summary) ? $review->summary : []; + $publishBlockers = is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : []; + $sectionStateCounts = $this->sectionStateCounts($sections); + $requiredSections = $sections->filter(static fn (mixed $section): bool => (bool) $section->required)->values(); + $requiredSectionStateCounts = $this->sectionStateCounts($requiredSections); + $outputReadiness = ReviewPackOutputReadiness::derive( + reviewStatus: (string) $review->status, + reviewCompletenessState: (string) $review->completeness_state, + evidenceCompletenessState: (string) $snapshot->completeness_state, + sectionStateCounts: $sectionStateCounts, + requiredSectionCount: $requiredSections->count(), + requiredSectionStateCounts: $requiredSectionStateCounts, + publishBlockers: $publishBlockers, + hasReadyExport: $hasReadyExport, + includePii: $includePii, + protectedValuesHidden: true, + disclosurePresent: $this->nonCertificationDisclosurePresent($reviewSummary), + ); + + $governancePackage = is_array($reviewSummary['governance_package'] ?? null) + ? $this->redactReportPayload($reviewSummary['governance_package'], $includePii) + : []; + + return array_merge($reviewSummary, [ + 'environment_review_id' => (int) $review->getKey(), + 'review_status' => (string) $review->status, + 'review_completeness_state' => (string) $review->completeness_state, + 'section_count' => $sections->count(), + 'section_state_counts' => $sectionStateCounts, + 'required_section_state_counts' => $requiredSectionStateCounts, + 'has_ready_export' => $hasReadyExport, + 'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0), + 'report_count' => (int) ($reviewSummary['report_count'] ?? 0), + 'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0, + 'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [], + 'publish_blockers' => $publishBlockers, + 'governance_package' => $governancePackage, + 'recommended_next_actions' => is_array($reviewSummary['recommended_next_actions'] ?? null) + ? $reviewSummary['recommended_next_actions'] + : [], + 'delivery_bundle' => $this->deliveryBundleSummary($review, $sections), + 'evidence_basis' => [ + 'snapshot_id' => (int) $snapshot->getKey(), + 'snapshot_fingerprint' => (string) $snapshot->fingerprint, + 'completeness_state' => (string) $snapshot->completeness_state, + 'generated_at' => $snapshot->generated_at?->toIso8601String(), + ], + 'evidence_resolution' => [ + 'outcome' => 'resolved', + 'snapshot_id' => (int) $snapshot->getKey(), + 'snapshot_fingerprint' => (string) $snapshot->fingerprint, + 'completeness_state' => (string) $snapshot->completeness_state, + ], + 'output_readiness' => $outputReadiness, + ]); + } + + /** + * @return array + */ + private 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 array $reviewSummary + */ + private function nonCertificationDisclosurePresent(array $reviewSummary): bool + { + $controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null) + ? $reviewSummary['control_interpretation'] + : []; + $disclosure = $this->plainText( + $controlInterpretation['non_certification_disclosure'] ?? null, + 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.', + ); + + return $disclosure !== ''; + } + + /** + * @param array $outputReadiness + * @return list + */ + private function executiveLimitationsLines(array $outputReadiness): array + { + $codes = collect($outputReadiness['limitations'] ?? []) + ->filter(static fn (mixed $limitation): bool => is_array($limitation) && is_string($limitation['code'] ?? null)) + ->pluck('code') + ->values() + ->all(); + + if ($codes === []) { + return []; + } + + $lines = []; + + if (in_array('evidence_basis_missing', $codes, true)) { + $lines[] = '- This review was published with a missing evidence basis.'; + } elseif (in_array('evidence_basis_stale', $codes, true)) { + $lines[] = '- This review was published with a stale evidence basis.'; + } elseif (in_array('evidence_basis_incomplete', $codes, true)) { + $lines[] = '- This review was published with an incomplete evidence basis.'; + } + + if (in_array('required_sections_incomplete', $codes, true)) { + $lines[] = '- Some required sections are included as structured appendices but are marked missing because their source evidence was incomplete at generation time.'; + } + + if (in_array('export_not_ready', $codes, true)) { + $lines[] = '- The package exists, but export readiness had not passed at generation time.'; + } + + if (in_array('contains_pii', $codes, true)) { + $lines[] = '- PII is included in this package. Review the contents before external sharing.'; + } + + if (in_array('publish_blockers_present', $codes, true)) { + $lines[] = '- Publish blockers remain recorded in the released review summary.'; + } + + if (in_array('disclosure_missing', $codes, true)) { + $lines[] = '- The non-certification disclosure was not fully available in the released review payload.'; + } + + return $lines; + } + /** * @param array $entries * @return list diff --git a/apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php b/apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php new file mode 100644 index 00000000..50b5a807 --- /dev/null +++ b/apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php @@ -0,0 +1,178 @@ + $sectionStateCounts + * @param array $requiredSectionStateCounts + * @param list $publishBlockers + * @return array{ + * review_status: string, + * review_completeness_state: string, + * evidence_completeness_state: string, + * has_ready_export: bool, + * section_state_counts: array, + * required_section_count: int, + * required_section_state_counts: array, + * required_section_limited_count: int, + * contains_pii: bool, + * protected_values_hidden: bool, + * disclosure_present: bool, + * customer_safe_state: string, + * readiness_state: string, + * primary_reason: string, + * primary_action: string, + * limitations: list, + * section_summary: array{ + * required_total: int, + * required_complete: int, + * required_limited: int, + * partial: int, + * missing: int, + * stale: int + * } + * } + */ + public static function derive( + string $reviewStatus, + string $reviewCompletenessState, + string $evidenceCompletenessState, + array $sectionStateCounts, + int $requiredSectionCount, + array $requiredSectionStateCounts, + array $publishBlockers, + bool $hasReadyExport, + bool $includePii, + bool $protectedValuesHidden = true, + bool $disclosurePresent = true, + ): array { + $sectionStateCounts = self::normalizeCounts($sectionStateCounts); + $requiredSectionStateCounts = self::normalizeCounts($requiredSectionStateCounts); + + $requiredLimitedCount = max( + 0, + (int) ($requiredSectionStateCounts[EnvironmentReviewCompletenessState::Partial->value] ?? 0) + + (int) ($requiredSectionStateCounts[EnvironmentReviewCompletenessState::Missing->value] ?? 0) + + (int) ($requiredSectionStateCounts[EnvironmentReviewCompletenessState::Stale->value] ?? 0) + ); + + $limitations = []; + + if (! $hasReadyExport) { + $limitations[] = ['code' => 'export_not_ready']; + } + + if ($evidenceCompletenessState !== EnvironmentReviewCompletenessState::Complete->value) { + $limitations[] = ['code' => match ($evidenceCompletenessState) { + EnvironmentReviewCompletenessState::Missing->value => 'evidence_basis_missing', + EnvironmentReviewCompletenessState::Stale->value => 'evidence_basis_stale', + default => 'evidence_basis_incomplete', + }]; + } + + if ($requiredLimitedCount > 0) { + $limitations[] = ['code' => 'required_sections_incomplete']; + } + + if ($publishBlockers !== []) { + $limitations[] = ['code' => 'publish_blockers_present']; + } + + if ($includePii) { + $limitations[] = ['code' => 'contains_pii']; + } + + if (! $disclosurePresent) { + $limitations[] = ['code' => 'disclosure_missing']; + } + + $readinessState = match (true) { + ! $hasReadyExport => self::STATE_EXPORT_NOT_READY, + self::hasMaterialLimitations($limitations) => self::STATE_PUBLISHED_WITH_LIMITATIONS, + $includePii => self::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE, + default => self::STATE_CUSTOMER_SAFE_READY, + }; + + $customerSafeState = match ($readinessState) { + self::STATE_CUSTOMER_SAFE_READY => 'customer_safe_ready', + self::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => 'internal_only', + self::STATE_EXPORT_NOT_READY => 'not_ready', + default => 'requires_review', + }; + + $primaryReason = $limitations[0]['code'] ?? 'customer_safe_ready'; + $primaryAction = match ($readinessState) { + self::STATE_CUSTOMER_SAFE_READY => 'download_customer_safe_review_pack', + self::STATE_INTERNAL_REVIEW_PACKAGE_AVAILABLE => 'review_package_contents', + self::STATE_EXPORT_NOT_READY => 'open_evidence_basis', + default => 'review_output_limitations', + }; + + $requiredComplete = max(0, min( + $requiredSectionCount, + (int) ($requiredSectionStateCounts[EnvironmentReviewCompletenessState::Complete->value] ?? 0) + )); + + return [ + 'review_status' => $reviewStatus, + 'review_completeness_state' => $reviewCompletenessState, + 'evidence_completeness_state' => $evidenceCompletenessState, + 'has_ready_export' => $hasReadyExport, + 'section_state_counts' => $sectionStateCounts, + 'required_section_count' => $requiredSectionCount, + 'required_section_state_counts' => $requiredSectionStateCounts, + 'required_section_limited_count' => $requiredLimitedCount, + 'contains_pii' => $includePii, + 'protected_values_hidden' => $protectedValuesHidden, + 'disclosure_present' => $disclosurePresent, + 'customer_safe_state' => $customerSafeState, + 'readiness_state' => $readinessState, + 'primary_reason' => $primaryReason, + 'primary_action' => $primaryAction, + 'limitations' => $limitations, + 'section_summary' => [ + 'required_total' => $requiredSectionCount, + 'required_complete' => $requiredComplete, + 'required_limited' => $requiredLimitedCount, + 'partial' => (int) ($requiredSectionStateCounts[EnvironmentReviewCompletenessState::Partial->value] ?? 0), + 'missing' => (int) ($requiredSectionStateCounts[EnvironmentReviewCompletenessState::Missing->value] ?? 0), + 'stale' => (int) ($requiredSectionStateCounts[EnvironmentReviewCompletenessState::Stale->value] ?? 0), + ], + ]; + } + + /** + * @param array $counts + * @return array + */ + private static function normalizeCounts(array $counts): array + { + return collect($counts) + ->mapWithKeys(static fn (mixed $count, string|int $key): array => [(string) $key => max(0, (int) $count)]) + ->all(); + } + + /** + * @param list $limitations + */ + private static function hasMaterialLimitations(array $limitations): bool + { + return collect($limitations) + ->pluck('code') + ->contains(static fn (string $code): bool => $code !== 'contains_pii'); + } +} diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index b7b6f8a3..aeb99329 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -68,6 +68,10 @@ 'view_managed_tenants' => 'Managed Environments anzeigen', 'workspace_wide_available' => 'Keine Umgebung ausgewählt. Workspace-weite Seiten bleiben verfügbar; eine Umgebung setzt nur den normalen aktiven Betriebskontext.', 'search_environments' => 'Umgebungen suchen...', + 'environment_search_results_count' => ':visible von :total Umgebungen angezeigt', + 'no_environment_search_results' => 'Keine Umgebung passt zu dieser Suche', + 'no_environment_search_results_description' => 'Löschen Sie die Suche, um zur vollständigen Workspace-Liste zurückzukehren.', + 'clear_search' => 'Suche löschen', 'search_tenants' => 'Umgebungen suchen...', 'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.', ], @@ -348,15 +352,31 @@ 'customer_workspace_scope_environment_filtered' => 'Umgebungsfilter: :environment', 'customer_workspace_scope_environment_filtered_description' => 'Die Seitendaten sind bewusst über den kanonischen environment_id-Filter eingegrenzt; die Shell bleibt Workspace-owned.', 'is_review_ready_to_share' => 'Ist dieses Review bereit zur Weitergabe?', + 'review_pack_output_status' => 'Wie ist der aktuelle Output-Status des Review-Pakets?', 'ready_to_share' => 'Bereit zur Weitergabe', + 'customer_safe_review_pack_ready' => 'Kundensicheres Review-Paket bereit', + 'published_with_limitations' => 'Veröffentlicht mit Einschränkungen', + 'internal_review_package_available' => 'Internes Review-Paket verfügbar', 'shareable_with_follow_up' => 'Teilbar mit Follow-up', 'follow_up_required_before_sharing' => 'Follow-up vor Weitergabe erforderlich', 'ready_to_share_reason' => 'Das veröffentlichte Review, der Nachweispfad und das aktuelle Review-Pack sind für die kundensichere Übergabe verfügbar.', + 'customer_safe_review_pack_ready_reason' => 'Das Review-Paket ist veröffentlicht, exportbereit und durch vollständige erforderliche Evidence-Abschnitte gestützt.', + 'evidence_basis_missing_reason' => 'Das Review-Paket ist veröffentlicht, aber die Evidence-Basis fehlt.', + 'evidence_basis_stale_reason' => 'Das Review-Paket ist veröffentlicht, aber die Evidence-Basis ist veraltet.', + 'evidence_basis_incomplete_reason' => 'Das Review-Paket ist veröffentlicht, aber die Evidence-Basis ist unvollständig.', + 'required_sections_incomplete_reason' => ':limited erforderliche Abschnitte sind im aktuellen Paket teilweise, fehlend oder veraltet (:complete von :total erforderlich vollständig).', + 'publish_blockers_present_reason' => 'In der Zusammenfassung des veröffentlichten Reviews sind weiterhin Publish-Blocker erfasst.', + 'contains_pii_reason' => 'Dieses Paket enthält PII und sollte vor externer Weitergabe geprüft werden.', + 'export_not_ready_reason' => 'Das Review-Paket existiert, aber der Exportvertrag ist noch nicht bereit.', 'shareable_with_follow_up_reason' => 'Das Review-Pack ist verfügbar, aber Accepted-Risk-Follow-up muss vor der Übergabe benannt werden.', 'follow_up_required_before_sharing_reason' => 'Review-Nachweis oder Paketverfügbarkeit benötigen noch Aufmerksamkeit, bevor dies geteilt werden kann.', 'findings_follow_up_required_reason' => ':summary Halten Sie offene Findings vor der Kundenübergabe sichtbar.', 'accepted_risk_follow_up_required_reason' => 'Accepted-Risk-Follow-up ist für dieses Review erfasst. Prüfen Sie Owner, Begründung und Review-Datum vor der Übergabe.', 'ready_to_share_impact' => 'Stakeholder können das aktuelle Review-Pack und das veröffentlichte Review als Nachweispfad nutzen.', + 'customer_safe_review_pack_ready_impact' => 'Stakeholder können das aktuelle Review-Pack und das veröffentlichte Review als Nachweispfad nutzen.', + 'published_with_limitations_impact' => 'Prüfen Sie die Output-Einschränkungen vor der Weitergabe an Kunden.', + 'internal_review_package_available_impact' => 'Dieses Paket enthält interne oder PII-tragende Details und sollte vor externer Weitergabe geprüft werden.', + 'export_not_ready_impact' => 'Stellen Sie dieses Paket nicht als kundenbereit dar, bevor die Exportbereitschaft erfüllt ist.', 'shareable_with_follow_up_impact' => 'Nutzen Sie das aktuelle Pack nur, wenn das Accepted-Risk-Follow-up in der Kundenübergabe enthalten ist.', 'follow_up_required_before_sharing_impact' => 'Behandeln Sie dieses Review erst als teilbar, wenn der nicht verfügbare Nachweis geprüft wurde.', 'findings_follow_up_required_impact' => 'Behandeln Sie dieses Review erst als teilbar, wenn offene Findings behoben, akzeptiert oder ausdrücklich geprüft wurden.', @@ -413,6 +433,16 @@ 'export_availability' => 'Export-Verfügbarkeit', 'export_ready' => 'Export bereit', 'export_not_ready' => 'Export nicht bereit', + 'evidence_basis_state' => 'Evidence-Basis', + 'section_completeness' => 'Abschnittsvollständigkeit', + 'sharing_boundary' => 'Freigabebereich', + 'pii_state' => 'PII', + 'contains_pii' => 'Enthält PII', + 'pii_excluded' => 'PII ausgeschlossen', + 'protected_values' => 'Geschützte Werte', + 'protected_values_hidden' => 'Geschützte Werte verborgen', + 'disclosure' => 'Offenlegung', + 'disclosure_present' => 'Offenlegung vorhanden', 'review_package_index' => 'Review-Paket-Index', 'review_package_index_description' => 'Veröffentlichte Reviews und kundensichere Paketeinträge, die in diesem Workspace verfügbar sind.', 'review_pack_state' => 'Review-Pack-Status', @@ -484,6 +514,8 @@ 'review_recommended' => 'Review empfohlen', 'recommended_next_action' => 'Empfohlene nächste Aktion', 'customer_safe' => 'Kundensicher', + 'requires_review' => 'Prüfung erforderlich', + 'internal_only' => 'Nur intern', 'interpretation_version_short' => 'Interpretationsversion: :version', 'additional_controls' => '+:count weitere Control(s)', 'control_limitations_summary' => 'Limitierungen: :limitations.', @@ -515,8 +547,14 @@ 'last_review' => 'Letztes Review', 'primary_action' => 'Primäre Aktion', 'download_review_pack' => 'Review-Pack herunterladen', + 'download_customer_safe_review_pack' => 'Kundensicheres Review-Paket herunterladen', + 'download_review_pack_with_limitations' => 'Review-Paket mit Einschränkungen herunterladen', + 'download_internal_review_pack' => 'Internes Review-Paket herunterladen', 'download_current_review_pack' => 'Aktuelles Review-Pack herunterladen', 'download_governance_package' => 'Governance-Paket herunterladen', + 'review_package_contents' => 'Paketinhalt prüfen', + 'review_output_limitations' => 'Output-Einschränkungen prüfen', + 'open_evidence_basis' => 'Evidence-Basis öffnen', 'governance_package' => 'Governance-Paket', 'governance_decisions' => 'Governance-Entscheidungen', 'governance_decisions_requiring_awareness' => 'Governance-Entscheidungen mit Aufmerksamkeitsbedarf', @@ -566,6 +604,10 @@ 'preparing' => 'In Vorbereitung', 'review_pack_available' => 'Aktuelles Review-Pack verfügbar', 'review_pack_available_customer_description' => 'Das aktuelle Review-Pack ist zum Download bereit.', + 'review_pack_customer_safe_ready_description' => 'Das aktuelle Review-Paket ist verfügbar und erfüllt den kundensicheren Output-Vertrag.', + 'review_pack_with_limitations_description' => 'Das Review-Paket existiert, aber Evidence-, Abschnitts- oder Veröffentlichungsgrenzen müssen noch geprüft werden.', + 'review_pack_internal_review_description' => 'Das Review-Paket existiert, enthält aber interne oder PII-tragende Details, die vor externer Weitergabe geprüft werden sollten.', + 'review_pack_export_not_ready_description' => 'Das Review-Paket existiert, aber der Exportvertrag ist noch nicht bereit.', 'review_pack_preparing' => 'In Vorbereitung', 'review_pack_preparing_description' => 'Das Review-Pack wird vorbereitet.', 'review_pack_not_available_yet' => 'Noch nicht verfügbar', @@ -621,12 +663,18 @@ 'evidence_basis_complete_description' => 'Die kundensichere Entscheidungszusammenfassung ist durch Evidence des veröffentlichten Reviews gestützt.', 'evidence_basis_no_awareness_required' => 'Keine Aufmerksamkeit erforderlich', 'evidence_basis_no_awareness_required_description' => 'In dieser Evidence-Basis des veröffentlichten Reviews benötigen keine Governance-Entscheidungen Aufmerksamkeit.', + 'evidence_basis_missing' => 'Fehlend', + 'evidence_basis_missing_description' => 'Erforderliche Evidence fehlt in der aktuellen Review-Basis.', + 'evidence_basis_stale' => 'Veraltet', + 'evidence_basis_stale_description' => 'Die Evidence-Basis ist veraltet und sollte vor der Weitergabe aktualisiert werden.', 'evidence_basis_incomplete' => 'Unvollständig', 'evidence_basis_incomplete_description' => 'Die Entscheidungs-Evidence ist unvollständig und darf nicht als „keine Entscheidungen“ interpretiert werden.', 'evidence_basis_unavailable' => 'Nicht verfügbar', 'evidence_basis_unavailable_description' => 'Kundensichere Entscheidungs-Evidence ist für dieses veröffentlichte Review nicht verfügbar.', 'evidence_basis_not_generated' => 'Nicht erzeugt', 'evidence_basis_not_generated_description' => 'Das Review-Pack ist noch nicht verfügbar; das veröffentlichte Review bleibt sichtbar.', + 'section_completeness_complete' => ':complete von :total erforderlich vollständig', + 'section_completeness_limited' => ':complete von :total erforderlich vollständig, :limited eingeschränkt', 'released_governance_record' => 'Veröffentlichter Governance-Nachweis', 'released_governance_record_available' => 'Dieses veröffentlichte Review ist für kundensichere Governance-Nutzung verfügbar.', 'outcome_summary' => 'Ergebniszusammenfassung', @@ -678,7 +726,7 @@ 'related_context' => 'Verwandter Kontext', 'publication_readiness' => 'Veröffentlichungsreife', 'ready_for_publication' => 'Dieses Review ist bereit für Veröffentlichung und Executive-Pack-Export.', - 'internal_only' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.', + 'internal_only_publication' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.', 'needs_follow_up' => 'Dieses Review benötigt vor der Veröffentlichung noch Nacharbeit.', 'key_entries' => 'Wichtige Einträge', 'entry' => 'Eintrag', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index 408c0e4b..de3842cb 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -68,6 +68,10 @@ 'view_managed_tenants' => 'View managed environments', 'workspace_wide_available' => 'No environment selected. Workspace-wide pages remain available, and choosing an environment only sets the normal active operating context.', 'search_environments' => 'Search environments...', + 'environment_search_results_count' => ':visible of :total environments shown', + 'no_environment_search_results' => 'No environments match this search', + 'no_environment_search_results_description' => 'Clear the search to return to the full workspace list.', + 'clear_search' => 'Clear search', 'search_tenants' => 'Search environments...', 'choose_workspace_first' => 'Choose a workspace first.', ], @@ -348,15 +352,31 @@ 'customer_workspace_scope_environment_filtered' => 'Environment filter: :environment', 'customer_workspace_scope_environment_filtered_description' => 'The page data is intentionally narrowed by the canonical environment_id filter while the shell remains workspace-owned.', 'is_review_ready_to_share' => 'Is this review ready to share?', + 'review_pack_output_status' => 'What is the current review pack output state?', 'ready_to_share' => 'Ready to share', + 'customer_safe_review_pack_ready' => 'Customer-safe review pack ready', + 'published_with_limitations' => 'Published with limitations', + 'internal_review_package_available' => 'Internal review package available', 'shareable_with_follow_up' => 'Shareable with follow-up', 'follow_up_required_before_sharing' => 'Follow-up required before sharing', 'ready_to_share_reason' => 'The released review, evidence path, and current review pack are available for customer-safe handoff.', + 'customer_safe_review_pack_ready_reason' => 'The review package is published, export-ready, and backed by complete required evidence sections.', + 'evidence_basis_missing_reason' => 'The review package is published, but the evidence basis is missing.', + 'evidence_basis_stale_reason' => 'The review package is published, but the evidence basis is stale.', + 'evidence_basis_incomplete_reason' => 'The review package is published, but the evidence basis is incomplete.', + 'required_sections_incomplete_reason' => ':limited required sections are partial, missing, or stale in the current package (:complete of :total required complete).', + 'publish_blockers_present_reason' => 'Publish blockers are still recorded in the released review summary.', + 'contains_pii_reason' => 'This package includes PII and should be reviewed before external sharing.', + 'export_not_ready_reason' => 'The review package exists, but the export contract is not ready yet.', 'shareable_with_follow_up_reason' => 'The review pack is available, but accepted-risk follow-up must be called out before handoff.', 'follow_up_required_before_sharing_reason' => 'Review proof or package availability still needs attention before this can be shared.', 'findings_follow_up_required_reason' => ':summary Keep open findings visible before customer handoff.', 'accepted_risk_follow_up_required_reason' => 'Accepted-risk follow-up is recorded for this review. Review the owner, rationale, and review date before handoff.', 'ready_to_share_impact' => 'Stakeholders can use the current review pack and released review as the evidence path.', + 'customer_safe_review_pack_ready_impact' => 'Stakeholders can use the current review pack and released review as the evidence path.', + 'published_with_limitations_impact' => 'Review output limitations before customer sharing.', + 'internal_review_package_available_impact' => 'This package includes internal or PII-bearing detail and should be reviewed before external sharing.', + 'export_not_ready_impact' => 'Do not present this package as customer-ready until export readiness passes.', 'shareable_with_follow_up_impact' => 'Use the current pack only with the accepted-risk follow-up included in the customer handoff.', 'follow_up_required_before_sharing_impact' => 'Do not treat this review as share-ready until the unavailable proof has been reviewed.', 'findings_follow_up_required_impact' => 'Do not treat this review as share-ready until open findings are resolved, accepted, or explicitly reviewed.', @@ -413,6 +433,16 @@ 'export_availability' => 'Export availability', 'export_ready' => 'Export ready', 'export_not_ready' => 'Export not ready', + 'evidence_basis_state' => 'Evidence basis', + 'section_completeness' => 'Section completeness', + 'sharing_boundary' => 'Sharing boundary', + 'pii_state' => 'PII', + 'contains_pii' => 'Contains PII', + 'pii_excluded' => 'PII excluded', + 'protected_values' => 'Protected values', + 'protected_values_hidden' => 'Protected values hidden', + 'disclosure' => 'Disclosure', + 'disclosure_present' => 'Disclosure present', 'review_package_index' => 'Review package index', 'review_package_index_description' => 'Released reviews and customer-safe package entries available in this workspace.', 'review_pack_state' => 'Review pack state', @@ -484,6 +514,8 @@ 'review_recommended' => 'Review recommended', 'recommended_next_action' => 'Recommended next action', 'customer_safe' => 'Customer-safe', + 'requires_review' => 'Requires review', + 'internal_only' => 'Internal only', 'interpretation_version_short' => 'Interpretation version: :version', 'additional_controls' => '+:count more control(s)', 'control_limitations_summary' => 'Limitations: :limitations.', @@ -515,8 +547,14 @@ 'last_review' => 'Last review', 'primary_action' => 'Primary action', 'download_review_pack' => 'Download review pack', + 'download_customer_safe_review_pack' => 'Download customer-safe review pack', + 'download_review_pack_with_limitations' => 'Download review pack with limitations', + 'download_internal_review_pack' => 'Download internal review pack', 'download_current_review_pack' => 'Download current review pack', 'download_governance_package' => 'Download governance package', + 'review_package_contents' => 'Review package contents', + 'review_output_limitations' => 'Review output limitations', + 'open_evidence_basis' => 'Open evidence basis', 'governance_package' => 'Governance package', 'governance_decisions' => 'Governance decisions', 'governance_decisions_requiring_awareness' => 'Governance decisions requiring awareness', @@ -566,6 +604,10 @@ 'preparing' => 'Preparing', 'review_pack_available' => 'Current review pack available', 'review_pack_available_customer_description' => 'Current review pack is ready to download.', + 'review_pack_customer_safe_ready_description' => 'The current review package is available and meets the customer-safe output contract.', + 'review_pack_with_limitations_description' => 'The review package exists, but evidence, section completeness, or publication limitations still need review.', + 'review_pack_internal_review_description' => 'The review package exists, but it includes internal or PII-bearing detail that should be reviewed before external sharing.', + 'review_pack_export_not_ready_description' => 'The review package exists, but the export contract is not ready yet.', 'review_pack_preparing' => 'Preparing', 'review_pack_preparing_description' => 'Review Pack is being prepared.', 'review_pack_not_available_yet' => 'Not available yet', @@ -621,12 +663,18 @@ 'evidence_basis_complete_description' => 'Customer-safe decision summary is backed by released-review evidence.', 'evidence_basis_no_awareness_required' => 'No awareness required', 'evidence_basis_no_awareness_required_description' => 'No governance decisions require awareness in this released-review evidence basis.', + 'evidence_basis_missing' => 'Missing', + 'evidence_basis_missing_description' => 'Required evidence is missing from the current review basis.', + 'evidence_basis_stale' => 'Stale', + 'evidence_basis_stale_description' => 'The evidence basis is stale and should be refreshed before sharing.', 'evidence_basis_incomplete' => 'Incomplete', 'evidence_basis_incomplete_description' => 'Decision evidence is incomplete and must not be interpreted as no decisions.', 'evidence_basis_unavailable' => 'Unavailable', 'evidence_basis_unavailable_description' => 'Customer-safe decision evidence is unavailable for this released review.', 'evidence_basis_not_generated' => 'Not generated', 'evidence_basis_not_generated_description' => 'The Review Pack is not available yet; the released review remains visible.', + 'section_completeness_complete' => ':complete of :total required complete', + 'section_completeness_limited' => ':complete of :total required complete, :limited limited', 'released_governance_record' => 'Released governance record', 'released_governance_record_available' => 'This released review is available for customer-safe governance consumption.', 'outcome_summary' => 'Outcome summary', @@ -678,7 +726,7 @@ 'related_context' => 'Related context', 'publication_readiness' => 'Publication readiness', 'ready_for_publication' => 'This review is ready for publication and executive-pack export.', - 'internal_only' => 'This review is currently safe for internal use only.', + 'internal_only_publication' => 'This review is currently safe for internal use only.', 'needs_follow_up' => 'This review still needs follow-up before publication.', 'key_entries' => 'Key entries', 'entry' => 'Entry', diff --git a/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php b/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php index 98f299c8..51d0e528 100644 --- a/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php @@ -443,7 +443,7 @@ @elseif ($decisionDirection === 'internal_only')
-
{{ __('localization.review.internal_only') }}
+
{{ __('localization.review.internal_only_publication') }}
@if ($publicationNextAction !== null)
{{ $publicationNextAction }}
diff --git a/apps/platform/resources/views/filament/pages/choose-environment.blade.php b/apps/platform/resources/views/filament/pages/choose-environment.blade.php index 0608fdfa..a8feb0b9 100644 --- a/apps/platform/resources/views/filament/pages/choose-environment.blade.php +++ b/apps/platform/resources/views/filament/pages/choose-environment.blade.php @@ -1,11 +1,14 @@ @php - $tenants = $this->getTenants(); + $allTenants = $this->getTenants(); + $tenants = $this->getVisibleTenants($allTenants); $workspace = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspace(); - $environmentCount = $tenants->count(); + $environmentCount = $allTenants->count(); + $visibleEnvironmentCount = $tenants->count(); + $hasSearch = trim($this->search) !== ''; @endphp - @if ($tenants->isEmpty()) + @if ($allTenants->isEmpty()) {{-- Empty state --}}
@@ -41,112 +44,162 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400" - Switch workspace + {{ __('localization.shell.switch_workspace') }}
@else {{-- ManagedEnvironment list --}} -
+
{{-- Header row --}} -
-
+
+
@if ($workspace) -
- - {{ $workspace->name }} +
+ + {{ $workspace->name }}
@endif - · {{ trans_choice('localization.shell.environment_count', $environmentCount, ['count' => $environmentCount]) }} + {{ trans_choice('localization.shell.environment_count', $environmentCount, ['count' => $environmentCount]) }}
+ +

{{ __('localization.shell.choose_environment_description') }}

{{ __('localization.shell.workspace_wide_available_without_environment') }}

+
+ + + + + + @if ($hasSearch) +
+ {{ __('localization.shell.environment_search_results_count', ['visible' => $visibleEnvironmentCount, 'total' => $environmentCount]) }} + +
+ @endif +
+ {{-- ManagedEnvironment cards --}} -
- @foreach ($tenants as $tenant) - @php - $presentation = $this->tenantLifecyclePresentation($tenant); - $badgeClasses = match ($presentation->badgeColor) { - 'success' => 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200', - 'warning' => 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200', - 'danger' => 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200', - default => 'border-gray-200 bg-gray-100 text-gray-700 dark:border-white/10 dark:bg-white/10 dark:text-gray-300', - }; - @endphp + @if ($tenants->isEmpty()) +
+

{{ __('localization.shell.no_environment_search_results') }}

+

{{ __('localization.shell.no_environment_search_results_description') }}

- @endforeach -
+
+ @else +
+ @foreach ($tenants as $tenant) + @php + $presentation = $this->tenantLifecyclePresentation($tenant); + $badgeClasses = match ($presentation->badgeColor) { + 'success' => 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200', + 'warning' => 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200', + 'danger' => 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200', + default => 'border-gray-200 bg-gray-100 text-gray-700 dark:border-white/10 dark:bg-white/10 dark:text-gray-300', + }; + $environmentLabel = $tenant->environment && $tenant->environment !== 'managed_environment' + ? strtoupper($tenant->environment) + : null; + @endphp + + @endforeach +
+ @endif
@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 4aa841fb..8ba176e9 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 @@ -47,6 +47,13 @@ $followUps = $reviewPayload['follow_ups']; $diagnostics = $reviewPayload['diagnostics']; $disclosureRules = $reviewPayload['disclosure_rules']; + $reviewPackValueToneClasses = [ + 'gray' => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300', + 'info' => 'border-info-200 bg-info-50 text-info-700 dark:border-info-700/60 dark:bg-info-500/10 dark:text-info-300', + 'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/60 dark:bg-success-500/10 dark:text-success-300', + 'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700/60 dark:bg-warning-500/10 dark:text-warning-300', + 'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700/60 dark:bg-danger-500/10 dark:text-danger-300', + ]; @endphp
@@ -61,8 +68,8 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit {{ $readiness['label'] }} - - {{ __('localization.review.customer_safe') }} + + {{ $readiness['boundary_label'] }}
@@ -111,15 +118,15 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit @endif - @if ($latest['secondary_action_url']) + @if ($readiness['secondary_action_url']) - {{ $latest['secondary_action_label'] }} + {{ $readiness['secondary_action_label'] }} @endif
@@ -442,22 +449,23 @@ class="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-whit

-
-
{{ __('localization.review.last_generated') }}
-
{{ $reviewPackPanel['last_generated_label'] }}
-
-
-
{{ __('localization.review.evidence_source') }}
-
{{ $reviewPackPanel['evidence_snapshot_label'] }}
-
-
-
{{ __('localization.review.export_availability') }}
-
{{ $reviewPackPanel['export_label'] }}
-
-
-
{{ __('localization.review.operation_proof') }}
-
{{ $reviewPackPanel['operation_label'] }}
-
+ @foreach ($reviewPackPanel['detail_rows'] as $row) + @php + $valueToneClass = $reviewPackValueToneClasses[$row['color']] ?? $reviewPackValueToneClasses['gray']; + @endphp + +
+
{{ $row['label'] }}
+
+ + {{ $row['value'] }} + +
+
+ @endforeach
diff --git a/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php index 5bc14e52..3cbb25d4 100644 --- a/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php +++ b/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php @@ -77,6 +77,7 @@ 'published_at' => now(), 'published_by_user_id' => (int) $user->getKey(), ])->save(); + $publishedReview = markEnvironmentReviewCustomerSafeReady($publishedReview); $internalOnlyReview = composeEnvironmentReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot); $internalOnlyReview->forceFill([ @@ -93,6 +94,10 @@ 'environment_review_id' => (int) $publishedReview->getKey(), 'evidence_snapshot_id' => (int) $publishedSnapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], 'file_path' => 'review-packs/customer-review-workspace-smoke.zip', 'file_disk' => 'exports', ]); @@ -124,10 +129,10 @@ ->assertSee('Review-Paket-Index') ->assertSee('Offenlegungsregel') ->assertSee('Eingeklappt') - ->assertSee('Review-Pack herunterladen') - ->assertSee('Das aktuelle Review-Pack ist zum Download bereit.') + ->assertSee('Kundensicheres Review-Paket herunterladen') + ->assertSee('Das aktuelle Review-Paket ist verfügbar und erfüllt den kundensicheren Output-Vertrag.') ->assertSee('In diesem veröffentlichten Review benötigen keine Governance-Entscheidungen Kundenaufmerksamkeit.') - ->assertSee('Bereit zur Weitergabe') + ->assertSee('Kundensicheres Review-Paket bereit') ->assertSee('Verfügbar') ->assertDontSee('Customer-safe governance package index') ->assertDontSee('localization.review.customer_safe_review_workspace') diff --git a/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php b/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php index b5b67ec6..5421ec90 100644 --- a/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php +++ b/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php @@ -28,7 +28,7 @@ ->waitForText('Customer-safe review packages') ->assertDontSee('No environment selected') ->assertDontSee('Environment filter:') - ->assertSee('Is this review ready to share?') + ->assertSee('What is the current review pack output state?') ->assertSee('Evidence path') ->assertSee('Review pack state') ->assertSee('Accepted risks') @@ -76,7 +76,7 @@ ->assertSee($environmentA->name) ->assertDontSee($environmentB->name) ->assertSee('Environment filter: '.$environmentA->name) - ->assertSee('Is this review ready to share?') + ->assertSee('What is the current review pack output state?') ->assertSee('Evidence path') ->assertSee('Review package index') ->assertScript('document.querySelector("[data-testid=\"customer-review-diagnostics\"]")?.open === false', true) diff --git a/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php b/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php index dbadd161..a4125973 100644 --- a/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php +++ b/apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php @@ -57,9 +57,10 @@ 'next_action' => 'Review the evidence basis before relying on the decision summary.', 'entries' => [], ], - ], + ], ], 'review-packs/spec342-browser-evidence-incomplete.zip', + normalizeOutputReadiness: false, ); spec342BrowserCreatePublishedReviewWithPack( @@ -107,14 +108,14 @@ $page = visit(CustomerReviewWorkspace::environmentFilterUrl($notReadyEnvironment)) ->resize(1236, 900) - ->waitForText('Follow-up required before sharing') - ->assertSee('Evidence incomplete') - ->assertSee('Customer-safe output') - ->assertSee('Not ready') + ->waitForText('Published with limitations') + ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->assertSee('Needs review') + ->assertSee('Download review pack with limitations') ->assertSee('Review consumption flow') ->assertScript('document.querySelectorAll("[data-testid=\"customer-review-readiness-step\"]").length === 6', true) - ->assertScript('document.querySelector("[data-step-label=\"Review pack\"]")?.dataset.stepState === "Evidence incomplete"', true) - ->assertScript('document.querySelector("[data-step-label=\"Customer-safe output\"]")?.dataset.stepState === "Not ready"', true) + ->assertScript('document.querySelector("[data-step-label=\"Review pack\"]")?.dataset.stepState === "Available"', true) + ->assertScript('document.querySelector("[data-step-label=\"Customer-safe output\"]")?.dataset.stepState === "Needs review"', true) ->assertScript('document.querySelector("[data-testid=\"customer-review-diagnostics\"]")?.open === false', true) ->assertDontSee('raw payload should stay hidden') ->assertDontSee('provider response should stay hidden') @@ -126,9 +127,9 @@ spec342CopyBrowserScreenshot('01-evidence-incomplete-not-ready'); $page = visit(CustomerReviewWorkspace::environmentFilterUrl($readyEnvironment)) - ->waitForText('Ready to share') + ->waitForText('Customer-safe review pack ready') ->assertSee('Stakeholders can use the current review pack and released review as the evidence path.') - ->assertSee('Download review pack') + ->assertSee('Download customer-safe review pack') ->assertSee('Review pack state') ->assertSee('Export ready') ->assertSee('Operation proof') @@ -136,7 +137,7 @@ ->assertSee('No open findings require customer action.') ->assertScript('document.querySelectorAll("[data-testid=\"customer-review-primary-action\"]").length === 1', true) ->assertScript('document.querySelector("[data-testid=\"customer-review-evidence-path-panel\"]")?.innerText.includes("Download review pack") === false', true) - ->assertScript('document.querySelector("[data-testid=\"customer-review-secondary-action\"]")?.innerText.includes("Download review pack") === false', true) + ->assertScript('document.querySelector("[data-testid=\"customer-review-secondary-action\"]")?.innerText.includes("Download customer-safe review pack") === false', true) ->assertScript('document.querySelectorAll("[data-testid=\"customer-review-readiness-step\"]").length === 6', true) ->assertScript('document.querySelector("[data-step-label=\"Customer-safe output\"]")?.dataset.stepState === "Ready"', true) ->assertScript('Array.from(document.querySelectorAll("[data-testid=\"customer-review-readiness-step\"] [class*=\"badge\"], [data-testid=\"customer-review-review-pack-panel\"] [class*=\"badge\"], [data-testid=\"customer-review-accepted-risk-panel\"] [class*=\"badge\"]")).every((badge) => ! badge.innerText.includes("..."))', true) @@ -159,13 +160,14 @@ $page = visit(CustomerReviewWorkspace::environmentFilterUrl($findingsEnvironment)) ->waitForText('Findings needing attention') - ->assertSee('Follow-up required before sharing') + ->assertSee('Published with limitations') ->assertSee('1 open finding needs attention; 1 is high impact.') ->assertSee('Keep open findings visible before customer handoff.') ->assertSee('Do not treat this review as share-ready until open findings are resolved, accepted, or explicitly reviewed.') ->assertSee('High impact') ->assertSee('Open review') - ->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download review pack") === false', true) + ->assertSee('Download review pack with limitations') + ->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download review pack with limitations") === true', true) ->assertScript('document.querySelector("[data-testid=\"customer-review-evidence-path-panel\"]")?.innerText.includes("Download review pack") === false', true) ->assertScript('document.querySelector("[data-step-label=\"Findings triaged\"]")?.dataset.stepState === "Needs review"', true) ->assertScript('document.querySelector("[data-step-label=\"Findings triaged\"]")?.dataset.stepCurrent === "true"', true) @@ -176,7 +178,7 @@ spec342CopyBrowserScreenshot('04-findings-need-attention'); $page = visit(CustomerReviewWorkspace::environmentFilterUrl($acceptedRiskEnvironment)) - ->waitForText('Shareable with follow-up') + ->waitForText('Published with limitations') ->assertSee('Accepted-risk follow-up is recorded for this review. Review the owner, rationale, and review date before handoff.') ->assertSee('The pack can be shared only with the accepted-risk context included in the customer handoff.') ->assertSee('Open review') @@ -186,7 +188,8 @@ ->assertSee('Customer-approved maintenance window.') ->assertSee('Review date not recorded') ->assertSee('Accepted risk requires customer awareness.') - ->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download review pack") === false', true) + ->assertSee('Download review pack with limitations') + ->assertScript('document.querySelector("[data-testid=\"customer-review-decision-card\"]")?.innerText.includes("Download review pack with limitations") === true', true) ->assertScript('document.querySelector("[data-step-label=\"Accepted risks reviewed\"]")?.dataset.stepCurrent === "true"', true) ->assertNoJavaScriptErrors() ->assertNoConsoleLogs(); @@ -274,6 +277,7 @@ function spec342BrowserCreatePublishedReviewWithPack( EvidenceSnapshot $snapshot, array $summaryOverrides = [], string $filePath = 'review-packs/spec342-browser-review-pack.zip', + bool $normalizeOutputReadiness = true, ): array { $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); $summary = array_replace_recursive( @@ -327,6 +331,9 @@ function spec342BrowserCreatePublishedReviewWithPack( 'published_at' => now()->subMinutes(3), 'published_by_user_id' => (int) $user->getKey(), ])->save(); + if ($normalizeOutputReadiness) { + $review = markEnvironmentReviewCustomerSafeReady($review); + } $pack = ReviewPack::factory()->ready()->create([ 'managed_environment_id' => (int) $environment->getKey(), @@ -336,6 +343,10 @@ function spec342BrowserCreatePublishedReviewWithPack( 'operation_run_id' => (int) $run->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), 'status' => ReviewPackStatus::Ready->value, + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], 'file_path' => $filePath, 'file_disk' => 'exports', 'generated_at' => now()->subMinutes(4), diff --git a/apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php b/apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php new file mode 100644 index 00000000..4e4028b6 --- /dev/null +++ b/apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php @@ -0,0 +1,256 @@ +browser()->timeout(60_000); + +beforeEach(function (): void { + Storage::fake('exports'); +}); + +it('Spec347 smokes review pack output readiness states', function (): void { + [$user, $readyEnvironment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + $readyEnvironment->forceFill(['name' => 'Spec347 Browser Ready'])->save(); + $limitedEnvironment = spec347BrowserEnvironmentFor($user, $readyEnvironment, 'Spec347 Browser Limited'); + $internalEnvironment = spec347BrowserEnvironmentFor($user, $readyEnvironment, 'Spec347 Browser Internal'); + + spec347BrowserCreatePublishedReviewWithPack( + $readyEnvironment, + $user, + seedEnvironmentReviewEvidence($readyEnvironment, findingCount: 0, driftCount: 0), + [], + [ + 'include_pii' => false, + 'include_operations' => true, + ], + 'review-packs/spec347-browser-ready.zip', + ); + + spec347BrowserCreatePublishedReviewWithPack( + $limitedEnvironment, + $user, + seedPartialEnvironmentReviewEvidence($limitedEnvironment, 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/spec347-browser-limited.zip', + normalizeOutputReadiness: false, + ); + + spec347BrowserCreatePublishedReviewWithPack( + $internalEnvironment, + $user, + seedEnvironmentReviewEvidence($internalEnvironment, findingCount: 0, driftCount: 0), + [], + [ + 'include_pii' => true, + 'include_operations' => true, + ], + 'review-packs/spec347-browser-internal.zip', + ); + + spec347AuthenticateBrowser($this, $user, $readyEnvironment); + + $page = visit(CustomerReviewWorkspace::environmentFilterUrl($readyEnvironment)) + ->resize(1236, 900) + ->waitForText('Customer-safe review pack ready') + ->assertSee('Download customer-safe review pack') + ->assertSee('PII excluded') + ->assertDontSee('Ready to share') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + $page->screenshot(true, spec347BrowserScreenshotName('01-customer-safe-ready')); + 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.') + ->assertSee('Download review pack with limitations') + ->assertSee('Requires review') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + $page->screenshot(true, spec347BrowserScreenshotName('02-published-with-limitations')); + spec347CopyBrowserScreenshot('02-published-with-limitations'); + + $page = visit(CustomerReviewWorkspace::environmentFilterUrl($internalEnvironment)) + ->waitForText('Internal review package available') + ->assertSee('Contains PII') + ->assertSee('Download internal review pack') + ->assertSee('Internal only') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + $page->screenshot(true, spec347BrowserScreenshotName('03-internal-review-package')); + spec347CopyBrowserScreenshot('03-internal-review-package'); +}); + +function spec347BrowserScreenshotName(string $name): string +{ + return 'spec347-review-pack-output-readiness-'.$name; +} + +function spec347CopyBrowserScreenshot(string $name): void +{ + $filename = spec347BrowserScreenshotName($name).'.png'; + $source = base_path('tests/Browser/Screenshots/'.$filename); + $targetDirectory = repo_path('specs/347-review-pack-output-contract-readiness-semantics/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 spec347AuthenticateBrowser(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 spec347BrowserEnvironmentFor(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 spec347BrowserCreatePublishedReviewWithPack( + ManagedEnvironment $environment, + User $user, + EvidenceSnapshot $snapshot, + array $summaryOverrides = [], + array $packOptions = [], + string $filePath = 'review-packs/spec347-browser-review-pack.zip', + bool $normalizeOutputReadiness = 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' => 'evidence_on_record', + 'readiness_label' => 'Evidence on record', + 'primary_reason' => 'Evidence path is complete.', + 'recommended_next_action' => 'Open the current customer review pack.', + ], + ], + ], + 'governance_package' => [ + 'decision_summary' => [ + 'status' => 'none', + 'evidence_state' => EnvironmentReviewCompletenessState::Complete->value, + 'decision_data_state' => 'complete', + 'total_count' => 0, + 'summary' => 'No governance decisions require customer awareness.', + 'next_action' => 'Open the current customer review pack.', + 'entries' => [], + ], + ], + ], + $summaryOverrides, + ); + + Storage::disk('exports')->put($filePath, 'PK-spec347-browser-test'); + + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'completeness_state' => EnvironmentReviewCompletenessState::Complete->value, + 'summary' => $summary, + 'generated_at' => now()->subMinutes(5), + 'published_at' => now()->subMinutes(3), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + if ($normalizeOutputReadiness) { + $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/EnvironmentReviewExecutivePackTest.php b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php index 5b487a52..2957c0a0 100644 --- a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php +++ b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php @@ -107,7 +107,8 @@ function spec308SeedExpiredDecisionFinding(ManagedEnvironment $tenant, User $req ->and($metadata['delivery_bundle']['released_review']['id'] ?? null)->toBe((int) $review->getKey()) ->and($metadata['delivery_bundle']['interpretation_version'] ?? null)->toBe($review->controlInterpretationVersion()) ->and($metadata['delivery_bundle']['entrypoint']['file'] ?? null)->toBe(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME) - ->and(collect($metadata['delivery_bundle']['appendix'] ?? [])->pluck('file')->all())->toBe(['metadata.json', 'summary.json', 'sections.json']) + ->and(array_slice(collect($metadata['delivery_bundle']['appendix'] ?? [])->pluck('file')->all(), 0, 3))->toBe(['metadata.json', 'summary.json', 'sections.json']) + ->and(collect($metadata['delivery_bundle']['appendix'] ?? [])->pluck('file')->all())->toContain('sections/10-executive_summary.json') ->and($filenames)->toContain('metadata.json', 'summary.json', 'sections.json', ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME) ->and(collect($filenames)->filter(fn (string $filename): bool => str_starts_with($filename, 'executive-'))->values()->all())->toBe([ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME]) ->and($executiveEntrypoint)->toContain('# Executive summary') diff --git a/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php b/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php index 92c0dec0..39c390ca 100644 --- a/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php +++ b/apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php @@ -63,6 +63,10 @@ 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'operation_run_id' => (int) $run->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], ]); $review->forceFill([ 'current_export_review_pack_id' => (int) $pack->getKey(), @@ -72,8 +76,8 @@ $component = spec342WorkspaceComponent($user, $environment); $component - ->assertSee('Is this review ready to share?') - ->assertSee('Ready to share') + ->assertSee('What is the current review pack output state?') + ->assertSee('Customer-safe review pack ready') ->assertSee('Stakeholders can use the current review pack and released review as the evidence path.') ->assertSee('Review consumption flow') ->assertSee('Review data') @@ -84,6 +88,7 @@ ->assertSee('No open findings require customer action.') ->assertSee('Review pack state') ->assertSee('Export ready') + ->assertSee('Download customer-safe review pack') ->assertSee('Operation proof') ->assertSee('Spec342 Operator') ->assertDontSee('Auditor-ready') @@ -101,7 +106,7 @@ it('shows not-ready proof states without raw diagnostics or false output claims', function (): void { $environment = ManagedEnvironment::factory()->create(['name' => 'Spec342 Evidence Missing']); [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'readonly'); - $snapshot = seedPartialEnvironmentReviewEvidence($environment); + $snapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0); $review = spec342PublishedReview($environment, $user, $snapshot, [ 'debug_payload' => 'raw payload should stay hidden', 'provider_response' => 'provider response should stay hidden', @@ -129,26 +134,30 @@ 'entries' => [], ], ], - ]); + ], normalizeOutputReadiness: false); $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' => false, + 'include_operations' => true, + ], ]); $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); $component = spec342WorkspaceComponent($user, $environment) - ->assertSee('Follow-up required before sharing') - ->assertSee('Evidence incomplete') + ->assertSee('Published with limitations') + ->assertSee('The review package is published, but the evidence basis is incomplete.') ->assertSee('Customer-safe output') - ->assertSee('Not ready') + ->assertSee('Needs review') ->assertSee('Diagnostics') ->assertSee('Collapsed') ->assertDontSee('Ready to share') - ->assertDontSee('Export ready') - ->assertDontSee('Download review pack') + ->assertSee('Export ready') + ->assertSee('Download review pack with limitations') ->assertDontSee('raw payload should stay hidden') ->assertDontSee('provider response should stay hidden') ->assertDontSee('stack trace should stay hidden') @@ -175,6 +184,10 @@ 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'operation_run_id' => (int) $run->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], ]); $review->forceFill([ 'current_export_review_pack_id' => (int) $pack->getKey(), @@ -189,12 +202,12 @@ ]); $component = spec342WorkspaceComponent($user, $environment) - ->assertSee('Follow-up required before sharing') + ->assertSee('Published with limitations') ->assertSee('1 open finding needs attention; 1 is high impact. Keep open findings visible before customer handoff.') ->assertSee('Do not treat this review as share-ready until open findings are resolved, accepted, or explicitly reviewed.') ->assertSee('Open review') ->assertSee('Review pack state') - ->assertDontSee('Download review pack') + ->assertSee('Download review pack with limitations') ->assertDontSee('TenantPilot recorded an access, scope, or configuration issue'); $html = $component->html(); @@ -324,6 +337,7 @@ function spec342PublishedReview( User $user, \App\Models\EvidenceSnapshot $snapshot, array $summaryOverrides = [], + bool $normalizeOutputReadiness = true, ): \App\Models\EnvironmentReview { $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); $summary = array_replace_recursive(is_array($review->summary) ? $review->summary : [], $summaryOverrides); @@ -336,5 +350,9 @@ function spec342PublishedReview( 'published_by_user_id' => (int) $user->getKey(), ])->save(); + if ($normalizeOutputReadiness) { + return markEnvironmentReviewCustomerSafeReady($review); + } + return $review->refresh(); } diff --git a/apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php b/apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php new file mode 100644 index 00000000..e8ac3de6 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php @@ -0,0 +1,123 @@ +create(['name' => 'Spec347 Ready']); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager'); + $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); + + $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' => false, + 'include_operations' => true, + ], + ]); + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + spec347WorkspaceComponent($user, $environment) + ->assertSee('What is the current review pack output state?') + ->assertSee('Customer-safe review pack ready') + ->assertSee('Customer-safe') + ->assertSee('Download customer-safe review pack') + ->assertSee('PII excluded') + ->assertDontSee('Ready to share'); +}); + +it('shows published-with-limitations when evidence is incomplete even if a pack exists', function (): void { + $environment = ManagedEnvironment::factory()->create(['name' => 'Spec347 Limitations']); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'readonly'); + $snapshot = seedPartialEnvironmentReviewEvidence($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(); + + $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' => false, + 'include_operations' => true, + ], + ]); + $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('Requires review') + ->assertSee('Download review pack with limitations') + ->assertDontSee('Ready to share'); +}); + +it('shows the internal-only workspace state when the export contains pii', function (): void { + $environment = ManagedEnvironment::factory()->create(['name' => 'Spec347 Internal']); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager'); + $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); + + $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, + ], + ]); + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + + spec347WorkspaceComponent($user, $environment) + ->assertSee('Internal review package available') + ->assertSee('Internal only') + ->assertSee('Contains PII') + ->assertSee('Review package contents') + ->assertSee('Download internal review pack') + ->assertDontSee('Customer-safe review pack ready'); +}); + +function spec347WorkspaceComponent(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/Spec347ReviewPackOutputContractTest.php b/apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php new file mode 100644 index 00000000..4c91101e --- /dev/null +++ b/apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php @@ -0,0 +1,101 @@ +generateFromReview($review, $user, [ + 'include_pii' => true, + 'include_operations' => false, + ]); + + app()->call([new GenerateReviewPackJob( + reviewPackId: (int) $pack->getKey(), + operationRunId: (int) $pack->operation_run_id, + ), 'handle']); + + $pack->refresh(); + + [$zip, $tempFile, $filenames] = spec347OpenPackZip($pack); + $metadata = json_decode((string) $zip->getFromName('metadata.json'), true, 512, JSON_THROW_ON_ERROR); + $summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR); + $sections = json_decode((string) $zip->getFromName('sections.json'), true, 512, JSON_THROW_ON_ERROR); + + expect($filenames)->toContain( + 'metadata.json', + 'summary.json', + 'sections.json', + ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME, + )->and(data_get($metadata, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT) + ->and(data_get($metadata, 'delivery_bundle.artifact_family'))->toBe('review_pack') + ->and(data_get($metadata, 'delivery_bundle.review_pack_id'))->toBe((int) $pack->getKey()) + ->and(data_get($metadata, 'delivery_bundle.released_review.id'))->toBe((int) $review->getKey()) + ->and(data_get($metadata, 'delivery_bundle.released_review.status'))->toBe((string) $review->status) + ->and(data_get($metadata, 'delivery_bundle.entrypoint.file'))->toBe(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME) + ->and(data_get($metadata, 'delivery_bundle.entrypoint.role'))->toBe('executive_entrypoint') + ->and(data_get($metadata, 'delivery_bundle.appendix.0.file'))->toBe('metadata.json') + ->and(data_get($metadata, 'environment_review.id'))->toBe((int) $review->getKey()) + ->and(data_get($metadata, 'environment_review.status'))->toBe((string) $review->status) + ->and(data_get($metadata, 'evidence_snapshot.id'))->toBe((int) $review->evidence_snapshot_id) + ->and(data_get($metadata, 'evidence_snapshot.fingerprint'))->toBe((string) $review->evidenceSnapshot?->fingerprint) + ->and(data_get($metadata, 'options.include_pii'))->toBeTrue() + ->and(data_get($metadata, 'options.include_operations'))->toBeFalse() + ->and(data_get($metadata, 'redaction_integrity.protected_values_hidden'))->toBeTrue() + ->and(data_get($summary, 'has_ready_export'))->toBeTrue() + ->and(data_get($summary, 'delivery_bundle.contract'))->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT) + ->and(data_get($summary, 'evidence_basis.completeness_state'))->toBe((string) $review->evidenceSnapshot?->completeness_state) + ->and(data_get($summary, 'output_readiness.has_ready_export'))->toBeTrue() + ->and(data_get($summary, 'output_readiness.contains_pii'))->toBeTrue() + ->and($sections)->toBeArray()->not->toBeEmpty(); + + $firstSection = $sections[0]; + $firstSectionFilename = sprintf( + 'sections/%02d-%s.json', + (int) $firstSection['sort_order'], + (string) $firstSection['section_key'], + ); + $firstSectionFile = json_decode((string) $zip->getFromName($firstSectionFilename), true, 512, JSON_THROW_ON_ERROR); + + expect($filenames)->toContain($firstSectionFilename) + ->and($firstSectionFile['section_key'] ?? null)->toBe($firstSection['section_key']) + ->and($firstSectionFile['title'] ?? null)->toBe($firstSection['title']) + ->and($firstSectionFile['sort_order'] ?? null)->toBe($firstSection['sort_order']) + ->and($firstSectionFile['required'] ?? null)->toBe($firstSection['required']) + ->and($firstSectionFile['completeness_state'] ?? null)->toBe($firstSection['completeness_state']); + + $zip->close(); + unlink($tempFile); +}); + +/** + * @return array{0: ZipArchive, 1: string, 2: list} + */ +function spec347OpenPackZip(ReviewPack $pack): array +{ + $zipContent = Storage::disk('exports')->get((string) $pack->file_path); + $tempFile = tempnam(sys_get_temp_dir(), 'spec347-review-pack-'); + file_put_contents($tempFile, $zipContent); + + $zip = new ZipArchive; + $zip->open($tempFile); + + $filenames = collect(range(0, $zip->numFiles - 1)) + ->map(fn (int $index): string => (string) $zip->getNameIndex($index)) + ->values() + ->all(); + + return [$zip, $tempFile, $filenames]; +} diff --git a/apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php b/apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php new file mode 100644 index 00000000..ab83ecb6 --- /dev/null +++ b/apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php @@ -0,0 +1,111 @@ +generateFromReview($review, $user, [ + 'include_pii' => false, + 'include_operations' => true, + ]); + + app()->call([new GenerateReviewPackJob( + reviewPackId: (int) $pack->getKey(), + operationRunId: (int) $pack->operation_run_id, + ), 'handle']); + + $pack->refresh(); + + [$zip, $tempFile, $filenames] = spec347ReadinessZip($pack); + $summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR); + $sections = collect(json_decode((string) $zip->getFromName('sections.json'), true, 512, JSON_THROW_ON_ERROR)); + $executive = (string) $zip->getFromName(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME); + + $limitedSection = $sections->first(fn (array $section): bool => (string) $section['completeness_state'] !== 'complete'); + + expect($pack->status)->toBe(ReviewPackStatus::Ready->value) + ->and(data_get($summary, 'has_ready_export'))->toBeTrue() + ->and(data_get($summary, 'output_readiness.readiness_state'))->toBe('published_with_limitations') + ->and(data_get($summary, 'output_readiness.evidence_completeness_state'))->toBe((string) $snapshot->completeness_state) + ->and((int) data_get($summary, 'output_readiness.section_summary.required_limited'))->toBeGreaterThan(0) + ->and($limitedSection)->not->toBeNull(); + + $limitedSectionFilename = sprintf( + 'sections/%02d-%s.json', + (int) $limitedSection['sort_order'], + (string) $limitedSection['section_key'], + ); + + expect($filenames)->toContain($limitedSectionFilename) + ->and($executive)->toContain('## Limitations') + ->and($executive)->toContain('incomplete evidence basis') + ->and($executive)->toContain('structured appendices but are marked missing'); + + $zip->close(); + unlink($tempFile); +}); + +it('distinguishes ready export from customer-safe readiness when pii is included', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $review = composeEnvironmentReviewForTest($tenant, $user); + $review = markEnvironmentReviewCustomerSafeReady($review); + + $pack = app(ReviewPackService::class)->generateFromReview($review, $user, [ + 'include_pii' => true, + 'include_operations' => true, + ]); + + app()->call([new GenerateReviewPackJob( + reviewPackId: (int) $pack->getKey(), + operationRunId: (int) $pack->operation_run_id, + ), 'handle']); + + $pack->refresh(); + + [$zip, $tempFile] = spec347ReadinessZip($pack); + $summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR); + $executive = (string) $zip->getFromName(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME); + + expect(data_get($summary, 'has_ready_export'))->toBeTrue() + ->and(data_get($summary, 'output_readiness.readiness_state'))->toBe('internal_review_package_available') + ->and(data_get($summary, 'output_readiness.customer_safe_state'))->toBe('internal_only') + ->and(data_get($summary, 'output_readiness.contains_pii'))->toBeTrue() + ->and($executive)->toContain('PII is included in this package'); + + $zip->close(); + unlink($tempFile); +}); + +/** + * @return array{0: ZipArchive, 1: string, 2: list} + */ +function spec347ReadinessZip(ReviewPack $pack): array +{ + $zipContent = Storage::disk('exports')->get((string) $pack->file_path); + $tempFile = tempnam(sys_get_temp_dir(), 'spec347-readiness-pack-'); + file_put_contents($tempFile, $zipContent); + + $zip = new ZipArchive; + $zip->open($tempFile); + + $filenames = collect(range(0, $zip->numFiles - 1)) + ->map(fn (int $index): string => (string) $zip->getNameIndex($index)) + ->values() + ->all(); + + return [$zip, $tempFile, $filenames]; +} diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php index 0e0ab8d4..8b88325e 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php @@ -40,7 +40,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t it('shows a customer-safe download action when the latest released review pack is ready', function (): void { $tenant = ManagedEnvironment::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); - $snapshot = seedEnvironmentReviewEvidence($tenant); + $snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); $review->forceFill([ @@ -48,6 +48,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t 'published_at' => now(), 'published_by_user_id' => (int) $user->getKey(), ])->save(); + $review = markEnvironmentReviewCustomerSafeReady($review); $pack = ReviewPack::factory()->ready()->create([ 'managed_environment_id' => (int) $tenant->getKey(), @@ -55,6 +56,10 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t 'environment_review_id' => (int) $review->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], 'expires_at' => now()->addDay(), ]); @@ -71,8 +76,9 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t ->assertSee(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review->fresh()], $tenant), false) ->assertSee('Review pack') ->assertSee('Available') - ->assertSee('Current review pack is ready to download.') - ->assertSee('Download review pack') + ->assertSee('The current review package is available and meets the customer-safe output contract.') + ->assertSee('Customer-safe review pack ready') + ->assertSee('Download customer-safe review pack') ->assertSee('source_surface=customer_review_workspace', false) ->assertSee('tenant_filter_id', false) ->assertSee('Open review') @@ -85,7 +91,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t it('keeps the customer review workspace download action visible while suspended read-only', function (): void { $tenant = ManagedEnvironment::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); - $snapshot = seedEnvironmentReviewEvidence($tenant); + $snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); $review->forceFill([ @@ -93,6 +99,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t 'published_at' => now(), 'published_by_user_id' => (int) $user->getKey(), ])->save(); + $review = markEnvironmentReviewCustomerSafeReady($review); $pack = ReviewPack::factory()->ready()->create([ 'managed_environment_id' => (int) $tenant->getKey(), @@ -100,6 +107,10 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t 'environment_review_id' => (int) $review->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], 'expires_at' => now()->addDay(), ]); @@ -116,7 +127,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) ->assertSee(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review->fresh()], $tenant), false) - ->assertSee('Download review pack') + ->assertSee('Download customer-safe review pack') ->assertSee('Open review') ->assertDontSee('Generate pack') ->assertDontSee('Regenerate') @@ -156,7 +167,7 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t it('shows a partial governance-package state when the released review basis is limitation-aware', function (): void { $tenant = ManagedEnvironment::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); - $snapshot = seedPartialEnvironmentReviewEvidence($tenant); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); $review->forceFill([ @@ -171,6 +182,10 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t 'environment_review_id' => (int) $review->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], 'expires_at' => now()->addDay(), ]); @@ -185,9 +200,10 @@ function suspendCustomerReviewWorkspacePackAccessWorkspace(ManagedEnvironment $t Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) ->assertSee(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review->fresh()], $tenant), false) - ->assertSee('Evidence incomplete') - ->assertSee('Review Pack or decision summary may be incomplete.') - ->assertDontSee('Download review pack'); + ->assertSee('Published with limitations') + ->assertSee('The review package is published, but the evidence basis is incomplete.') + ->assertSee('Download review pack with limitations') + ->assertSee('Available'); }); it('shows preparing and unavailable review-pack states without download links', function (): void { diff --git a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php index b0ba79e1..c89e72d6 100644 --- a/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php +++ b/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php @@ -191,7 +191,7 @@ Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) ->assertSee('Customer Review Workspace') - ->assertSee('Is this review ready to share?') + ->assertSee('What is the current review pack output state?') ->assertSee('Review consumption flow') ->assertSee('Evidence') ->assertSee('Findings needing attention') @@ -200,7 +200,6 @@ ->assertSee('Review pack state') ->assertSee('Review pack') ->assertSee('Decision trail') - ->assertSee('Accepted risk records') ->assertSee('Accepted risks') ->assertSee('Expiring soon') ->assertSee('Expired') @@ -219,7 +218,7 @@ ->assertSee('Support details stay on authorized diagnostic surfaces') ->assertSee('Customer acceptance checkpoint') ->assertSee('Open review') - ->assertDontSee('Download review pack') + ->assertSee('Download review pack with limitations') ->assertDontSee('raw payload should stay hidden') ->assertDontSee('stack trace should stay hidden') ->assertDontSee('internal exception should stay hidden') @@ -278,6 +277,10 @@ 'environment_review_id' => (int) $review->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], ]); $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); @@ -288,16 +291,16 @@ Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) - ->assertSee('Is this review ready to share?') - ->assertSee('Shareable with follow-up') + ->assertSee('What is the current review pack output state?') + ->assertSee('Published with limitations') ->assertSee('Accepted-risk follow-up is recorded for this review') ->assertSee('The pack can be shared only with the accepted-risk context included in the customer handoff.') - ->assertSee('Review needed') + ->assertSee('Needs review') ->assertSee('Follow-up required') ->assertSee('Accepted-risk follow-up is required.') ->assertSee('Open review') - ->assertSeeInOrder(['Shareable with follow-up', 'Open review']) - ->assertDontSee('Download review pack') + ->assertSeeInOrder(['Published with limitations', 'Open review']) + ->assertSee('Download review pack with limitations') ->assertDontSee('Ready to share'); }); @@ -331,7 +334,7 @@ it('shows explicit unavailable proof states instead of false share readiness', function (): void { $tenant = ManagedEnvironment::factory()->create(['name' => 'Needs Evidence ManagedEnvironment']); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); - $snapshot = seedPartialEnvironmentReviewEvidence($tenant); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); $review->forceFill([ @@ -346,6 +349,10 @@ 'environment_review_id' => (int) $review->getKey(), 'evidence_snapshot_id' => (int) $snapshot->getKey(), 'initiated_by_user_id' => (int) $user->getKey(), + 'options' => [ + 'include_pii' => false, + 'include_operations' => true, + ], ]); $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); @@ -356,13 +363,13 @@ Livewire::actingAs($user) ->test(CustomerReviewWorkspace::class) - ->assertSee('Is this review ready to share?') - ->assertSee('Follow-up required before sharing') - ->assertSee('Evidence incomplete') + ->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('No operation proof linked') - ->assertSee('Export not ready') + ->assertSee('Export ready') ->assertDontSee('Ready to share') - ->assertDontSee('Download review pack'); + ->assertSee('Download review pack with limitations'); }); it('shows the current released review using deterministic published review ordering', function (): void { @@ -559,7 +566,7 @@ ->assertSee('Accepted risks') ->assertSee('Accepted risk') ->assertSee('Included in the released review evidence basis.') - ->assertSee('Review needed') + ->assertSee('Needs review') ->assertSee('Open review') ->assertDontSee('Ready for release') ->assertSee('Risk Owner') diff --git a/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php b/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php index 21f3a9c2..0e7cf01f 100644 --- a/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php +++ b/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use App\Filament\Pages\ChooseEnvironment; use App\Filament\Pages\EnvironmentDashboard; use App\Filament\Resources\EvidenceSnapshotResource; use App\Models\ManagedEnvironment; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; uses(RefreshDatabase::class); @@ -43,7 +45,56 @@ ->assertDontSee('Choose Onboarding ManagedEnvironment') ->assertDontSee('Choose Archived ManagedEnvironment') ->assertSee(__('localization.shell.choose_environment_description')) - ->assertSee(__('localization.shell.workspace_wide_available_without_environment')); + ->assertSee(__('localization.shell.workspace_wide_available_without_environment')) + ->assertSee('data-testid="choose-environment-search"', false) + ->assertSee('data-testid="choose-environment-add"', false) + ->assertSee('data-testid="choose-environment-switch-workspace"', false) + ->assertDontSee('MANAGED_ENVIRONMENT'); +}); + +it('filters selectable environments without leaking non-matching cards', function (): void { + $alphaEnvironment = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Alpha Production Environment', + 'domain' => 'alpha.example.test', + ]); + [$user, $alphaEnvironment] = createUserWithTenant( + tenant: $alphaEnvironment, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $betaEnvironment = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $alphaEnvironment->workspace_id, + 'name' => 'Beta Sandbox Environment', + 'domain' => 'beta.example.test', + ]); + createUserWithTenant( + tenant: $betaEnvironment, + user: $user, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + Filament::setTenant(null, true); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $alphaEnvironment->workspace_id); + + Livewire::actingAs($user) + ->test(ChooseEnvironment::class) + ->assertSee('Alpha Production Environment') + ->assertSee('Beta Sandbox Environment') + ->set('search', 'beta') + ->assertSet('search', 'beta') + ->assertSee('Beta Sandbox Environment') + ->assertSee('beta.example.test') + ->assertDontSee('Alpha Production Environment') + ->assertSee(__('localization.shell.environment_search_results_count', ['visible' => 1, 'total' => 2])) + ->set('search', 'missing-environment') + ->assertSee(__('localization.shell.no_environment_search_results')) + ->assertDontSee('Alpha Production Environment') + ->assertDontSee('Beta Sandbox Environment'); }); it('shows a workspace-safe empty state when no selectable tenants remain', function (): void { diff --git a/apps/platform/tests/Pest.php b/apps/platform/tests/Pest.php index f7d67cd7..dac52eca 100644 --- a/apps/platform/tests/Pest.php +++ b/apps/platform/tests/Pest.php @@ -6,6 +6,7 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\EnvironmentReview; +use App\Models\EnvironmentReviewSection; use App\Models\EvidenceSnapshot; use App\Models\Finding; use App\Models\ManagedEnvironment; @@ -24,8 +25,10 @@ use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Graph\GraphClientInterface; use App\Services\Tenants\TenantActionPolicySurface; +use App\Support\EnvironmentReviewCompletenessState; use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; +use App\Support\Governance\Controls\ComplianceEvidenceMappingV1; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; @@ -1333,6 +1336,150 @@ function composeEnvironmentReviewForTest(ManagedEnvironment $tenant, User $user, return $review->refresh(); } +function markEnvironmentReviewCustomerSafeReady(EnvironmentReview $review): EnvironmentReview +{ + $review->loadMissing(['sections', 'evidenceSnapshot.items']); + + $disclosure = 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.'; + $controlSummary = [ + 'control_key' => 'customer-output', + 'control_name' => 'Customer output', + 'domain_key' => 'customer_delivery', + 'readiness_bucket' => 'evidence_on_record', + 'readiness_label' => 'Evidence on record', + 'limitation_flags' => [], + 'limitation_labels' => [], + 'customer_summary' => 'Customer output has evidence on record in this released review.', + 'evidence_basis_summary' => '1 evidence signal references this control.', + 'accepted_risk_summary' => null, + 'recommended_next_action' => 'Open the current customer review pack.', + 'detail_anchor' => 'control-customer-output', + 'supporting_finding_ids' => [], + 'finding_count' => 0, + 'open_finding_count' => 0, + 'accepted_risk_count' => 0, + ]; + $controlExplanation = [ + 'title' => 'Customer output', + 'control_key' => 'customer-output', + 'control_name' => 'Customer output', + 'readiness_bucket' => 'evidence_on_record', + 'readiness_label' => 'Evidence on record', + 'limitation_flags' => [], + 'limitation_labels' => [], + 'customer_summary' => 'Customer output has evidence on record in this released review.', + 'evidence_basis_summary' => '1 evidence signal references this control.', + 'accepted_risk_summary' => null, + 'explanation_text' => 'Customer output has evidence on record in this released review.', + 'evidence_basis_items' => [ + '1 evidence signal references this control.', + ], + 'accepted_risk_context' => null, + 'recommended_next_action' => 'Open the current customer review pack.', + 'proof_access_state' => 'available', + 'supporting_finding_ids' => [], + ]; + + $snapshot = $review->evidenceSnapshot; + + if ($snapshot instanceof EvidenceSnapshot) { + $snapshot->items->each(function ($item): void { + $item->forceFill([ + 'state' => EnvironmentReviewCompletenessState::Complete->value, + ])->save(); + }); + + $snapshotSummary = is_array($snapshot->summary) ? $snapshot->summary : []; + $snapshotSummary['missing_dimensions'] = 0; + $snapshotSummary['stale_dimensions'] = 0; + $snapshotSummary['dimensions'] = collect($snapshotSummary['dimensions'] ?? []) + ->map(static function (mixed $dimension): mixed { + if (! is_array($dimension)) { + return $dimension; + } + + $dimension['state'] = EnvironmentReviewCompletenessState::Complete->value; + + return $dimension; + }) + ->values() + ->all(); + + $snapshot->forceFill([ + 'completeness_state' => EnvironmentReviewCompletenessState::Complete->value, + 'summary' => $snapshotSummary, + ])->save(); + } + + $review->sections->each(function (EnvironmentReviewSection $section) use ($controlSummary, $controlExplanation, $disclosure): void { + $attributes = [ + 'completeness_state' => EnvironmentReviewCompletenessState::Complete->value, + ]; + + if ($section->isControlInterpretation()) { + $attributes['summary_payload'] = array_replace_recursive([ + 'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY, + 'display_label' => 'Compliance evidence mapping v1', + 'non_certification_disclosure' => $disclosure, + 'mapped_control_count' => 1, + 'follow_up_required_count' => 0, + 'limitation_counts' => [], + 'limitations' => [], + ], is_array($section->summary_payload) ? $section->summary_payload : []); + $attributes['render_payload'] = array_replace_recursive([ + 'entries' => [$controlExplanation], + 'disclosure' => $disclosure, + 'next_actions' => ['Open the current customer review pack.'], + 'empty_state' => null, + ], is_array($section->render_payload) ? $section->render_payload : []); + } + + $section->forceFill($attributes)->save(); + }); + + $sectionCount = $review->sections->count(); + $summary = is_array($review->summary) ? $review->summary : []; + $existingControlInterpretation = is_array($summary['control_interpretation'] ?? null) + ? $summary['control_interpretation'] + : []; + $existingControls = collect($existingControlInterpretation['controls'] ?? []) + ->filter(static fn (mixed $control): bool => is_array($control)) + ->values(); + + $summary['control_interpretation'] = array_replace_recursive([ + 'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY, + 'display_label' => 'Compliance evidence mapping v1', + 'non_certification_disclosure' => $disclosure, + 'mapped_control_count' => 1, + 'follow_up_required_count' => 0, + 'limitation_counts' => [], + 'limitations' => [], + 'controls' => [$controlSummary], + ], $existingControlInterpretation); + + $summary['control_interpretation']['controls'] = [ + array_replace( + $controlSummary, + is_array($existingControls->first()) ? $existingControls->first() : [], + ), + ]; + $summary['publish_blockers'] = []; + $summary['section_count'] = $sectionCount; + $summary['section_state_counts'] = [ + 'complete' => $sectionCount, + 'partial' => 0, + 'missing' => 0, + 'stale' => 0, + ]; + + $review->forceFill([ + 'completeness_state' => EnvironmentReviewCompletenessState::Complete->value, + 'summary' => $summary, + ])->save(); + + return $review->refresh(); +} + /** * @param array $summaryOverrides */ 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 69052d5c..51b04463 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 @@ -48,3 +48,28 @@ ## Top Issues ## Target Direction Spec 344 implements the first density/hierarchy polish wave. If the surface still feels too dense after real operator use, follow up with a targeted mockup and a second, narrower polish pass rather than adding new workflow surfaces. + +## Spec 347 Follow-up + +Spec 347 hardens the Review Pack output contract and aligns the workspace with the review-pack ZIP semantics instead of collapsing everything into a generic "ready" claim. + +- Decision-card status is now contract-backed and qualified: + - `Kundensicheres Review-Paket bereit` / `Customer-safe review pack ready` + - `Veröffentlicht mit Einschränkungen` / `Published with limitations` + - `Internes Review-Paket verfügbar` / `Internal review package available` + - `Export nicht bereit` / `Export not ready` +- Review-pack proof now exposes evidence basis state, section completeness, sharing boundary, PII visibility, protected-values status, disclosure presence, and operation proof in one bounded panel. +- Download labels are qualified by the same readiness contract instead of implying customer-safe sharing when evidence or section completeness is incomplete. +- The workspace continues to keep diagnostics collapsed and secondary. + +### Browser proof + +- Spec347 screenshots: `specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/` +- Verified states: + - customer-safe ready + - published with limitations + - internal-only / PII-bearing export + +### 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. diff --git a/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/01-customer-safe-ready.png b/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/01-customer-safe-ready.png new file mode 100644 index 00000000..08c44777 Binary files /dev/null and b/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/01-customer-safe-ready.png differ diff --git a/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/02-published-with-limitations.png b/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/02-published-with-limitations.png new file mode 100644 index 00000000..b3650f26 Binary files /dev/null and b/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/02-published-with-limitations.png differ diff --git a/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/03-internal-review-package.png b/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/03-internal-review-package.png new file mode 100644 index 00000000..e99e5bc9 Binary files /dev/null and b/specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/03-internal-review-package.png differ diff --git a/specs/347-review-pack-output-contract-readiness-semantics/checklists/requirements.md b/specs/347-review-pack-output-contract-readiness-semantics/checklists/requirements.md new file mode 100644 index 00000000..82b75d41 --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/checklists/requirements.md @@ -0,0 +1,68 @@ +# Requirements Checklist: Spec 347 - Review Pack Output Contract & Readiness Semantics + +**Purpose**: Preparation analysis for Spec 347 readiness +**Created**: 2026-06-02 +**Feature**: `specs/347-review-pack-output-contract-readiness-semantics/spec.md` + +## Candidate Selection And Guardrails + +- [x] CHK001 The candidate source is explicit: direct user-provided Spec 347 draft plus roadmap/spec-candidate alignment. +- [x] CHK002 No `specs/347-*` package existed before this prep. +- [x] CHK003 Related completed specs are treated as historical context only: 109, 308, 312, 337, 342, 343, and 344. +- [x] CHK004 Active Spec 346 is treated as adjacent context only and is not rewritten or normalized by this prep. +- [x] CHK005 The selected slice is narrow and reviewable: output contract, readiness semantics, workspace wording, tests, and disclosure only. + +## Required Prep Artifacts + +- [x] CHK006 `spec.md` exists and contains no placeholder template sections. +- [x] CHK007 `plan.md` exists and is repo-aware. +- [x] CHK008 `tasks.md` exists and is ordered, small, and verifiable. +- [x] CHK009 `repo-truth-map.md` exists. +- [x] CHK010 `contracts/review-pack-output-contract.md` exists. +- [x] CHK011 `contracts/readiness-semantics.md` exists. +- [x] CHK012 `contracts/customer-safe-output-boundary.md` exists. +- [x] CHK013 This checklist exists. + +## Spec Quality + +- [x] CHK014 Spec Candidate Check is completed. +- [x] CHK015 The spec distinguishes repo truth from the user draft, including the current `sections/` file layout and the existing `auditor_ready_executive_export.v1` contract. +- [x] CHK016 The spec states clear goals, non-goals, requirements, risks, assumptions, and acceptance criteria. +- [x] CHK017 The spec keeps readiness semantics derived-only and forbids new persisted readiness truth by default. +- [x] CHK018 The spec includes a proportionality review for the possible bounded readiness mapper. + +## Plan / Task Alignment + +- [x] CHK019 The plan identifies the actual repo surfaces likely to change. +- [x] CHK020 The plan explicitly preserves signed-download safety. +- [x] CHK021 The plan explicitly keeps Filament v5 / Livewire v4 posture and provider registration location visible. +- [x] CHK022 The task list includes tests-first work and explicit runtime validation commands. +- [x] CHK023 The task list keeps scope bounded and includes non-goal guardrails against portal/rewrite/persistence creep. + +## UI / Productization Coverage + +- [x] CHK024 UI Surface Impact is completed and does not claim no-impact. +- [x] CHK025 UI/Productization Coverage is completed for the existing strategic customer-safe workspace surface. +- [x] CHK026 The plan and spec point to the existing UI audit page report `ui-006-customer-review-workspace.md` instead of inventing a new identity. +- [x] CHK027 Audience-aware disclosure and no-false-ready/certification boundaries are explicit. + +## Test Governance + +- [x] CHK028 The declared test families are the narrowest honest proof: Feature plus one bounded Browser smoke. +- [x] CHK029 New test file paths are specified. +- [x] CHK030 Existing Review Pack and Customer Review Workspace regression commands are included. +- [x] CHK031 No broad new heavy-governance family is introduced. + +## Readiness Gate Outcome + +- [x] CHK032 Candidate Selection Gate passes. +- [x] CHK033 Spec Readiness Gate passes. +- [x] CHK034 Runtime implementation has not started in this preparation step. +- [x] CHK035 Recommended next step is implementation, not additional prep. + +## Review Outcome + +- [x] Outcome class: acceptable-special-case +- [x] Workflow outcome: keep +- [x] Final note location: active feature PR close-out entry `Guardrail / Smoke Coverage` +- [x] Preparation analyze result: pass via repo-based artifact review checklist; no standalone local `speckit.analyze` command was available in this repo surface diff --git a/specs/347-review-pack-output-contract-readiness-semantics/contracts/customer-safe-output-boundary.md b/specs/347-review-pack-output-contract-readiness-semantics/contracts/customer-safe-output-boundary.md new file mode 100644 index 00000000..ff694143 --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/contracts/customer-safe-output-boundary.md @@ -0,0 +1,110 @@ +# Customer-Safe Output Boundary + +Status: prepared +Created: 2026-06-02 +Scope: Derived customer-safe vs internal-only boundary for review-derived Review Pack output + +This document defines what Spec 347 may present as customer-safe, internal-only, or limitations-bearing output. + +## 1. Boundary Principles + +- customer-safe is a derived presentation boundary, not a new persisted entity +- internal-only is a derived presentation boundary, not a new persisted entity +- PII visibility must be explicit +- redaction visibility must be explicit +- diagnostics and raw/support detail remain secondary or hidden on default customer-safe paths +- non-certification disclosure is mandatory + +## 2. Customer-Safe Minimum Conditions + +A package may be described as customer-safe only when repo truth supports all of: + +1. released review exists +2. current package artifact exists and is valid for download/use +3. evidence basis is not silently incomplete +4. required section limitations are either absent or clearly disclosed +5. no internal-only/raw/support detail is exposed by default +6. PII handling is explicit +7. redaction integrity is explicit +8. non-certification disclosure is present + +If any of these are missing or ambiguous, the package must use a more conservative label. + +## 3. Internal Package Conditions + +A package should be treated as internal or review-required when any of these are true: + +- evidence basis is incomplete, stale, or missing +- required sections are incomplete and limitations are material +- `include_pii` is true and external sharing still requires operator review +- export exists but customer-safe readiness is not fully supported by repo truth +- current wording would otherwise overclaim confidence + +## 4. PII And Redaction Rules + +Current repo truth already provides: + +- `options.include_pii` +- `redaction_integrity.protected_values_hidden` + +Spec 347 must make these visible in the output contract and/or workspace rendering. + +Rules: + +- `include_pii=true` must never be invisible on the operator decision path +- `protected_values_hidden=true` must be visible as redaction integrity context where relevant +- PII included does not automatically forbid sharing, but it does require explicit operator awareness + +## 5. Disclosure Rules + +Customer-safe default paths must show: + +- what the package is +- what evidence basis it uses +- whether limitations exist +- whether PII is included +- what the next action is + +Customer-safe default paths must not show by default: + +- raw JSON +- raw payloads +- stack traces +- support-only diagnostics +- fingerprints unless explicitly needed and safe +- internal reason ownership or platform debug semantics + +## 6. Download Boundary + +Current signed downloads remain valid proof/artifact delivery. + +Spec 347 must not treat download availability alone as sufficient proof of customer-safe readiness. + +Download labeling should therefore distinguish between: + +- customer-safe ready package +- package with limitations +- internal package + +## 7. Executive Summary Boundary + +The executive summary must: + +- include a limitations note when the package is not clearly customer-safe ready +- retain non-certification disclosure +- avoid raw/internal-only detail + +It may describe: + +- published review +- evidence basis +- top findings +- accepted risks +- governance decisions +- next actions + +It must not imply: + +- certification +- legal attestation +- guaranteed compliance diff --git a/specs/347-review-pack-output-contract-readiness-semantics/contracts/readiness-semantics.md b/specs/347-review-pack-output-contract-readiness-semantics/contracts/readiness-semantics.md new file mode 100644 index 00000000..01cecd4c --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/contracts/readiness-semantics.md @@ -0,0 +1,176 @@ +# Readiness Semantics + +Status: prepared +Created: 2026-06-02 +Scope: Derived readiness vocabulary for review-derived Review Pack output and Customer Review Workspace mapping + +This contract defines derived semantics only. It does not introduce a new persisted state family. + +## 1. Distinct Truth Dimensions + +The following must remain distinct: + +- review publication state +- review completeness state +- evidence completeness state +- section completeness state +- export artifact availability +- output-readiness / limitations state +- customer-safe vs internal-only boundary + +None of these may silently stand in for the others. + +## 2. Current Repo-Backed Inputs + +Current repo truth already exposes inputs for derived readiness: + +- `EnvironmentReview.status` +- `EnvironmentReview.completeness_state` +- `EnvironmentReview.summary.publish_blockers` +- `EnvironmentReview.summary.section_state_counts` +- `EnvironmentReview.summary.has_ready_export` +- `EvidenceSnapshot.completeness_state` +- `ReviewPack.status` +- `ReviewPack.file_path` +- `ReviewPack.file_disk` +- `ReviewPack.expires_at` +- `ReviewPack.options.include_pii` + +## 3. Semantic Rules + +### 3.1 Published + +`published` means: + +- the environment review has been released/published + +It does **not** automatically mean: + +- evidence complete +- export ready +- customer-safe ready +- limitation-free + +### 3.2 Review Completeness + +`review_completeness_state` means: + +- review composition truth according to current review logic + +It does **not** automatically mean: + +- every required section is complete +- evidence basis is complete +- the generated package is ready to share + +### 3.3 Evidence Completeness + +Evidence completeness means: + +- the anchored evidence basis behind the released review + +If evidence completeness is partial, stale, or missing: + +- a package may still exist +- the output must be labeled as limited or review-required +- unqualified share-ready wording is forbidden + +### 3.4 Section Completeness + +Section completeness describes: + +- whether the required source truth for that section is complete enough + +It does **not** describe: + +- whether a section-detail file exists + +Therefore: + +- a detail file may exist while completeness is `missing` +- the UI and executive summary must explain that the section structure exists but the source basis is incomplete + +### 3.5 Ready Export + +`has_ready_export` means: + +- the current review summary believes a ready export artifact is available + +It does **not** automatically mean: + +- customer-safe ready +- PII-free +- no limitations + +### 3.6 Customer-Safe + +Customer-safe readiness is derived only when repo truth supports all of: + +- released review exists +- evidence basis is sufficiently complete for the current contract +- required section limitations are visible or absent +- non-certification disclosure is present +- no internal-only/raw/support detail is exposed by default +- PII/redaction state is explicit + +If those conditions are not met, the package may still be: + +- available with limitations +- internal package available +- export not ready +- review required before sharing + +## 4. Preferred Derived Vocabulary + +Presentation labels remain derived, not persisted. + +Preferred vocabulary: + +- `Customer-safe review pack ready` +- `Published with limitations` +- `Internal review package available` +- `Export not ready` +- `Evidence basis incomplete` +- `Required sections incomplete` +- `Contains PII` +- `Protected values hidden` +- `Review limitations before customer sharing` + +Discouraged vocabulary unless the contract truly proves it: + +- `Ready to share` +- `Customer-ready` +- `Auditor-ready` +- `Certified` +- `Compliant` + +## 5. Qualified Download Semantics + +Download affordances should follow contract-backed semantics: + +- `Download customer-safe review pack` only when the contract truly supports it +- `Download review pack with limitations` when the export exists but is limited +- `Download internal review pack` when the package is useful but not clearly customer-safe + +The implementation may keep shorter wording only if the same state is clearly explained adjacent to the action. + +## 6. Limitations Trigger Conditions + +An explicit limitations state is required when any of these are true: + +- evidence completeness is not complete +- required sections are partial, stale, blocked, or missing +- `has_ready_export` is false +- PII is included and customer-safe external sharing still needs review +- publish blockers are present + +## 7. Explicit Non-Claims + +No readiness label may imply: + +- certification +- legal attestation +- full audit opinion +- guaranteed compliance + +Those remain forbidden unless a future spec intentionally introduces that product truth. diff --git a/specs/347-review-pack-output-contract-readiness-semantics/contracts/review-pack-output-contract.md b/specs/347-review-pack-output-contract-readiness-semantics/contracts/review-pack-output-contract.md new file mode 100644 index 00000000..9b573085 --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/contracts/review-pack-output-contract.md @@ -0,0 +1,162 @@ +# Review Pack Output Contract + +Status: prepared +Created: 2026-06-02 +Scope: Review-derived Review Pack ZIP contract + +This document defines the current contract that Spec 347 is allowed to harden. It is intentionally repo-based and preserves the current review-derived ZIP shape unless implementation proves a narrower correction is required. + +## 1. Required Root Files + +A valid review-derived Review Pack ZIP must contain these root files: + +- `executive-summary.md` +- `metadata.json` +- `summary.json` +- `sections.json` + +These files already exist in current repo truth and remain the baseline contract. + +## 2. Section-Detail Files + +Current repo truth generates one JSON detail file per included review section under: + +- `sections/%02d-%s.json` + +Examples under current section ordering: + +- `sections/10-executive_summary.json` +- `sections/15-control_interpretation.json` +- `sections/20-open_risks.json` +- `sections/30-accepted_risks.json` +- `sections/40-permission_posture.json` +- `sections/50-baseline_drift_posture.json` +- `sections/60-operations_health.json` + +Important: + +- the current repo truth uses a `sections/` directory, not root-level numbered files +- section-detail files may exist even when the corresponding section completeness is `missing` +- `sections.json` is the canonical section index unless implementation safely promotes more keys into each detail file + +## 3. Required Metadata Fields + +`metadata.json` must expose at least: + +- `version` +- `generated_at` +- `delivery_bundle.contract` +- `delivery_bundle.artifact_family` +- `delivery_bundle.review_pack_id` +- `delivery_bundle.released_review.id` +- `delivery_bundle.released_review.status` +- `delivery_bundle.released_review.completeness_state` +- `delivery_bundle.evidence_basis.snapshot_id` +- `delivery_bundle.evidence_basis.snapshot_fingerprint` +- `delivery_bundle.evidence_basis.completeness_state` +- `delivery_bundle.entrypoint.file` +- `delivery_bundle.entrypoint.role` +- `delivery_bundle.appendix[]` +- `environment_review.id` +- `environment_review.status` +- `environment_review.completeness_state` +- `evidence_snapshot.id` +- `evidence_snapshot.fingerprint` +- `evidence_snapshot.completeness_state` +- `options.include_pii` +- `options.include_operations` +- `redaction_integrity.protected_values_hidden` + +If implementation keeps the current contract constant and current file layout, missing fields should be added in place rather than by introducing a parallel contract layer. + +## 4. Required Summary Fields + +`summary.json` must expose at least: + +- `environment_review_id` +- `review_status` +- `review_completeness_state` +- `evidence_resolution` +- `section_count` +- `section_state_counts` +- `publish_blockers` +- `delivery_bundle` +- `governance_package` + +Strongly preferred for explicit readiness mapping: + +- `has_ready_export` +- any derived label or reason fields only if they remain clearly derived and non-canonical + +## 5. Required Section Fields + +Every entry in `sections.json` must expose: + +- `section_key` +- `title` +- `sort_order` +- `required` +- `completeness_state` +- `summary_payload` +- `render_payload` + +Each section completeness state must remain derived from repo-backed review section truth. + +Current section-detail files already expose: + +- `title` +- `completeness_state` +- `summary_payload` +- `render_payload` + +Spec 347 implementation must choose one of two valid contracts: + +1. **Promoted detail-file contract** + Add `section_key`, `required`, and `sort_order` to each detail file. + +2. **Canonical-index contract** + Keep `sections.json` as the canonical index and explicitly document that detail files are subordinate payloads keyed by filename plus `sections.json`. + +## 6. File-To-Section Consistency Rules + +For every section listed in `sections.json`: + +- a corresponding detail file may exist under `sections/` +- if the detail file exists, its title and completeness state must not contradict `sections.json` +- if a section is marked `missing`, the detail file may still exist +- `missing` refers to source/evidence completeness, not to file absence by default + +The implementation must not leave this semantics implicit. + +## 7. Executive Entrypoint Rules + +`executive-summary.md` is the human entrypoint and must: + +- state review status +- state evidence-basis context +- include limitations when sharing is constrained +- include non-certification disclosure +- point to the structured appendix (`metadata.json`, `summary.json`, `sections.json`) + +It must not: + +- imply certification +- imply legal attestation +- imply guaranteed compliance +- leak raw/internal-only diagnostics + +## 8. Backward-Compatibility Posture + +This repo is still pre-production lean. + +Therefore Spec 347 favors: + +- hardening current file shapes in place +- documenting repo-truth deviations explicitly +- avoiding compatibility shims or parallel ZIP layouts + +It does not favor: + +- dual root-vs-sections layouts +- legacy alias files +- broad export-version migration machinery diff --git a/specs/347-review-pack-output-contract-readiness-semantics/plan.md b/specs/347-review-pack-output-contract-readiness-semantics/plan.md new file mode 100644 index 00000000..391a6741 --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/plan.md @@ -0,0 +1,313 @@ +# Implementation Plan: Spec 347 - Review Pack Output Contract & Readiness Semantics + +**Branch**: `347-review-pack-output-contract-readiness-semantics` | **Date**: 2026-06-02 | **Spec**: `specs/347-review-pack-output-contract-readiness-semantics/spec.md` +**Input**: User-provided Spec 347 draft + repo truth from current review-derived Review Pack generation and Customer Review Workspace readiness behavior. + +## Summary + +Harden the current review-derived Review Pack output without rewriting the export pipeline. + +This slice should: + +- document the current file contract +- reconcile review/evidence/section/export/customer-safe semantics +- tighten ZIP payload consistency where fields are missing or ambiguous +- add explicit limitations/disclosure output +- replace unqualified workspace sharing language when repo truth says the package is limited + +This slice must not: + +- rebuild Review Pack generation +- add persistence +- add a portal +- change signed-download safety +- create a new workflow engine + +## 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 generated ZIP artifacts +- **Performance Goals**: no new remote calls during render, no new queue family, deterministic ZIP contract and deterministic derived readiness mapping +- **Constraints**: no false customer-safe/export-ready/certification claims; no weakened signed-download controls; no revived legacy file layout or query aliases; keep diagnostics secondary +- **Scale/Scope**: one existing ZIP contract, one existing strategic workspace page, targeted Feature coverage, one Browser smoke, and spec-local contract docs + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: material change to an existing strategic customer-safe review surface plus existing output artifact semantics +- **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` + - review-derived ZIP output generated by `apps/platform/app/Jobs/GenerateReviewPackJob.php` + - workspace download wording and signed download affordance only; Review Pack Resource detail/header copy stays out of scope unless repo truth reveals a direct contradiction that cannot be fixed on the workspace surface +- **No-impact class, if applicable**: N/A +- **Native vs custom classification summary**: native Filament page plus existing Blade composition; existing export ZIP contract; no new route or panel/provider +- **Shared-family relevance**: evidence/review/export readiness labels, disclosure language, download affordances, proof links +- **State layers in scope**: page payload, artifact payload, signed-download label/copy on the workspace surface only +- **Audience modes in scope**: operator-MSP, customer-safe review consumer, support where authorized +- **Decision/diagnostic/raw hierarchy plan**: readiness and limitations first, proof second, diagnostics third +- **Raw/support gating plan**: diagnostics collapsed/secondary; raw/support details remain hidden on customer-safe default paths +- **One-primary-action / duplicate-truth control**: one dominant next action on the workspace decision card; lower sections add proof rather than restating the verdict +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory because this is a strategic output boundary and trust surface +- **Special surface test profiles**: `global-context-shell` + customer-safe strategic review surface + artifact contract +- **Required tests or manual smoke**: functional-core + browser smoke +- **Exception path and spread control**: one bounded readiness mapper is allowed; no generic review-output 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`; do not create a new page-report identity unless implementation proves necessary +- **Coverage artifacts to update**: existing workspace page report only, unless route/archetype truth changes +- **Navigation / Filament provider-panel handling**: N/A; no panel/provider change expected +- **Screenshot or page-report need**: yes, one page-report update plus bounded screenshots because this is a strategic customer-safe surface + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: + - `EnvironmentReviewComposer` + - `GenerateReviewPackJob` + - `ReviewPackService` + - `CustomerReviewWorkspace` + - existing Review Pack download and artifact proof surfaces +- **Shared abstractions reused**: + - current `delivery_bundle` metadata and summary payloads + - current `governance_package` review summary + - current `ArtifactTruthPresenter` + - current download/audit flow +- **New abstraction introduced? why?**: maybe one narrow readiness mapper or presenter if current page-local heuristics and ZIP payload logic need a single source for derived output-readiness labels +- **Why the existing abstraction was sufficient or insufficient**: existing structures already carry most facts, but they do not yet define one coherent output-readiness vocabulary or one authoritative contract between ZIP and workspace UI +- **Bounded deviation / spread control**: keep any new mapper local to review-pack output semantics; do not create a broad governance-output layer + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: existing proof linkage and audit/download context only +- **Central contract reused**: existing operation proof links and current generation lifecycle +- **Delegated UX behaviors**: unchanged +- **Surface-owned behavior kept local**: limitations copy and qualified next-action selection +- **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**: review output contract, customer-safe sharing boundary, export readiness semantics +- **Neutral platform terms / contracts preserved**: review pack, evidence basis, limitations, customer-safe, internal package, output contract +- **Retained provider-specific semantics and why**: only in existing stored report or review content where already repo-backed +- **Bounded extraction or follow-up path**: none + +## Current Repo Truth Summary + +- `EnvironmentReviewComposer` already derives: + - `summary.evidence_basis` + - `summary.section_state_counts` + - `summary.publish_blockers` + - `summary.has_ready_export` (initially `false`) + - `summary.governance_package` +- `GenerateReviewPackJob` already writes review-derived ZIP files: + - `metadata.json` + - `summary.json` + - `sections.json` + - `executive-summary.md` + - one file per section under `sections/` +- `GenerateReviewPackJob` already updates: + - `ReviewPack.summary.review_status` + - `ReviewPack.summary.review_completeness_state` + - `ReviewPack.summary.delivery_bundle` + - `ReviewPack.summary.evidence_resolution` + - `EnvironmentReview.summary.has_ready_export = true` after successful generation +- `CustomerReviewWorkspace` currently derives share-readiness from: + - accepted-risk follow-up + - open findings + - package availability + - evidence availability + - mapped review data + - not from a dedicated output-readiness contract +- Current gaps: + - section-detail files do not currently repeat `section_key`, `required`, or `sort_order` + - executive summary does not have a dedicated limitations block + - workspace UI does not surface `include_pii` or redaction state + - workspace UI does not consume `has_ready_export` as a first-class output-readiness input + - current ready/share labels are stronger than the current explicit bundle contract + - existing executive-pack and localization regressions were not yet listed in the original Spec 347 validation matrix even though this slice is expected to change those surfaces + +## Implementation Approach + +### Phase 0 - Repo Truth Gate + +1. Re-read this spec, plan, tasks, repo-truth map, and contract docs before runtime changes. +2. Re-check current generator and workspace files before editing: + - `apps/platform/app/Jobs/GenerateReviewPackJob.php` + - `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php` + - `apps/platform/app/Services/ReviewPackService.php` + - `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` + - `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` + - `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php` +3. Keep `specs/347-review-pack-output-contract-readiness-semantics/repo-truth-map.md` current if runtime edits reveal additional truth or limitations. +4. Preserve repo-truth deviations from the user draft explicitly: + - section-detail files live under `sections/` + - current page report identity is `ui-006-customer-review-workspace.md` + +### Phase 1 - Contract Docs First + +1. Finalize spec-local contract docs before runtime edits: + - `contracts/review-pack-output-contract.md` + - `contracts/readiness-semantics.md` + - `contracts/customer-safe-output-boundary.md` +2. Make the contracts explicit about: + - file layout + - required fields + - meaning of `missing` + - evidence vs export vs customer-safe vs internal-only states + - PII/redaction visibility +3. Treat those docs as the runtime review checklist, not as parallel product logic. + +### Phase 2 - Tests First + +1. Add focused contract tests: + - `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php` + - `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php` + - `apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php` + - `apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php` +2. Reuse existing helpers and extend current review-pack/customer-review tests only where proportional. +3. Re-run existing regressions that already pin the executive entrypoint, workspace wording, browser readiness path, and localization: + - `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php` + - `apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php` + - `apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php` + - `apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php` +4. Lock the following before runtime refactor: + - required root files + - required metadata/summary fields + - section/file consistency + - limitations/disclosure + - no false share-ready labels + - PII visibility + - preserved signed-download safety + +### Phase 3 - Derived Output-Readiness Mapper + +1. Choose the narrowest home for derived output readiness: + - page-local helper if truly local + - small support-layer mapper only if both ZIP contract and workspace rendering must share it +2. Derive, do not persist: + - readiness label + - primary reason + - primary action + - evidence basis state + - section completeness summary + - customer-safe/internal-only/limitations state + - PII/redaction visibility +3. Prefer reuse of current review summary and pack summary fields over new fields. +4. Only add new payload keys where current structures genuinely cannot express the needed contract. + +### Phase 4 - ZIP Contract Hardening + +1. Keep the current required root files and current `sections/` detail-file placement unless runtime proof forces a narrower change. +2. Tighten `metadata.json` so required bundle/review/evidence/options/redaction fields are always present. +3. Tighten `summary.json` so readiness-related fields are explicit and stable. +4. Decide the canonical section contract: + - either add `section_key`, `required`, and `sort_order` to each section-detail file + - or keep `sections.json` as the canonical section index and document the detail files as thinner subordinate payloads +5. Preserve the existing delivery contract constant unless a repo-justified contract-version bump is necessary. + +### Phase 5 - Executive Summary Hardening + +1. Add an explicit `## Limitations` block whenever output readiness is limited by: + - evidence completeness + - required section completeness + - export readiness + - PII/internal-only boundary +2. Keep the existing non-certification disclosure visible. +3. Avoid raw/internal-only detail in the markdown entrypoint. +4. Ensure the executive summary explains that a section file may exist even when the section is marked `missing`. + +### Phase 6 - Customer Review Workspace Remap + +1. Replace unqualified share-ready language with contract-backed labels when the output contract is incomplete. +2. Surface: + - evidence basis state + - section completeness summary + - PII/redaction visibility + - qualified customer-safe/internal-only/limitations state +3. Keep one dominant next action: + - review limitations + - open review + - qualified download +4. Keep diagnostics collapsed and secondary. +5. Do not redesign the page beyond bounded readiness/disclosure hardening. + +### Phase 7 - Copy, Audit, And Browser Proof + +1. Update `apps/platform/lang/en/localization.php` and `apps/platform/lang/de/localization.php` only for the new qualified readiness vocabulary needed by repo truth. +2. Update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md` with: + - output contract summary + - readiness label mapping + - limitations and PII visibility expectations + - deferred follow-ups + Review Pack Resource detail/header coverage is not part of this slice unless a minimal contradiction fix becomes unavoidable. +3. Capture screenshots under `specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/`. +4. Use one bounded browser smoke proving: + - limitations-bearing package + - qualified download label + - no unqualified share-ready claim + - PII warning where repo-backed + +### Phase 8 - Validation And Close-Out + +1. Run the narrowest focused Feature and Browser commands. +2. Re-run overlapping regressions for `ReviewPack` and `CustomerReviewWorkspace`. +3. Run `pint --dirty` and `git diff --check`. +4. Record unrelated failures honestly if broader regressions are not green. +5. Do not widen scope into portal, lifecycle-governance, or localization-wide cleanup. + +## Deployment / Ops Impact + +- **Env vars**: none expected +- **Migrations**: none expected +- **Queues/scheduler**: no new queue family or scheduler change expected +- **Storage/volumes**: existing exports disk only; no storage topology change expected +- **Filament assets**: none expected; if any registered Filament assets unexpectedly appear, deployment must include `cd apps/platform && php artisan filament:assets` + +## Filament / Laravel Guardrails + +- **Livewire v4 compliance**: required; no Livewire v3 APIs +- **Panel provider registration**: remains `apps/platform/bootstrap/providers.php` +- **Global search**: no resource global-search change is expected +- **Destructive/high-impact actions**: no new destructive action is planned. Existing regenerate/expire actions remain governed by current confirmation, authorization, and audit rules if touched at all. +- **Asset strategy**: no new panel asset strategy expected + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature for ZIP contract and workspace state mapping; Browser for first-screen readiness wording and qualified download proof +- **Affected validation lanes**: confidence + browser +- **Why this lane mix is the narrowest sufficient proof**: file contracts and workspace rendering are deterministic and can be proven with focused tests; one browser path covers the strategic customer-safe trust surface +- **Narrowest proving command(s)**: + +```bash +cd apps/platform +./vendor/bin/sail artisan test tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php --compact +./vendor/bin/sail artisan test tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php --compact +./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php --compact +./vendor/bin/sail artisan test --compact --filter=ReviewPack +./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace +./vendor/bin/sail pint --dirty +git diff --check +``` + +- **Fixture / helper / factory / seed / context cost risks**: reuse existing review/evidence/review-pack helpers; do not add a heavy default browser or feature fixture layer +- **Expensive defaults or shared helper growth introduced?**: none expected +- **Heavy-family additions, promotions, or visibility changes**: one explicit browser smoke only +- **Surface-class relief / special coverage rule**: no relief; this is a strategic customer-safe trust surface +- **Closing validation and reviewer handoff**: confirm no false-ready claims, explicit limitations, explicit PII/redaction visibility, preserved signed-download safety, and no new persistence/framework +- **Budget / baseline / trend follow-up**: none expected beyond one explicit browser smoke +- **Review-stop questions**: + - Did the implementation introduce a new persisted readiness truth? + - Did it invent a second output-readiness dialect instead of aligning ZIP and workspace? + - Did it weaken download safety? + - Did it overclaim customer-safe or certification semantics? +- **Escalation path**: `document-in-feature` for unreachable states; `follow-up-spec` only for broader artifact lifecycle issues diff --git a/specs/347-review-pack-output-contract-readiness-semantics/repo-truth-map.md b/specs/347-review-pack-output-contract-readiness-semantics/repo-truth-map.md new file mode 100644 index 00000000..bb77dd49 --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/repo-truth-map.md @@ -0,0 +1,116 @@ +# Spec 347 - Repo Truth Map + +Status: prepared +Created: 2026-06-02 +Scope: Review Pack output contract and Customer Review Workspace readiness semantics + +This map records the repo-backed truth that Spec 347 is allowed to harden. It must be updated if runtime inspection during implementation reveals a narrower or broader truth boundary. + +## Classification Vocabulary + +- `repo-verified`: directly observed in runtime code, tests, routes, or current spec history +- `derived from existing truth`: can be computed safely from current models or payloads +- `foundation-real`: existing foundation exists, but final contract semantics are still open +- `not available`: no repo-backed truth exists today +- `deferred`: intentionally out of scope for Spec 347 + +## Current Review-Derived ZIP Shape + +| Data point | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| Review-derived ZIP exists | repo-verified | `apps/platform/app/Jobs/GenerateReviewPackJob.php`, `apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php` | Keep as baseline; do not rewrite the generator | +| Required root files | repo-verified | `metadata.json`, `summary.json`, `sections.json`, `executive-summary.md` created in `buildReviewDerivedFileMap()` | Treat as required contract root files | +| Section-detail files live under `sections/` | repo-verified | `buildReviewDerivedFileMap()` writes `sections/%02d-%s.json` | Preserve repo truth; document deviation from user draft | +| Delivery contract constant | repo-verified | `App\Services\ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT` = `auditor_ready_executive_export.v1` | Preserve unless a narrow version bump is justified | +| Executive entrypoint filename | repo-verified | `ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME` | Preserve | + +## Current Metadata / Summary Truth + +| Data point | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| `metadata.json.delivery_bundle.entrypoint` | repo-verified | `deliveryBundleMetadata()` | Keep required | +| `metadata.json.delivery_bundle.appendix` | repo-verified | `deliveryBundleMetadata()` | Keep required | +| `metadata.json.delivery_bundle.artifact_family` | repo-verified | `deliveryBundleMetadata()` | Keep required | +| `metadata.json.delivery_bundle.review_pack_id` | repo-verified | `deliveryBundleMetadata()` | Keep required | +| `metadata.json.delivery_bundle.released_review.*` | repo-verified | `deliveryBundleMetadata()` | Keep required | +| `metadata.json.delivery_bundle.evidence_basis.*` | repo-verified | `deliveryBundleMetadata()` | Keep required | +| `metadata.json.options.include_pii` / `include_operations` | repo-verified | `buildReviewDerivedFileMap()` | Keep required | +| `metadata.json.redaction_integrity.protected_values_hidden` | repo-verified | `buildReviewDerivedFileMap()` | Keep required | +| `summary.json.review_status` / `review_completeness_state` | repo-verified | review-derived summary payload in `buildReviewDerivedFileMap()` | Keep required | +| `summary.json.section_state_counts` | repo-verified in review summary, not guaranteed in pack summary | `EnvironmentReviewComposer` writes it into `EnvironmentReview.summary`; `summary.json` currently merges the review summary | Verify and keep explicit | +| `summary.json.has_ready_export` | repo-verified in `EnvironmentReview.summary`; not guaranteed as a contract input in all consumers | `EnvironmentReviewComposer` seeds false; `GenerateReviewPackJob` sets true on successful generation | Keep explicit and consume honestly | +| `summary.json.delivery_bundle` | repo-verified | review-derived summary payload | Keep required | + +## Current Section Truth + +| Data point | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| `sections.json` contains `section_key`, `title`, `sort_order`, `required`, `completeness_state`, `summary_payload`, `render_payload` | repo-verified | `buildReviewDerivedFileMap()` | Treat as canonical section index | +| Section-detail files include only `title`, `completeness_state`, `summary_payload`, `render_payload` | repo-verified | `buildReviewDerivedFileMap()` | Gap: detail files do not currently repeat key/required/order | +| Section files are generated even when section completeness is `missing` | derived from existing truth | every included section gets a detail file regardless of completeness state | Define and test this semantics explicitly | +| Section-file absence meaning | not available as explicit contract | no current doc/test explains absence semantics | Add contract documentation and focused tests | + +## Current Review / Evidence / Export Readiness Truth + +| Data point | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| Review publication state | repo-verified | `EnvironmentReview.status`, `published_at` | Keep as distinct from export readiness | +| Review completeness state | repo-verified | `EnvironmentReview.completeness_state` | Keep distinct from evidence/export/customer-safe readiness | +| Evidence completeness state | repo-verified | `EvidenceSnapshot.completeness_state`, `summary.evidence_basis`, `summary.evidence_resolution` | Keep distinct | +| Review summary `publish_blockers` | repo-verified | `EnvironmentReviewComposer` | Keep distinct | +| Review summary `has_ready_export` | repo-verified | `EnvironmentReviewComposer` + `GenerateReviewPackJob` | Use as explicit signal, not implied magic | +| Review Pack artifact readiness | repo-verified | `ReviewPack.status`, `file_path`, `file_disk`, `expires_at`, signed download route | Keep distinct from customer-safe sharing | +| Customer-safe readiness | foundation-real | current workspace heuristics in `CustomerReviewWorkspace::reviewReadinessForTenant()` | Replace heuristic-only phrasing with contract-backed mapping | +| Internal-only / limitations-bearing label | not available as explicit contract | no current dedicated state label exists | Add derived contract only | +| PII visibility in package metadata | repo-verified | `metadata.json.options.include_pii` | Surface in UI/readiness mapping | +| PII visibility in workspace UI | not available | current workspace does not surface it | Gap to address | + +## Current Customer Review Workspace Truth + +| Data point | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| Strategic first-screen decision card exists | repo-verified | `customer-review-workspace.blade.php`, Spec 342 tests | Keep as first decision surface | +| Current primary labels: `Ready to share`, `Shareable with follow-up`, `Follow-up required before sharing` | repo-verified | `CustomerReviewWorkspace::reviewReadinessForTenant()` and localization keys | Candidate wording to harden | +| Package availability states: `available`, `evidence_incomplete`, `not_available`, `preparing`, `expired`, `unavailable` | repo-verified | `CustomerReviewWorkspace::governancePackageAvailability()` | Reuse where possible; map more explicitly to output contract | +| Readiness does not explicitly consume `include_pii` | repo-verified absence | no PII branch in workspace readiness methods | Gap to address | +| Readiness does not explicitly consume a section completeness summary | repo-verified absence | section counts not surfaced on the decision card | Gap to address | +| Diagnostics remain collapsed | repo-verified | current Blade/tests | Preserve | + +## Current Executive Summary Truth + +| Data point | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| Non-certification disclosure exists | repo-verified | `buildExecutiveEntrypoint()` | Preserve | +| Dedicated limitations section does not exist | repo-verified absence | executive summary currently has Executive story / Evidence basis / Key findings / Accepted risks / Governance decisions / Next actions / Non-certification disclosure / Structured auditor appendix | Add explicit limitations block when needed | +| Executive summary does not explicitly explain section-file-present + section-missing semantics | repo-verified absence | no such wording in `buildExecutiveEntrypoint()` | Gap to address | + +## Current Download Safety Truth + +| Data point | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| Signed route required | repo-verified | `ReviewPackDownloadController`, `ReviewPackDownloadTest` | Preserve unchanged | +| Capability required | repo-verified | `Capabilities::REVIEW_PACK_VIEW` check | Preserve unchanged | +| Ready status required | repo-verified | controller check | Preserve unchanged | +| Expiry required | repo-verified | controller check | Preserve unchanged | +| File existence required | repo-verified | controller check | Preserve unchanged | +| Audit event on download | repo-verified | controller audit log | Preserve unchanged | + +## Existing Proof Tests + +| Test surface | Classification | Repo evidence | Spec 347 handling | +|---|---|---|---| +| Review-derived ZIP contract basics | repo-verified | `apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php` | Extend or complement | +| Review-derived executive entrypoint and section-order contract | repo-verified | `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php` | Re-run and extend where Spec 347 changes the executive entrypoint or delivery-bundle semantics | +| Download safety | repo-verified | `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` | Preserve and re-run | +| Review Pack generation | repo-verified | `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php` | Reuse helpers | +| Customer Review Workspace false-claim prevention | repo-verified | `apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php` | Extend or complement | +| Customer Review Workspace smoke | repo-verified | `apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php` | Use as pattern or overlap regression | +| Customer Review Workspace localization contract | repo-verified | `apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php` | Re-run when readiness vocabulary changes | + +## Primary Repo-Truth Gaps To Close + +1. No explicit documented contract for section-detail files vs `sections.json`. +2. No explicit dedicated limitations block in the executive summary. +3. No first-class output-readiness contract that aligns ZIP payloads with workspace wording. +4. No explicit PII/redaction visibility on the workspace first screen. +5. Existing ready/share labels are stronger than the currently explicit bundle contract. diff --git a/specs/347-review-pack-output-contract-readiness-semantics/spec.md b/specs/347-review-pack-output-contract-readiness-semantics/spec.md new file mode 100644 index 00000000..0aa58fbd --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/spec.md @@ -0,0 +1,411 @@ +# Feature Specification: Spec 347 - Review Pack Output Contract & Readiness Semantics + +**Feature Branch**: `347-review-pack-output-contract-readiness-semantics` +**Created**: 2026-06-02 +**Status**: Draft +**Type**: Contract-first hardening / review-pack output semantics / customer-safe export productization +**Runtime posture**: Narrow runtime hardening over existing review-derived Review Pack exports and current Customer Review Workspace readiness heuristics. No generator rewrite, no new persistence, no new portal, and no broad review workflow rebuild. +**Input**: User-provided full Spec 347 draft + repo truth from Specs 109, 308, 337, 342, 343, 344, and current Spec 346 context. + +## Dependencies And Historical Context + +This spec is a follow-up over already repo-real review, evidence, and customer-safe productization work: + +- Spec 109 - Review Pack Export +- Spec 308 - Decision Register Summary / Review Pack Inclusion +- Spec 337 - Evidence / Review Pack Product Process Flow Alignment +- 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 346 - Governance Inbox Final Operator Workflow (active adjacent context only; not a prerequisite to reopen or complete here) + +Repo-truth adjustment against the user draft: + +- Current review-derived ZIP section detail files are generated under `sections/%02d-%s.json`, not as root-level `10-...json` files. +- The current review-derived delivery contract already exists as `auditor_ready_executive_export.v1` in `App\Services\ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT`. +- `metadata.json` already carries `delivery_bundle.entrypoint`, `appendix`, review identity, evidence identity, options, and redaction integrity. +- `EnvironmentReview.summary.has_ready_export` already exists, but the Customer Review Workspace does not use it as a first-class output-readiness contract. +- `CustomerReviewWorkspace` currently derives `Ready to share`, `Shareable with follow-up`, and `Follow-up required before sharing` from page-local heuristics over findings, accepted-risk follow-up, evidence availability, mapped review data, and download availability. +- The existing UI audit page report for this surface is `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`; this spec must not invent a new page-report identity unless repo truth later proves it necessary. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already produces structured review-pack ZIPs, but the product contract for those files and the customer-share/readiness semantics remain ambiguous. +- **Today's failure**: Operators can see that a review is published, a ZIP exists, and section files are present, while evidence completeness, section completeness, `has_ready_export`, and customer-safe sharing semantics can still disagree. The UI can therefore imply "ready to share" more strongly than the bundle contract proves. +- **User-visible improvement**: Review Pack output becomes self-explanatory and trustworthy. Operators can tell whether a package exists, whether it is output-contract complete, whether evidence is incomplete, whether sections are missing by source truth rather than by file absence, whether PII is included, and whether the package is customer-safe, internal-only, or limitations-bearing. +- **Smallest enterprise-capable version**: Keep the current review-derived ZIP shape, add a narrow derived output-readiness contract, harden required metadata/section semantics, add limitations/disclosure output, and qualify Customer Review Workspace labels and download affordances. +- **Explicit non-goals**: No review-pack generator rewrite, no new evidence pipeline, no portal, no PSA/ITSM handoff, no PDF replacement, no new legal attestation workflow, no new queue family, no new table, no broad customer-review redesign, no Governance Inbox rewrite, and no new GRC framework. +- **Permanent complexity imported**: One repo-truth map, three spec-local contract documents, one narrow readiness mapper or presenter if needed, focused Feature tests, one bounded Browser smoke file, and screenshots. No new persisted entity, no new public status family, and no new runtime framework. +- **Why now**: Specs 337 and 342-344 made evidence/review-pack/customer-safe consumption repo-real, but the remaining trust gap is no longer raw data generation. It is the output contract and the readiness semantics at the export boundary. +- **Why not local**: Copy-only changes would leave `metadata.json`, `summary.json`, section files, and workspace UI heuristics semantically misaligned. A narrow contract-first hardening slice is the smallest honest fix. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: Strategic customer-safe surface, output/readiness trust language, export semantics, and cross-surface contract alignment. Defense: the slice reuses existing truth, forbids new persistence, explicitly avoids a generator rewrite, and scopes new semantics to derived contract mapping plus focused tests. +- **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 347 draft + - roadmap lane: customer-safe review consumption and evidence/review-pack productization + - spec-candidate alignment: open gap adjacent to `customer-review-workspace-v1-completion`, `localization-v1-customer-facing-surfaces`, and review/evidence follow-through +- **Completed-spec guardrail result**: + - no `specs/347-*` package existed before this prep + - Specs 109, 308, 312, 337, 342, 343, and 344 contain completed-task, validation, smoke, close-out, or implementation-history signals and are treated as historical context only + - Spec 346 is active adjacent context and must not be rewritten, normalized, or treated as completed by this prep +- **Close alternatives deferred**: + - provider readiness / onboarding productization + - broader customer-facing localization hardening + - sellable smoke matrix + - customer portal boundary contract +- **Smallest viable implementation slice**: existing review-derived ZIP output plus existing Customer Review Workspace output-readiness surfaces only: required file contract, metadata/section semantics, explicit limitations/disclosure, qualified workspace labels, qualified download affordances, and focused tests/browser smoke. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace canonical-view plus environment-owned review/evidence/export artifacts. +- **Primary Routes**: + - `/admin/reviews/workspace` (`App\Filament\Pages\Reviews\CustomerReviewWorkspace`) + - existing Environment Review and Review Pack detail/download destinations only where required for truth-preserving handoff + - signed download route `/admin/review-packs/{reviewPack}/download` +- **Data Ownership**: + - `EnvironmentReview` remains released-review truth + - `EnvironmentReviewSection` remains section truth + - `EvidenceSnapshot` remains anchored evidence-basis truth + - `ReviewPack` remains generated export artifact truth + - `OperationRun` remains generation/download proof linkage only + - output-readiness stays derived; no new persisted readiness entity is introduced +- **RBAC**: + - workspace membership and managed-environment entitlement remain mandatory + - existing capabilities remain authoritative, especially `REVIEW_PACK_VIEW`, `ENVIRONMENT_REVIEW_VIEW`, and evidence/report capabilities + - non-members and cross-workspace or cross-environment access remain deny-as-not-found + - no new public route family or legacy query alias may be introduced + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: keep `environment_id` as the only page-local filter contract; do not inherit hidden environment shell state or revive `/admin/t` semantics. +- **Explicit entitlement checks preventing cross-tenant leakage**: all review, evidence, pack, and download destinations must continue to resolve through existing workspace/environment scoped routes and policies. + +## 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`, the signed Review Pack download affordance as reached from that workspace surface, and review-derived review-pack output files. +- **Current or new page archetype**: existing strategic customer-safe review surface plus existing artifact/download proof surfaces; no new route archetype. +- **Design depth**: Strategic Surface for `CustomerReviewWorkspace`; Domain Pattern Surface for Review Pack detail/download truth. +- **Repo-truth level**: repo-verified existing runtime surface plus existing ZIP contract. +- **Existing pattern reused**: Specs 337 and 342-344 customer-safe evidence/review readiness patterns, existing Review Pack generation/download contracts, existing disclosure and diagnostics collapse pattern. +- **New pattern required**: a bounded output-readiness contract over existing truth; no new global UI framework. +- **Screenshot required**: yes, under `specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/`. +- **Page audit required**: update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`. Do not invent a new page-report identity unless implementation proves the current report cannot absorb the contract. +- **Customer-safe review required**: yes. This spec directly governs customer-safe, internal-only, and limitations-bearing output semantics. +- **Dangerous-action review required**: no new destructive action is expected. Existing regenerate/expire actions remain out of scope. +- **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, evidence/review/export readiness labels, action links, evidence/report viewers, review-pack proof and download affordances, customer-safe disclosure. +- **Systems touched**: + - `CustomerReviewWorkspace` + - `EnvironmentReviewComposer` + - `GenerateReviewPackJob` + - `ReviewPackService` + - `ReviewPackDownloadController` + - existing Review Pack and Environment Review resources/tests +- **Existing pattern(s) to extend**: Spec 337 evidence/review-pack state truth, Spec 342 customer-safe first-screen truth, existing disclosure rules, existing `delivery_bundle` and `evidence_resolution` payloads. +- **Shared contract / presenter / builder / renderer to reuse**: existing `EnvironmentReview.summary`, `governance_package`, `delivery_bundle`, `ArtifactTruthPresenter`, and current Review Pack metadata/summary structures wherever sufficient. +- **Why the existing shared path is sufficient or insufficient**: current structures already hold most raw facts, but they do not yet define one coherent output-readiness contract or one trustworthy mapping into workspace labels. +- **Allowed deviation and why**: one bounded page-local or support-layer readiness mapper is allowed if it avoids duplicating heuristics across page, ZIP, and tests. It must remain local to review-pack output semantics and not become a generic review framework. +- **Consistency impact**: ZIP files, Review Pack summary/metadata, workspace readiness labels, PII/redaction visibility, disclosure wording, and download labels must describe the same readiness state. +- **Review focus**: block any new persisted readiness entity, new legal/certification semantics, or a second independent readiness dialect. + +## OperationRun UX Impact *(mandatory)* + +- **Touches OperationRun start/completion/link UX?**: existing proof and download audit context only +- **Shared OperationRun UX contract/layer reused**: existing `OperationRunLinks`, current generation flow, current audit/download proof handling +- **Delegated start/completion UX behaviors**: unchanged +- **Local surface-owned behavior that remains**: readiness explanation, limitations copy, and primary action selection +- **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/readiness semantics over existing review artifacts +- **Seams affected**: none beyond existing review/evidence/export artifact truth +- **Neutral platform terms preserved or introduced**: review pack, evidence basis, output contract, customer-safe, internal package, limitations, export readiness +- **Provider-specific semantics retained and why**: only where existing stored report or review content already contains provider-backed labels +- **Why this does not deepen provider coupling accidentally**: no new Graph, provider, or identity contract 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/readiness labels | yes | Native Filament page plus existing Blade composition | customer-safe review readiness, evidence/proof, download affordance | page, URL-query, derived payload | no | Existing route only | +| Customer Review Workspace review-pack panel | yes | Native Filament page plus existing Blade composition | package status, export readiness, evidence basis, PII warning | page payload | no | Derived only | +| Review-derived ZIP output files | yes | existing export file contract | metadata, section detail, executive entrypoint disclosure | artifact payload | no | No route change | +| Review Pack Resource detail/header wording | no by default | existing Filament resource/detail | artifact proof remains existing behavior | none unless repo truth later proves a contradiction on the signed download path | yes if unexpectedly touched | keep out of scope for Spec 347 unless a minimal consistency patch becomes unavoidable | + +## 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 can be shared, must be reviewed, or is internal-only | readiness label, reason, impact, evidence state, section state summary, PII/redaction state, primary next action | review detail, section detail, operation proof, diagnostics, download | Primary because it is the first customer-safe consumption screen | follows handoff/export decision workflow | removes guesswork from ZIP existence alone | +| Review-derived executive entrypoint | Secondary Context | Reader opens the package and decides what it is and what its limitations are | review status, evidence basis, limitations, disclosure, next action | structured appendix JSON, section detail files | Secondary because it explains the already generated artifact | supports handoff and archive consumption | removes ambiguity from raw file names | +| Review Pack detail/download | Secondary Context | Operator verifies artifact truth and authorized download path | artifact status, download availability, timestamps | file metadata, operation proof | Secondary because it is artifact detail, not the first sharing decision surface | supports artifact verification | keeps detail out of the first 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 | readiness label, reason, impact, package state, evidence basis state, section completeness summary, PII/redaction visibility | operation proof, review detail, evidence link, section detail | raw payloads, fingerprints, internal-only diagnostics | review limitations or download qualified package | raw/support detail hidden or secondary | page states the readiness decision once; lower sections add proof | +| Executive summary | customer-safe or internal package reader | what the package is, evidence basis, limitations, disclosure, next action | none by default | JSON appendix only | review limitations or read appendix | internal-only/raw detail absent from markdown | limitations are explicit instead of implied by other files | + +## 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 | decide whether to review limitations or download the package | explicit primary action in decision card | forbidden | secondary links and proof panels | none in scope | `/admin/reviews/workspace` | existing review or pack routes only | workspace shell, visible environment filter, package state | Customer Review Workspace | readiness, evidence basis, section limits, PII/redaction | none | +| Review Pack detail | Utility / Artifact Detail | Read-only artifact proof | verify package and download | existing detail affordance | current repo-real behavior only | secondary proof/actions stay in detail | existing regenerate/expire actions stay out of scope | existing review-pack collection route | existing review-pack detail route | workspace/environment context and artifact status | Review pack | artifact truth and download 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 a current review package is customer-safe, limitations-bearing, or internal-only | workspace review hub | Can I rely on this package, can I share it, and what needs review first? | readiness label, evidence completeness, section completeness, PII/redaction, disclosure, next action | operation proof, lower-level evidence/report links, debug/support detail | review publication, review completeness, evidence completeness, package availability, output readiness, customer-safe boundary | none by default | review limitations, open review, qualified download | none in scope | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: maybe one bounded readiness mapper/presenter only +- **New enum/state/reason family?**: no persisted family; any labels stay derived +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: published review, existing ZIP, and section files do not yet produce one trustworthy statement about output readiness and customer-safe sharing. +- **Existing structure is insufficient because**: metadata, review summary, section files, and workspace heuristics are not yet contract-aligned, so each can imply a different state. +- **Narrowest correct implementation**: derive one output-readiness contract from existing review/pack/evidence truth, harden the ZIP fields/files already present, and remap workspace labels to that contract. +- **Ownership cost**: spec-local contract docs, one bounded mapper, focused tests/browser smoke, and one UI audit update. +- **Alternative intentionally rejected**: new persisted readiness entity, new review-pack engine, new customer portal, or a full generator rewrite. +- **Release truth**: current-release truth. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Compatibility shims, legacy ZIP aliases, dual file layouts, or fallback route/query aliases 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 core work is deterministic payload/file contract hardening plus server-rendered workspace/readiness semantics. Focused Feature tests can assert JSON fields, ZIP contents, section consistency, qualified labels, and authorization. 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/Spec347ReviewPackOutputContractTest.php` + - `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php` + - `apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php` + - `apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php` +- **Relevant existing regressions to rerun**: + - `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php` + - `apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php` + - `apps/platform/tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php` + - `apps/platform/tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php` +- **Fixture / helper cost impact**: reuse existing review-pack, customer-review, and evidence snapshot helpers. No new heavy default fixture family should be introduced. +- **Heavy-family visibility / justification**: one explicit browser smoke only +- **Special surface test profile**: `global-context-shell` + customer-safe strategic review surface + artifact contract +- **Standard-native relief or required special coverage**: special coverage required for no-false-ready claims, limitations copy, and qualified download labels +- **Reviewer handoff**: reviewers must confirm no new persistence, no revived legacy file layout assumptions, no false customer-safe/export-ready/certification claims, and no weakened signed-download controls +- **Budget / baseline / trend impact**: none expected beyond one explicit browser smoke addition +- **Escalation needed**: `document-in-feature` for unreachable states; `follow-up-spec` only if repo truth reveals a broader governance-artifact lifecycle gap +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Planned validation commands**: + +```bash +cd apps/platform +./vendor/bin/sail artisan test tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php --compact +./vendor/bin/sail artisan test tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php --compact +./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php --compact +./vendor/bin/sail artisan test --compact --filter=ReviewPack +./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace +./vendor/bin/sail pint --dirty +git diff --check +``` + +## Summary + +TenantPilot already produces a structured review-derived Review Pack ZIP. The current strength is not file generation itself; it is the existing mix of: + +- `executive-summary.md` +- `metadata.json` +- `summary.json` +- `sections.json` +- one JSON file per review section under `sections/` + +The current gap is semantic trust, not missing basic output. Spec 347 therefore hardens: + +1. the Review Pack file contract +2. readiness semantics across review, evidence, section, export, and customer-safe states +3. qualified workspace/download wording +4. explicit limitations and disclosure +5. tests that block false ready-to-share claims and protect existing workspace/executive-pack/localization behavior + +## Goals + +1. Define the Review Pack file contract over the current review-derived ZIP shape. +2. Make review status, review completeness, evidence completeness, section completeness, export readiness, and customer-safe boundaries explicit and non-conflated. +3. Ensure a section file may exist while the section completeness state is `missing`, and explain that semantics clearly. +4. Surface PII/redaction and internal-vs-customer-safe boundaries explicitly. +5. Prevent unqualified `Ready to share` or similar claims when the repo-backed contract says otherwise. +6. Preserve signed-download safety and non-certification disclosure. + +## Non-Goals + +- rebuild the Review Pack generator from scratch +- add new persistence +- add a portal +- add legal attestation or certification workflow +- replace JSON with PDF +- redesign Governance Inbox +- redesign Customer Review Workspace beyond bounded output-readiness wording and proof hierarchy +- change OperationRun semantics +- change EvidenceSnapshot generation semantics +- add new GRC/control taxonomies + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Interpret A Generated Package (Priority: P1) + +As an MSP operator, I need a generated Review Pack to explain whether it is ready, limited, or internal-only so I can decide whether to share it safely. + +**Why this priority**: This is the core trust gap. A ZIP existing is not enough. + +**Independent Test**: Generate or inspect a review-derived ZIP and assert that required files, required metadata fields, limitations/disclosure, and section semantics are present and non-contradictory. + +**Acceptance Scenarios**: + +1. **Given** a review-derived package with missing evidence completeness, **When** the ZIP is inspected, **Then** it still contains required files but explicitly reports incomplete evidence basis and limitations. +2. **Given** a section marked `missing`, **When** its file exists, **Then** the contract explains that source completeness is missing rather than the file being absent. + +--- + +### User Story 2 - Read Output Readiness In The Workspace (Priority: P1) + +As an operator viewing Customer Review Workspace, I need qualified output-readiness labels so the UI does not imply customer-safe sharing when the package contract is incomplete. + +**Why this priority**: The workspace is the first decision surface for handoff/share decisions. + +**Independent Test**: Render the workspace with repo-backed incomplete-evidence, missing-section, PII-included, and ready states and assert qualified labels and primary actions. + +**Acceptance Scenarios**: + +1. **Given** `has_ready_export` is false or evidence completeness is missing, **When** the workspace renders, **Then** it does not show unqualified `Ready to share`. +2. **Given** PII is included, **When** the workspace renders, **Then** it shows operator-visible sharing caution and does not imply customer-safe external sharing without review. + +--- + +### User Story 3 - Preserve Safe Download And Honest Disclosure (Priority: P2) + +As an authorized operator, I need downloads to remain safe while the package itself clearly states what it does and does not claim. + +**Why this priority**: Export/download safety must remain intact while wording changes. + +**Independent Test**: Re-run signed-download tests and confirm executive-summary disclosure/limitations are present. + +**Acceptance Scenarios**: + +1. **Given** a ready pack with valid signed URL, **When** it is downloaded, **Then** current authorization and signed-link behavior remain unchanged. +2. **Given** an executive summary is generated, **When** evidence or section readiness is limited, **Then** it includes a limitations note and non-certification disclosure. + +## Functional Requirements + +- **FR-001**: The review-derived Review Pack output contract MUST document required root files, current section-detail file placement, required metadata fields, required summary fields, and required section fields. +- **FR-002**: Required root files for a valid review-derived package MUST remain `executive-summary.md`, `metadata.json`, `summary.json`, and `sections.json`. +- **FR-003**: Section-detail files MAY remain under `sections/`, but the contract MUST explain that repo truth and MUST require consistency with `sections.json`. +- **FR-004**: `metadata.json` MUST expose bundle contract identity, artifact family, review-pack identity, released-review identity/state, evidence-basis identity/state, entrypoint declaration, appendix declaration, options, and redaction integrity. +- **FR-005**: `summary.json` MUST expose review status, review completeness, evidence resolution, section state counts, publish blockers, delivery-bundle summary, and enough state to derive export readiness honestly. +- **FR-006**: Every entry in `sections.json` MUST expose section key, title, sort order, required flag, completeness state, summary payload, and render payload. +- **FR-007**: Section-detail files MUST either carry the same key/required/state contract directly or be explicitly documented as legacy shape if the implementation keeps the current thinner file payload. +- **FR-008**: The contract MUST define the meaning of `published`, `review_completeness_state`, evidence completeness, section completeness, `has_ready_export`, and customer-safe/internal-only boundaries so they are not treated as synonyms. +- **FR-009**: Customer Review Workspace MUST not show unqualified share-ready language when the repo-backed contract says evidence is incomplete, required sections are incomplete, export is not ready, or PII/customer-safe review still needs operator review. +- **FR-010**: Workspace output-readiness copy MUST explicitly surface evidence basis state, section limitations summary, and PII/redaction visibility when relevant. +- **FR-011**: `executive-summary.md` MUST include an explicit limitations section whenever evidence completeness, required section completeness, export readiness, or PII/customer-safe boundaries limit sharing. +- **FR-012**: Non-certification disclosure MUST remain present in the executive summary and remain visible in the customer-safe workspace/output context where share-ready language appears. +- **FR-013**: Existing signed download authorization, expiry, and file-existence checks MUST remain unchanged in behavior. +- **FR-014**: Focused tests MUST verify required files, required fields, section/file consistency, qualified readiness mapping, PII visibility, disclosure presence, and preserved download safety. + +## Non-Functional Requirements + +- **NFR-001**: Output must be self-explanatory to an operator or stakeholder without requiring source-code knowledge. +- **NFR-002**: No new persisted readiness entity or public status family may be introduced unless repo truth proves a derived contract is insufficient. +- **NFR-003**: Customer-safe wording must remain conservative and must not imply certification, audit opinion, or guaranteed compliance. +- **NFR-004**: Backward-compatibility expectations remain pre-production lean. Current contract hardening may replace weaker wording/shape directly rather than adding compatibility shims. +- **NFR-005**: Diagnostics, raw payloads, fingerprints, and support-only details remain hidden or secondary on customer-safe default paths. + +## Acceptance Criteria + +- **AC-001**: `specs/347-review-pack-output-contract-readiness-semantics/contracts/review-pack-output-contract.md` exists and documents the current file contract, required fields, and file-to-section consistency. +- **AC-002**: `specs/347-review-pack-output-contract-readiness-semantics/contracts/readiness-semantics.md` exists and defines the derived readiness vocabulary without introducing a new persisted state family. +- **AC-003**: `specs/347-review-pack-output-contract-readiness-semantics/contracts/customer-safe-output-boundary.md` exists and defines customer-safe, internal-only, PII/redaction, and disclosure boundaries. +- **AC-004**: Focused Feature tests verify required root files and required metadata/summary fields for review-derived packs. +- **AC-005**: Focused Feature tests verify that missing section completeness can coexist with a present section file and that the semantics are explicit. +- **AC-006**: Focused Feature/Livewire tests verify the workspace does not show unqualified share-ready language when the output contract is incomplete. +- **AC-007**: Focused tests verify visible PII/redaction/customer-safe warnings when `include_pii` is true or when the package is not customer-safe ready. +- **AC-008**: Executive summary output contains a limitations block when contract-backed limitations exist. +- **AC-009**: Signed-download behavior remains gated and functional. +- **AC-010**: One bounded Browser smoke proves the qualified readiness labels and download wording on the strategic workspace surface. + +## Risks + +- **Risk 1 - readiness language becomes a second hidden framework** + Mitigation: keep the contract derived-only; prefer one bounded mapper and explicit docs over a generic workflow engine. +- **Risk 2 - package truth and workspace truth drift again** + Mitigation: make tests assert the same state vocabulary across ZIP files and workspace rendering. +- **Risk 3 - user draft expectations conflict with current file layout** + Mitigation: preserve repo truth (`sections/` detail files) and document the deviation explicitly instead of inventing a compatibility layer. +- **Risk 4 - customer-safe wording becomes over-optimistic** + Mitigation: prefer conservative labels and require explicit disclosure for evidence gaps, missing sections, and PII. + +## Assumptions And Open Questions + +### Assumptions + +- Current review-derived ZIP layout remains the baseline. +- The narrowest correct implementation is derived-contract hardening, not a file-layout rewrite. +- Existing review-pack and workspace tests can be extended rather than replaced wholesale. + +### Open Questions + +- Should section-detail files gain `section_key`, `required`, and `sort_order` directly, or should the contract treat `sections.json` as the canonical section index and keep section-detail files thinner? + Current bias: prefer adding the missing keys if it is low-risk, but keep the contract honest either way. +- Should the qualified download labels live only on the workspace first-screen path, or also in Review Pack Resource detail/header copy? + Current bias: workspace first. Review Pack Resource detail/header copy stays out of scope unless implementation finds a strict signed-download contradiction that cannot be solved on the workspace surface alone. + +## Follow-up Spec Candidates + +- **Spec 348** - Provider readiness / evidence refresh follow-through if output-readiness blockers need clearer operational remediation. +- **Spec 349** - Customer-facing localization and copy hardening for review/output semantics. +- **Spec 350** - Sellable smoke matrix for governance, review, evidence, and export flows. +- **Spec 351** - Customer portal output-boundary contract after platform output semantics are stable. diff --git a/specs/347-review-pack-output-contract-readiness-semantics/tasks.md b/specs/347-review-pack-output-contract-readiness-semantics/tasks.md new file mode 100644 index 00000000..bf3507a9 --- /dev/null +++ b/specs/347-review-pack-output-contract-readiness-semantics/tasks.md @@ -0,0 +1,143 @@ +# Tasks: Spec 347 - Review Pack Output Contract & Readiness Semantics + +**Input**: Design documents from `/specs/347-review-pack-output-contract-readiness-semantics/` +**Prerequisites**: `spec.md`, `plan.md`, `repo-truth-map.md`, and the three contract documents under `contracts/` + +**Tests**: Required. This is a runtime output-contract and customer-safe trust-surface change on existing review-pack and Customer Review Workspace paths. + +## Test Governance Checklist + +- [x] Lane assignment is explicit and narrow: Feature for ZIP/workspace contract, 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 profile (`global-context-shell` + customer-safe strategic review surface + artifact contract) is explicit. +- [x] Any unreachable state is documented in the active spec package rather than faked. + +## Phase 1: Preparation And Repo Truth + +**Purpose**: Confirm current output truth and keep the runtime implementation bounded to the existing review-derived export and workspace surfaces. + +- [x] T001 Re-read `specs/347-review-pack-output-contract-readiness-semantics/spec.md`, `plan.md`, `repo-truth-map.md`, and all three contract docs before runtime changes. +- [x] T002 Re-read related historical context only: Specs 109, 308, 312, 337, 342, 343, 344, and active Spec 346. Do not modify their artifacts. +- [x] T003 Re-verify current runtime truth in: + - `apps/platform/app/Jobs/GenerateReviewPackJob.php` + - `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php` + - `apps/platform/app/Services/ReviewPackService.php` + - `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` + - `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` + - `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php` +- [x] T004 Keep `specs/347-review-pack-output-contract-readiness-semantics/repo-truth-map.md` updated if implementation-time code differs from the prepared truth. +- [x] T005 Confirm no migration, package, env var, queue family, scheduler change, storage-topology change, or Filament asset 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`. +- [x] T008 Confirm no new global-search behavior is introduced for review/evidence/review-pack resources. + +## Phase 2: Finalize Contract Docs + +**Purpose**: Lock the implementation against one explicit contract instead of allowing page-local drift. + +- [x] T009 Finalize `specs/347-review-pack-output-contract-readiness-semantics/contracts/review-pack-output-contract.md`. +- [x] T010 Finalize `specs/347-review-pack-output-contract-readiness-semantics/contracts/readiness-semantics.md`. +- [x] T011 Finalize `specs/347-review-pack-output-contract-readiness-semantics/contracts/customer-safe-output-boundary.md`. +- [x] T012 Record repo-truth deviations from the user draft explicitly: + - section-detail files currently live under `sections/` + - current delivery contract is `auditor_ready_executive_export.v1` + - current UI audit page report is `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md` +- [x] T013 Confirm the contract keeps semantics derived-only and does not introduce a new persisted readiness family. + +## Phase 3: Tests First + +**Purpose**: Lock required file/field/label semantics before runtime refactor. + +- [x] T014 Add `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php`. +- [x] T015 Add `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php`. +- [x] T016 Add `apps/platform/tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php`. +- [x] T017 Add `apps/platform/tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php`. +- [x] T018 Add assertions for required root files: `executive-summary.md`, `metadata.json`, `summary.json`, `sections.json`. +- [x] T019 Add assertions for required metadata fields: bundle contract, artifact family, review-pack id, released-review state, evidence-basis state, entrypoint, appendix, options, and redaction integrity. +- [x] T020 Add assertions for required summary/readiness fields, including review status, review completeness, evidence resolution, section state counts, publish blockers, delivery bundle, and any contract-backed readiness flag inputs. +- [x] T021 Add assertions that a section marked `missing` may still have a section-detail file and that the semantics are explicit. +- [x] T022 Add assertions that the workspace does not show unqualified `Ready to share` when evidence, section, export, or customer-safe readiness is incomplete. +- [x] T023 Add assertions that `include_pii=true` or equivalent repo-backed PII truth results in an operator-visible review warning before sharing. +- [x] T024 Add assertions that executive summary output contains limitations and non-certification disclosure when contract-backed limitations exist. +- [x] T025 Reuse or extend existing tests such as `EnvironmentReviewDerivedReviewPackTest.php`, `EnvironmentReviewExecutivePackTest.php`, `ReviewPackDownloadTest.php`, `Spec342CustomerReviewWorkspaceConsumptionTest.php`, `Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php`, and `CustomerReviewSurfaceLocalizationTest.php` only where more proportional than duplicating all setup. + +## Phase 4: Derived Output-Readiness Mapper + +**Purpose**: Replace scattered heuristics with one bounded derived contract. + +- [x] T026 Choose the narrowest implementation home for derived output readiness: + - page-local helper inside `CustomerReviewWorkspace` + - or one bounded support-layer mapper shared with review-pack output generation/tests +- [x] T027 Derive a contract that exposes label, reason, impact, primary action, evidence basis state, section completeness summary, PII/redaction visibility, and customer-safe/internal-only/limitations state. +- [x] T028 Reuse current review summary, review-pack summary, and existing `delivery_bundle` / `evidence_resolution` data before adding any new payload keys. +- [x] T029 Keep any added payload keys narrow and review-pack-output-specific; do not create a generic governance output engine. + +## Phase 5: Review-Derived ZIP Contract Hardening + +**Purpose**: Keep the current generator shape while removing contract ambiguity. + +- [x] T030 Update `apps/platform/app/Jobs/GenerateReviewPackJob.php` so review-derived ZIP generation always emits the required root files and required contract fields. +- [x] T031 Preserve the current review-derived contract constant in `apps/platform/app/Services/ReviewPackService.php` unless a repo-justified version bump is necessary. +- [x] T032 Decide and implement the canonical section-detail contract: + - add `section_key`, `required`, and `sort_order` to each `sections/*.json` file, or + - explicitly keep `sections.json` as the canonical section index and document the thinner subordinate detail-file shape +- [x] T033 Ensure `metadata.json` and `summary.json` expose consistent review, evidence, section, and bundle semantics. +- [x] T034 Ensure file-to-section consistency is testable: every detail file corresponds to a `sections.json` entry and does not silently drift in key/title/state. +- [x] T035 Keep review-pack download safety unchanged; do not weaken signed-route, capability, expiry, or file-existence checks in `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`. + +## Phase 6: Executive Summary And Disclosure Hardening + +**Purpose**: Make the human entrypoint honest without leaking internal detail. + +- [x] T036 Update review-derived executive-summary generation in `apps/platform/app/Jobs/GenerateReviewPackJob.php` to add a dedicated `## Limitations` block when evidence completeness, section completeness, export readiness, or PII/customer-safe boundary limits sharing. +- [x] T037 Keep or strengthen the existing non-certification disclosure in the executive summary. +- [x] T038 Explicitly explain in the executive summary when section files are present but the corresponding section completeness is `missing`. +- [x] T039 Keep internal-only/raw/support detail out of the markdown entrypoint. + +## Phase 7: Customer Review Workspace Remap + +**Purpose**: Make the first screen reflect the same contract as the ZIP. + +- [x] T040 Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` to use qualified output-readiness labels when the package contract is incomplete. +- [x] T041 Update `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` so the first screen surfaces evidence basis state, section completeness summary, PII/redaction visibility, and limitations-aware next action. +- [x] T042 Qualify download labels and affordances on the workspace surface based on repo-backed state, for example internal-only or limitations-bearing package wording where justified by the contract. +- [x] T043 Keep exactly one dominant next action in the decision card. +- [x] T044 Keep diagnostics collapsed and secondary. +- [x] T045 Avoid broader Customer Review Workspace redesign outside bounded readiness/disclosure hardening. + +## Phase 8: Copy, Audit, And Browser Proof + +**Purpose**: Align user-facing wording and proof artifacts with the hardened contract. + +- [x] T046 Update only the required output-readiness and disclosure keys in: + - `apps/platform/lang/en/localization.php` + - `apps/platform/lang/de/localization.php` +- [x] T047 Update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md` with the output contract/readiness mapping, limitations behavior, and deferred follow-ups. +- [x] T048 Keep the existing page-report identity and do not invent `ui-009-*` unless runtime review proves the current report cannot absorb the output-contract scope. +- [x] T049 Capture browser screenshots under `specs/347-review-pack-output-contract-readiness-semantics/artifacts/screenshots/`. + +## Phase 9: Validation + +**Purpose**: Prove the contract and preserve current safety. + +- [x] T050 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/Filament/Spec347CustomerReviewWorkspaceOutputReadinessTest.php --compact`. +- [x] T051 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/EnvironmentReview/EnvironmentReviewExecutivePackTest.php tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php tests/Feature/Filament/Spec342CustomerReviewWorkspaceConsumptionTest.php --compact`. +- [x] T052 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php tests/Browser/Spec342CustomerReviewWorkspaceConsumptionSmokeTest.php --compact`. +- [x] T053 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ReviewPack`. +- [x] T054 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace`. +- [x] T055 Run `cd apps/platform && ./vendor/bin/sail pint --dirty`. +- [x] T056 Run `git diff --check`. +- [x] T057 Report any unrelated broader-suite failures honestly if they remain out of scope. + +## Non-Goals Checklist + +- [x] NT001 Do not rebuild Review Pack generation from scratch. +- [x] NT002 Do not add a new persisted readiness entity, table, or status family. +- [x] NT003 Do not add a portal, PSA/ITSM handoff, or broader artifact-lifecycle framework. +- [x] NT004 Do not redesign Governance Inbox or broadly redesign Customer Review Workspace. +- [x] NT005 Do not add legal/compliance approval, certification, or attestation semantics. +- [x] NT006 Do not weaken signed-download safety. +- [x] NT007 Do not invent a legacy-compatible root-level section-file layout if repo truth remains `sections/*.json`. +- [x] NT008 Do not expand this slice into Review Pack Resource detail/header productization unless a minimal contradiction fix is proven unavoidable. diff --git a/specs/348-choose-environment-enterprise-selector/plan.md b/specs/348-choose-environment-enterprise-selector/plan.md new file mode 100644 index 00000000..3619dda2 --- /dev/null +++ b/specs/348-choose-environment-enterprise-selector/plan.md @@ -0,0 +1,48 @@ +# Implementation Plan: Spec 348 - Choose Environment Enterprise Selector + +**Branch**: `348-choose-environment-enterprise-selector` | **Date**: 2026-06-02 | **Spec**: `specs/348-choose-environment-enterprise-selector/spec.md` + +## Summary + +Productize the existing `/admin/choose-environment` selector so it behaves like an enterprise context-selection surface: stable layout, immediate secondary actions, search for larger workspaces, localized search states, and no raw technical badge overflow. + +## Technical Context + +- **Language/Version**: PHP 8.4.15, Laravel 12.52.x. +- **Primary Dependencies**: Filament 5.2.x, Livewire 4.1.x, Tailwind CSS 4.x, Pest 4.x. +- **Storage**: PostgreSQL; no schema change. +- **Testing**: Pest Feature tests. +- **Constraints**: No provider calls during render, no new persistence, no new route, no destructive action. + +## Implementation Approach + +1. Extend `ChooseEnvironment` with a local `search` property and a filtered collection helper. +2. Replace the dynamic card grid with a single-column selector layout that fits the Filament simple-layout container. +3. Move `Add environment` and `Switch workspace` into the top selector header as neutral secondary actions. +4. Add a localized search box, result count, clear action, and no-results state. +5. Hide the default raw `managed_environment` kind label while preserving non-default short labels such as `DEV`. +6. Add stable test IDs for selector, filter, actions, result list, no-results state, and environment cards. +7. Extend focused Feature coverage for markup and Livewire search behavior. + +## UI / Surface Guardrail Plan + +- **Affected surface**: `UI-004` `/admin/choose-environment`. +- **Native vs custom**: existing custom Blade in a Filament Page; no new component family. +- **Action hierarchy**: environment card selection remains the primary page purpose; Add/Switch remain neutral secondary actions. +- **Status semantics**: lifecycle status remains a badge; status colors are not applied to buttons. +- **Dangerous actions**: none. + +## Validation Plan + +- Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Workspaces/ChooseEnvironmentPageTest.php --compact`. +- Run `cd apps/platform && ./vendor/bin/sail pint app/Filament/Pages/ChooseEnvironment.php tests/Feature/Workspaces/ChooseEnvironmentPageTest.php`. +- Run `git diff --check`. +- Verify in the in-app browser at `/admin/choose-environment` for current, mobile, and desktop viewports. + +## Deployment Impact + +- **Env vars**: none. +- **Migrations**: none. +- **Queues / scheduler**: none. +- **Storage**: none. +- **Assets**: no Filament asset registration; normal Vite/Tailwind build applies. `filament:assets` is not newly required by this change. diff --git a/specs/348-choose-environment-enterprise-selector/spec.md b/specs/348-choose-environment-enterprise-selector/spec.md new file mode 100644 index 00000000..de36bf3f --- /dev/null +++ b/specs/348-choose-environment-enterprise-selector/spec.md @@ -0,0 +1,142 @@ +# Feature Specification: Spec 348 - Choose Environment Enterprise Selector + +**Feature Branch**: `348-choose-environment-enterprise-selector` +**Created**: 2026-06-02 +**Status**: Draft +**Type**: Narrow UI productization / operator context selection +**Input**: User asked to implement the enterprise best-practice findings from the in-app browser review of `/admin/choose-environment`. + +## Spec Candidate Check + +- **Problem**: The environment chooser is conceptually correct but its card grid overflows at common desktop/tablet widths, hides secondary actions below the list, and does not scale well when a workspace has many environments. +- **Today's failure**: Long environment names/domains and the raw `MANAGED_ENVIRONMENT` badge can spill out of cards, making the first context-selection step feel unreliable for enterprise operators. +- **User-visible improvement**: The selector becomes a stable single-column work surface with search, top-level secondary actions, localized empty/search states, and card content that cannot overflow. +- **Smallest enterprise-capable version**: Rework the existing `/admin/choose-environment` page and its custom Blade only. Add a Livewire search property and focused Feature tests. +- **Explicit non-goals**: No new route, no new model/table, no new selector framework, no new global design component, no role/capability change, no destructive action, no provider integration change. +- **Permanent complexity imported**: One public Livewire string property, one filtered collection method, localization keys, and focused tests. +- **Why now**: The page is the first environment-context decision after workspace selection, and visible overflow undermines trust before the operator enters an admin workflow. +- **Why not local**: The fix is local and intentionally stays local; a broader selector framework would be disproportionate. +- **Approval class**: Cleanup. +- **Red flags triggered**: UI surface change only. No persistence, status, taxonomy, or abstraction red flags. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12** +- **Decision**: approve. + +## Spec Scope Fields + +- **Scope**: workspace + environment selector. +- **Primary Routes**: `/admin/choose-environment`. +- **Data Ownership**: No data model ownership changes. Existing workspace and managed-environment records remain authoritative. +- **RBAC**: Existing workspace membership, environment entitlement, operability, and deny-as-not-found checks remain authoritative. + +## UI Surface Impact + +- [ ] No UI surface impact +- [x] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [x] New table/form/state added +- [ ] Customer-facing surface changed +- [ ] Dangerous action changed +- [x] Workspace/environment context presentation changed + +## UI/Productization Coverage + +- **Route/page/surface**: `/admin/choose-environment`, `App\Filament\Pages\ChooseEnvironment`, `resources/views/filament/pages/choose-environment.blade.php`. +- **Current or new page archetype**: Workspace / Tenant Context, continuing route inventory row `UI-004`. +- **Design depth**: Domain Pattern Surface. +- **Repo-truth level**: repo-verified. +- **Existing pattern reused**: Filament page, existing environment lifecycle presentation, existing workspace context, existing secondary action destinations. +- **New pattern required**: none. The page keeps a local selector layout. +- **Screenshot required**: no persistent audit screenshot for this narrow cleanup; browser verification is sufficient. +- **Page audit required**: no new report required; route inventory classification stays valid. +- **Customer-safe review required**: no. +- **Dangerous-action review required**: no destructive/high-impact action is added. +- **Coverage files updated or explicitly not needed**: Active spec documents the checked impact; durable route classification `UI-004` remains accurate. + +## Cross-Cutting / Shared Pattern Reuse + +- **Cross-cutting feature?**: yes, narrowly. +- **Interaction classes**: context selector, secondary action links, status badges. +- **Existing patterns to extend**: TenantPilot enterprise UI standards for static/interactive rows, action hierarchy, and status-as-badge semantics. +- **Allowed deviation and why**: none. +- **Consistency impact**: Search and secondary actions must stay neutral; status remains badges; hidden raw provider/technical labels must not dominate the selector. + +## OperationRun UX Impact + +N/A - no OperationRun start, completion, or link semantics touched. + +## Provider Boundary / Platform Core Check + +- **Shared provider/platform boundary touched?**: yes, vocabulary only. +- **Boundary classification**: platform-core selector wording. +- **Seams affected**: visible labels and search matching over existing environment fields. +- **Neutral platform terms preserved**: workspace, environment, active operating context. +- **Provider-specific semantics retained and why**: none added. + +## Proportionality Review + +- **New source of truth?**: no. +- **New persisted entity/table/artifact?**: no. +- **New abstraction?**: no. +- **New enum/state/reason family?**: no. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: overflow and poor scanability on the first environment-context decision. +- **Existing structure is insufficient because**: the current dynamic grid produces columns too narrow for the Filament simple layout. +- **Narrowest correct implementation**: one-page layout adjustment plus one local search property. +- **Ownership cost**: one method and a few localized strings. +- **Alternative intentionally rejected**: new reusable selector component; not justified by one page. +- **Release truth**: current-release cleanup. + +## Testing / Lane / Runtime Impact + +- **Test purpose / classification**: Feature. +- **Validation lane(s)**: fast-feedback. +- **Why sufficient**: The change is server-rendered Blade plus Livewire state; focused HTTP and Livewire Feature assertions prove visibility, filtering, and hidden raw label behavior. +- **New or expanded test families**: none. +- **Fixture/helper cost impact**: uses existing factories and `createUserWithTenant()`. +- **Heavy-family visibility / justification**: no browser test added; in-app browser verification covers the visual regression for this task. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Workspaces/ChooseEnvironmentPageTest.php --compact` + - `cd apps/platform && ./vendor/bin/sail pint app/Filament/Pages/ChooseEnvironment.php tests/Feature/Workspaces/ChooseEnvironmentPageTest.php` + - `git diff --check` + +## User Scenarios & Testing + +### User Story 1 - Select an environment without layout ambiguity (P1) + +As a workspace operator, I need each environment row to stay readable at desktop, tablet, and mobile widths so I can safely choose the intended context. + +**Acceptance Criteria** + +1. The chooser uses a single-column selector layout inside the Filament simple layout. +2. Raw `MANAGED_ENVIRONMENT` labels are not default-visible for ordinary managed environments. +3. Status remains shown as a small badge. + +### User Story 2 - Find one environment in a larger workspace (P1) + +As an MSP/operator, I need search on the selector so I can narrow the list by environment name, domain, status, or visible context. + +**Acceptance Criteria** + +1. Search filters the selectable environment collection without changing authorization scope. +2. The result count is visible while searching. +3. A no-results state provides a clear reset action. + +### User Story 3 - Reach secondary context actions immediately (P2) + +As an operator, I need Add Environment and Switch Workspace visible near the top so I do not scroll past long environment lists for context-management actions. + +**Acceptance Criteria** + +1. Secondary actions render in the selector header. +2. Actions use neutral styling and real routes. +3. No destructive action is introduced. + +## Requirements + +- **FR-348-001**: The selector MUST not use dynamic two-/three-column grids inside the simple page container. +- **FR-348-002**: Environment cards MUST include stable `data-testid` attributes for browser and feature assertions. +- **FR-348-003**: Search MUST use Livewire v4-compatible debounced binding. +- **FR-348-004**: Existing workspace/environment entitlement and operability checks MUST remain unchanged. +- **FR-348-005**: The page MUST remain free of destructive/high-impact actions. diff --git a/specs/348-choose-environment-enterprise-selector/tasks.md b/specs/348-choose-environment-enterprise-selector/tasks.md new file mode 100644 index 00000000..d766c197 --- /dev/null +++ b/specs/348-choose-environment-enterprise-selector/tasks.md @@ -0,0 +1,37 @@ +# Tasks: Spec 348 - Choose Environment Enterprise Selector + +**Input**: `specs/348-choose-environment-enterprise-selector/spec.md`, `plan.md` + +## Phase 1: Implementation + +- [x] T001 Add a local Livewire search property to `ChooseEnvironment`. +- [x] T002 Add a filtered collection helper that reuses the already authorized/selectable environment collection. +- [x] T003 Replace the dynamic multi-column grid with a single-column selector layout. +- [x] T004 Move `Add environment` and `Switch workspace` to the top selector header. +- [x] T005 Add localized search, result count, clear action, and no-results copy. +- [x] T006 Hide the default raw `managed_environment` kind label while preserving non-default short labels. +- [x] T007 Add stable `data-testid` attributes for the selector surface. + +## Phase 2: Tests + +- [x] T008 Extend HTTP Feature assertions for selector search/action test IDs. +- [x] T009 Add Livewire Feature coverage for search filtering and no-results state. +- [x] T010 Run focused Feature tests. + +## Phase 3: Browser Verification + +- [x] T011 Verify `/admin/choose-environment` in the in-app browser at the current viewport. +- [x] T012 Verify no overflow at mobile and desktop viewport sizes. + +## Phase 4: Final Checks + +- [x] T013 Run focused Pint formatting for the changed PHP files. +- [x] T014 Run `git diff --check`. +- [x] T015 Report final Filament/Livewire/global-search/action/assets/testing/deployment posture. + +## Non-Goals + +- [x] NT001 No migration, persisted entity, or new source of truth. +- [x] NT002 No new route or panel provider change. +- [x] NT003 No new selector framework or shared UI taxonomy. +- [x] NT004 No destructive/high-impact action change.