diff --git a/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php b/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php index 0e570051..c07b0f4e 100644 --- a/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php +++ b/apps/platform/app/Filament/Resources/EnvironmentReviewResource.php @@ -415,6 +415,7 @@ public static function getPages(): array { return [ 'index' => Pages\ListEnvironmentReviews::route('/'), + 'resolve-publication' => Pages\ResolveReviewPublication::route('/{record}/resolve-publication'), 'view' => Pages\ViewEnvironmentReview::route('/{record}'), ]; } diff --git a/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ResolveReviewPublication.php b/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ResolveReviewPublication.php new file mode 100644 index 00000000..ab165847 --- /dev/null +++ b/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ResolveReviewPublication.php @@ -0,0 +1,600 @@ +record = $this->resolveRecord($record); + $this->authorizeAccess(); + + $user = auth()->user(); + + if (! $user instanceof User || ! $this->record instanceof EnvironmentReview) { + abort(403); + } + + $caseService = app(ReviewPublicationResolutionService::class); + $case = $user->can('refresh', $this->record) + ? $caseService->openOrResume( + review: $this->record, + actor: $user, + sourceSurface: 'environment_review_detail', + ) + : $caseService->activeCaseForReview($this->record); + + if ($case instanceof ReviewPublicationResolutionCase && ! $user->can('view', $case)) { + abort(404); + } + + if (! $user->can('refresh', $this->record) && ! ($case instanceof ReviewPublicationResolutionCase)) { + abort(403); + } + + if (! $case instanceof ReviewPublicationResolutionCase) { + Notification::make() + ->success() + ->title('Review is ready for publication') + ->body('No publication resolution case was needed for the current review state.') + ->send(); + + $this->redirect(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $this->record], $this->record->tenant)); + + return; + } + + $this->resolutionCaseId = (int) $case->getKey(); + $this->heading = $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value + ? 'Review is ready to return to publication' + : 'Review can\'t be published yet'; + $this->subheading = $this->record->tenant?->getFilamentName(); + } + + public function getTitle(): string + { + return $this->pageOutcomeTitle(); + } + + public function getHeading(): string + { + return $this->pageOutcomeTitle(); + } + + protected function resolveRecord(int|string $key): Model + { + return EnvironmentReviewResource::resolveScopedRecordOrFail($key); + } + + protected function getHeaderActions(): array + { + return array_values(array_filter([ + $this->executeCurrentStepAction(), + Actions\Action::make('back_to_review') + ->label('Back to review') + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url(fn (): string => $this->reviewUrl()), + Actions\ActionGroup::make([ + $this->cancelResolutionAction(), + ]) + ->label('More') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray') + ->visible(fn (): bool => $this->resolutionCase()?->statusEnum()->isActive() ?? false), + ])); + } + + /** + * @return array + */ + public function caseState(): array + { + $case = $this->resolutionCase(); + + if (! $case instanceof ReviewPublicationResolutionCase) { + return [ + 'case' => null, + 'steps' => [], + ]; + } + + $case->loadMissing(['steps.operationRun', 'environmentReview.tenant', 'tenant']); + $tenant = $case->tenant; + $currentStep = $case->currentStep(); + + return [ + 'case' => [ + 'id' => (int) $case->getKey(), + 'status' => (string) $case->status, + 'status_label' => $this->statusLabel((string) $case->status), + 'status_color' => $this->caseStatusColor((string) $case->status), + 'current_step_key' => $case->current_step_key, + 'summary' => is_array($case->summary) ? $case->summary : [], + 'created_at' => $case->created_at?->diffForHumans(), + 'last_evaluated_at' => $case->last_evaluated_at?->diffForHumans(), + ], + 'decision' => $this->decisionState($case, $currentStep), + 'steps' => $case->steps + ->map(fn (ReviewPublicationResolutionStep $step): array => $this->stepState($step, $tenant)) + ->values() + ->all(), + ]; + } + + private function executeCurrentStepAction(): Actions\Action + { + $action = Actions\Action::make('execute_current_step') + ->label(fn (): string => $this->currentStepActionLabel()) + ->icon(fn (): string => $this->currentStepActionIcon()) + ->color('primary') + ->visible(fn (): bool => $this->hasExecutableCurrentStep()) + ->disabled(fn (): bool => $this->currentStepIsRunning() || ! $this->canExecuteCurrentStep()) + ->tooltip(fn (): ?string => $this->currentStepActionTooltip()) + ->requiresConfirmation() + ->modalHeading(fn (): string => $this->currentStepActionLabel()) + ->modalDescription(fn (): string => $this->currentStepConfirmationDescription()) + ->modalSubmitActionLabel('Continue') + ->action(function (): void { + $case = $this->resolutionCase(); + $user = auth()->user(); + + if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) { + abort(403); + } + + if (! $this->canExecuteCurrentStep()) { + abort(403); + } + + $result = app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $user); + $updatedCase = $result['case']; + $this->resolutionCaseId = (int) $updatedCase->getKey(); + + if ($result['operation_run'] instanceof OperationRun && is_string($result['operation_type'])) { + OperationUxPresenter::queuedToast($result['operation_type'])->send(); + + return; + } + + Notification::make() + ->success() + ->title('Resolution step completed') + ->send(); + + if ($updatedCase->status === ReviewPublicationResolutionCaseStatus::Completed->value) { + $this->redirect($this->reviewUrl()); + } + }); + + return $action; + } + + private function cancelResolutionAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('cancel_resolution') + ->label('Cancel resolution') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->visible(fn (): bool => $this->resolutionCase()?->statusEnum()->isActive() ?? false) + ->requiresConfirmation() + ->modalHeading('Cancel publication resolution') + ->modalDescription('This only cancels the TenantPilot resolution case. It does not modify the provider tenant or publish the review.') + ->action(function (): void { + $case = $this->resolutionCase(); + $user = auth()->user(); + + if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) { + abort(403); + } + + app(ReviewPublicationResolutionService::class)->cancel($case, $user); + + Notification::make() + ->success() + ->title('Resolution cancelled') + ->send(); + + $this->redirect($this->reviewUrl()); + }), + ) + ->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE) + ->preserveVisibility() + ->apply(); + } + + private function authorizeAccess(): void + { + $tenant = EnvironmentReviewResource::panelTenantContext(); + $record = $this->getRecord(); + $user = auth()->user(); + + if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $record instanceof EnvironmentReview) { + abort(404); + } + + if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) { + abort(404); + } + + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + if (! $user->can('view', $record)) { + abort(403); + } + } + + private function resolutionCase(): ?ReviewPublicationResolutionCase + { + if (! is_numeric($this->resolutionCaseId)) { + return null; + } + + $case = ReviewPublicationResolutionCase::query() + ->with(['steps.operationRun', 'environmentReview.tenant', 'tenant']) + ->find((int) $this->resolutionCaseId); + + if (! $case instanceof ReviewPublicationResolutionCase) { + return null; + } + + $user = auth()->user(); + + if (! $user instanceof User || ! $user->can('view', $case)) { + abort(403); + } + + return $case; + } + + private function pageOutcomeTitle(): string + { + $case = $this->resolutionCase(); + + if ($case instanceof ReviewPublicationResolutionCase + && $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value) { + return 'Review is ready to return to publication'; + } + + return 'Review can\'t be published yet'; + } + + private function currentStep(): ?ReviewPublicationResolutionStep + { + return $this->resolutionCase()?->currentStep(); + } + + private function hasExecutableCurrentStep(): bool + { + $step = $this->currentStep(); + + if (! $step instanceof ReviewPublicationResolutionStep) { + return false; + } + + return $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Pending; + } + + private function currentStepIsRunning(): bool + { + return $this->currentStep()?->statusEnum() === ReviewPublicationResolutionStepStatus::Running; + } + + private function canExecuteCurrentStep(): bool + { + $case = $this->resolutionCase(); + $user = auth()->user(); + + if (! $case instanceof ReviewPublicationResolutionCase || ! $user instanceof User) { + return false; + } + + return app(ReviewPublicationResolutionStepAuthorizer::class)->canExecuteCurrentStep($user, $case); + } + + private function currentStepActionTooltip(): ?string + { + if ($this->currentStepIsRunning()) { + return 'The linked operation is still running.'; + } + + return $this->canExecuteCurrentStep() ? null : AuthUiTooltips::insufficientPermission(); + } + + private function currentStepActionLabel(): string + { + return $this->currentStepActionLabelFor($this->currentStep()?->stepKeyEnum()); + } + + private function currentStepActionLabelFor(?ReviewPublicationResolutionStepKey $stepKey): string + { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Update required reports', + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Collect evidence', + ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Refresh review', + ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Generate review pack', + ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to publication', + default => 'Continue', + }; + } + + private function currentStepActionIcon(): string + { + return $this->currentStepActionIconFor($this->currentStep()?->stepKeyEnum()); + } + + private function currentStepActionIconFor(?ReviewPublicationResolutionStepKey $stepKey): string + { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::CompleteRequiredReports, + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot, + ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'heroicon-o-arrow-path', + ReviewPublicationResolutionStepKey::GenerateReviewPack => 'heroicon-o-arrow-down-tray', + ReviewPublicationResolutionStepKey::ReturnToPublication => 'heroicon-o-check-badge', + default => 'heroicon-o-play', + }; + } + + private function reviewUrl(): string + { + $record = $this->getRecord(); + + if (! $record instanceof EnvironmentReview) { + return url('/admin'); + } + + return EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $record], $record->tenant); + } + + /** + * @return array + */ + private function decisionState(ReviewPublicationResolutionCase $case, ?ReviewPublicationResolutionStep $currentStep): array + { + $summary = is_array($case->summary) ? $case->summary : []; + $missingReports = collect((array) ($summary['missing_report_dimensions'] ?? [])) + ->map(fn (mixed $dimension): string => $this->reportRequirementLabel((string) $dimension)) + ->filter(static fn (string $dimension): bool => $dimension !== '') + ->values() + ->all(); + $blockers = collect((array) ($summary['publication_blockers'] ?? [])) + ->map(fn (mixed $blocker): string => $this->operatorBlockerLabel((string) $blocker)) + ->filter(static fn (string $blocker): bool => $blocker !== '') + ->unique() + ->values() + ->all(); + $blockerCount = max((int) ($summary['publication_blocker_count'] ?? 0), count($blockers), count($missingReports)); + $stepKey = $currentStep?->stepKeyEnum(); + $readyToReturn = (string) $case->status === ReviewPublicationResolutionCaseStatus::ReadyToContinue->value + || $stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication; + + return [ + 'headline' => $readyToReturn ? 'Review is ready to return to publication' : 'Review can\'t be published yet', + 'status_badge_label' => $readyToReturn ? 'Ready to continue' : 'Publication blocked', + 'status_badge_color' => $readyToReturn ? 'success' : 'warning', + 'blocked_summary' => $this->blockedSummary($blockerCount, count($missingReports)), + 'blockers' => $blockers, + 'missing_reports' => $missingReports, + 'next_action_label' => $this->currentStepActionLabelFor($stepKey), + 'next_action_description' => $this->nextStepDescription($stepKey), + 'after_this' => $this->afterNextStepDescription($stepKey), + ]; + } + + /** + * @return array + */ + private function stepState(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): array + { + $operationRun = $step->operationRun; + $stepKey = $step->stepKeyEnum(); + + return [ + 'key' => (string) $step->step_key, + 'label' => (string) data_get($step->summary, 'label', $step->step_key), + 'operator_label' => $this->operatorStepLabel($stepKey), + 'description' => (string) data_get($step->summary, 'description', ''), + 'operator_description' => $this->operatorStepDescription($stepKey), + 'state_description' => (string) data_get($step->summary, 'state_description', ''), + 'status' => (string) $step->status, + 'status_label' => $this->statusLabel((string) $step->status), + 'status_color' => $this->stepStatusColor((string) $step->status), + 'proof_type' => $step->proof_type, + 'proof_id' => $step->proof_id, + 'proof_status' => $step->proof_status, + 'proof_url' => $this->proofUrl($step, $tenant), + 'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, + 'operation_url' => $operationRun instanceof OperationRun && $tenant instanceof ManagedEnvironment + ? OperationRunLinks::view($operationRun, $tenant) + : null, + ]; + } + + private function proofUrl(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): ?string + { + if (! $tenant instanceof ManagedEnvironment || ! is_numeric($step->proof_id) || ! is_string($step->proof_type)) { + return null; + } + + return match ($step->proof_type) { + 'environment_review' => EnvironmentReviewResource::environmentScopedUrl('view', ['record' => (int) $step->proof_id], $tenant), + 'evidence_snapshot' => EvidenceSnapshotResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant), + 'review_pack' => ReviewPackResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant), + default => null, + }; + } + + private function statusLabel(string $status): string + { + return str($status)->replace('_', ' ')->title()->toString(); + } + + private function caseStatusColor(string $status): string + { + return match ($status) { + ReviewPublicationResolutionCaseStatus::Completed->value => 'success', + ReviewPublicationResolutionCaseStatus::Blocked->value => 'danger', + ReviewPublicationResolutionCaseStatus::WaitingForRun->value => 'info', + ReviewPublicationResolutionCaseStatus::ReadyToContinue->value => 'warning', + ReviewPublicationResolutionCaseStatus::Cancelled->value, + ReviewPublicationResolutionCaseStatus::Superseded->value => 'gray', + default => 'primary', + }; + } + + private function stepStatusColor(string $status): string + { + return match ($status) { + ReviewPublicationResolutionStepStatus::Completed->value => 'success', + ReviewPublicationResolutionStepStatus::Failed->value => 'danger', + ReviewPublicationResolutionStepStatus::Running->value => 'info', + ReviewPublicationResolutionStepStatus::Actionable->value => 'warning', + ReviewPublicationResolutionStepStatus::Superseded->value => 'gray', + default => 'gray', + }; + } + + private function reportRequirementLabel(string $dimension): string + { + return match ($dimension) { + 'permission_posture' => 'Permission posture', + 'entra_admin_roles' => 'Entra admin roles', + default => str($dimension)->replace('_', ' ')->title()->toString(), + }; + } + + private function nextStepDescription(?ReviewPublicationResolutionStepKey $stepKey): string + { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'TenantPilot will update the missing required reports. It will not publish the review.', + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'This will collect a current evidence snapshot for the review. It will not publish the review.', + ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'This will refresh the review from current evidence. It will not publish the review.', + ReviewPublicationResolutionStepKey::GenerateReviewPack => 'This will generate the review pack needed for customer-ready output. It will not publish the review.', + ReviewPublicationResolutionStepKey::ReturnToPublication => 'All checks are resolved. Return to the review and use the existing publish action when you are ready.', + default => 'TenantPilot will continue the next safe preparation step. It will not publish the review.', + }; + } + + private function afterNextStepDescription(?ReviewPublicationResolutionStepKey $stepKey): string + { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::ReturnToPublication => 'Publishing remains a separate action on the review page.', + default => 'After this, TenantPilot re-checks the evidence, refreshes the review if needed, generates the review pack if needed, and sends you back to the review. Publishing remains a separate action.', + }; + } + + private function currentStepConfirmationDescription(): string + { + return match ($this->currentStep()?->stepKeyEnum()) { + ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'TenantPilot will update the missing required reports. This will not publish the review.', + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'TenantPilot will collect a current evidence snapshot for this review. This will not publish the review.', + ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'TenantPilot will refresh the review from current evidence. This will not publish the review.', + ReviewPublicationResolutionStepKey::GenerateReviewPack => 'TenantPilot will generate the review pack needed for customer-ready output. This will not publish the review.', + ReviewPublicationResolutionStepKey::ReturnToPublication => 'TenantPilot will return you to the review. Publishing remains a separate action.', + default => 'TenantPilot will continue the next safe preparation step. This will not publish the review.', + }; + } + + private function blockedSummary(int $blockerCount, int $missingReportCount): string + { + if ($missingReportCount > 0) { + return sprintf( + 'TenantPilot found %d required %s that must be updated before this review can become customer-ready.', + $missingReportCount, + $missingReportCount === 1 ? 'report' : 'reports', + ); + } + + if ($blockerCount > 0) { + return sprintf( + 'TenantPilot found %d missing %s before this review can become customer-ready.', + $blockerCount, + $blockerCount === 1 ? 'requirement' : 'requirements', + ); + } + + return 'TenantPilot is checking the remaining publication prerequisites before this review returns to the publish action.'; + } + + private function operatorBlockerLabel(string $blocker): string + { + $blocker = trim($blocker); + + if ($blocker === '') { + return ''; + } + + if (str($blocker)->lower()->contains('report-backed evidence')) { + return 'Required reports are missing.'; + } + + return $blocker; + } + + private function operatorStepLabel(?ReviewPublicationResolutionStepKey $stepKey): string + { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Check readiness', + ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Update required reports', + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Collect evidence snapshot', + ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Refresh review', + ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Generate review pack', + ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to publication', + default => 'Continue preparation', + }; + } + + private function operatorStepDescription(?ReviewPublicationResolutionStepKey $stepKey): string + { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'TenantPilot checks whether the review is safe to prepare for publication.', + ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required reports must be current before publication can continue.', + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'The review needs a complete and current evidence snapshot.', + ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'The review is rebuilt from the latest evidence and report state.', + ReviewPublicationResolutionStepKey::GenerateReviewPack => 'A current review pack is prepared before returning to publication.', + ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to the review and decide whether to publish.', + default => 'TenantPilot prepares the next safe publication prerequisite.', + }; + } +} diff --git a/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php b/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php index 54c0e546..5cb961e8 100644 --- a/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php +++ b/apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php @@ -11,15 +11,15 @@ use App\Models\ReviewPack; use App\Models\User; use App\Services\Audit\WorkspaceAuditLogger; -use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate; use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService; +use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate; use App\Services\EnvironmentReviews\EnvironmentReviewService; -use App\Services\ReviewPackService; use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\EnvironmentReviewStatus; use App\Support\Rbac\UiEnforcement; use App\Support\ReviewPackStatus; +use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionService; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; use Filament\Actions; use Filament\Forms\Components\Textarea; @@ -101,6 +101,7 @@ protected function getHeaderActions(): array private function primaryLifecycleAction(): ?Actions\Action { return match ($this->primaryLifecycleActionName()) { + 'resolve_publication_blockers' => $this->resolvePublicationBlockersAction(), 'refresh_review' => $this->refreshReviewAction(), 'publish_review' => $this->publishReviewAction(), 'create_next_review' => $this->createNextReviewAction(), @@ -116,6 +117,10 @@ private function primaryLifecycleActionName(): ?string return null; } + if ($this->shouldOfferPublicationResolution()) { + return 'resolve_publication_blockers'; + } + $mappedPrimaryActionName = $this->mappedPrimaryLifecycleActionName(); if ($mappedPrimaryActionName !== null) { @@ -144,6 +149,7 @@ private function secondaryLifecycleActions(): array { return array_values(array_filter(array_map( fn (string $name): ?Actions\Action => match ($name) { + 'resolve_publication_blockers' => $this->resolvePublicationBlockersAction(), 'refresh_review' => $this->refreshReviewAction(), 'publish_review' => $this->publishReviewAction(), 'export_executive_pack' => $this->exportExecutivePackAction(), @@ -230,6 +236,50 @@ private function refreshReviewAction(): Actions\Action ->apply(); } + private function resolvePublicationBlockersAction(): Actions\Action + { + return UiEnforcement::forAction( + Actions\Action::make('resolve_publication_blockers') + ->label('Resolve publication blockers') + ->icon('heroicon-o-wrench-screwdriver') + ->color('primary') + ->hidden(fn (): bool => ! $this->shouldOfferPublicationResolution()) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $case = app(ReviewPublicationResolutionService::class)->openOrResume( + review: $this->record, + actor: $user, + sourceSurface: 'environment_review_detail', + ); + + if ($case === null) { + Notification::make() + ->success() + ->title('Review is ready for publication') + ->send(); + + $this->outputGuidanceStateCache = null; + + return; + } + + $this->redirect(EnvironmentReviewResource::environmentScopedUrl( + 'resolve-publication', + ['record' => $this->record], + $this->record->tenant, + )); + }), + ) + ->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE) + ->preserveVisibility() + ->apply(); + } + private function publishReviewAction(): Actions\Action { $rule = GovernanceActionCatalog::rule('publish_review'); @@ -456,6 +506,11 @@ private function canPublishCurrentReview(): bool return app(EnvironmentReviewReadinessGate::class)->canPublish($this->record); } + private function shouldOfferPublicationResolution(): bool + { + return $this->record->isMutable() && ! $this->canPublishCurrentReview(); + } + private function refreshReviewFeedbackBody(): string { return data_get($this->outputGuidanceState(), 'resolution_case.primary_action.key') === 'publish_review' @@ -468,6 +523,7 @@ private function mappedPrimaryLifecycleActionName(): ?string $actionName = data_get($this->outputGuidanceState(), 'resolution_case.primary_action.action_name'); return is_string($actionName) && in_array($actionName, [ + 'resolve_publication_blockers', 'refresh_review', 'publish_review', 'create_next_review', diff --git a/apps/platform/app/Models/ReviewPublicationResolutionCase.php b/apps/platform/app/Models/ReviewPublicationResolutionCase.php new file mode 100644 index 00000000..4459bbc9 --- /dev/null +++ b/apps/platform/app/Models/ReviewPublicationResolutionCase.php @@ -0,0 +1,154 @@ + + */ + protected function casts(): array + { + return [ + 'summary' => 'array', + 'metadata' => 'array', + 'last_evaluated_at' => 'datetime', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'superseded_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(ManagedEnvironment::class, 'managed_environment_id'); + } + + /** + * @return BelongsTo + */ + public function environmentReview(): BelongsTo + { + return $this->belongsTo(EnvironmentReview::class); + } + + /** + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + /** + * @return BelongsTo + */ + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to_user_id'); + } + + /** + * @return HasMany + */ + public function steps(): HasMany + { + return $this->hasMany(ReviewPublicationResolutionStep::class, 'case_id') + ->orderBy('position') + ->orderBy('id'); + } + + public function currentStep(): ?ReviewPublicationResolutionStep + { + $currentStepKey = $this->current_step_key; + + if (! is_string($currentStepKey) || $currentStepKey === '') { + return null; + } + + if ($this->relationLoaded('steps')) { + $step = $this->steps->firstWhere('step_key', $currentStepKey); + + return $step instanceof ReviewPublicationResolutionStep ? $step : null; + } + + return $this->steps()->where('step_key', $currentStepKey)->first(); + } + + public function statusEnum(): ReviewPublicationResolutionCaseStatus + { + return ReviewPublicationResolutionCaseStatus::tryFrom((string) $this->status) + ?? ReviewPublicationResolutionCaseStatus::Open; + } + + public function isActive(): bool + { + return $this->statusEnum()->isActive(); + } + + public function currentStepKeyEnum(): ?ReviewPublicationResolutionStepKey + { + return ReviewPublicationResolutionStepKey::tryFrom((string) $this->current_step_key); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForWorkspace(Builder $query, int $workspaceId): Builder + { + return $query->where('workspace_id', $workspaceId); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForTenant(Builder $query, int $tenantId): Builder + { + return $query->where('managed_environment_id', $tenantId); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForReview(Builder $query, EnvironmentReview|int $review): Builder + { + return $query->where('environment_review_id', $review instanceof EnvironmentReview ? (int) $review->getKey() : $review); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereIn('status', ReviewPublicationResolutionCaseStatus::activeValues()); + } +} diff --git a/apps/platform/app/Models/ReviewPublicationResolutionStep.php b/apps/platform/app/Models/ReviewPublicationResolutionStep.php new file mode 100644 index 00000000..b030d68f --- /dev/null +++ b/apps/platform/app/Models/ReviewPublicationResolutionStep.php @@ -0,0 +1,83 @@ + + */ + protected function casts(): array + { + return [ + 'summary' => 'array', + 'metadata' => 'array', + 'position' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'failed_at' => 'datetime', + 'superseded_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function case(): BelongsTo + { + return $this->belongsTo(ReviewPublicationResolutionCase::class, 'case_id'); + } + + /** + * @return BelongsTo + */ + public function operationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class); + } + + public function statusEnum(): ReviewPublicationResolutionStepStatus + { + return ReviewPublicationResolutionStepStatus::tryFrom((string) $this->status) + ?? ReviewPublicationResolutionStepStatus::Pending; + } + + public function stepKeyEnum(): ?ReviewPublicationResolutionStepKey + { + return ReviewPublicationResolutionStepKey::tryFrom((string) $this->step_key); + } + + /** + * @return array{type:string,id:int}|null + */ + public function proofReference(): ?array + { + if (! is_string($this->proof_type) || $this->proof_type === '' || ! is_numeric($this->proof_id)) { + return null; + } + + return [ + 'type' => $this->proof_type, + 'id' => (int) $this->proof_id, + ]; + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('position')->orderBy('id'); + } +} diff --git a/apps/platform/app/Policies/ReviewPublicationResolutionCasePolicy.php b/apps/platform/app/Policies/ReviewPublicationResolutionCasePolicy.php new file mode 100644 index 00000000..cca7c32e --- /dev/null +++ b/apps/platform/app/Policies/ReviewPublicationResolutionCasePolicy.php @@ -0,0 +1,90 @@ +authorizedTenantOrNull($user, $case); + + if (! $tenant instanceof ManagedEnvironment) { + return Response::denyAsNotFound(); + } + + return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::ENVIRONMENT_REVIEW_VIEW) + ? true + : Response::deny(); + } + + public function executeStep(User $user, ReviewPublicationResolutionCase $case): Response|bool + { + $tenant = $this->authorizedTenantOrNull($user, $case); + + if (! $tenant instanceof ManagedEnvironment) { + return Response::denyAsNotFound(); + } + + return app(ReviewPublicationResolutionStepAuthorizer::class)->canExecuteCurrentStep($user, $case) + ? true + : Response::deny(); + } + + public function cancel(User $user, ReviewPublicationResolutionCase $case): Response|bool + { + return $this->authorizeManageAction($user, $case); + } + + private function authorizeManageAction(User $user, ReviewPublicationResolutionCase $case): Response|bool + { + $tenant = $this->authorizedTenantOrNull($user, $case); + + if (! $tenant instanceof ManagedEnvironment) { + return Response::denyAsNotFound(); + } + + return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::ENVIRONMENT_REVIEW_MANAGE) + ? true + : Response::deny(); + } + + private function authorizedTenantOrNull(User $user, ReviewPublicationResolutionCase $case): ?ManagedEnvironment + { + $case->loadMissing(['tenant', 'environmentReview']); + + $tenant = $case->tenant; + $review = $case->environmentReview; + + if (! $tenant instanceof ManagedEnvironment || ! $review instanceof EnvironmentReview) { + return null; + } + + if (! $user->canAccessTenant($tenant)) { + return null; + } + + if ((int) $case->workspace_id !== (int) $tenant->workspace_id) { + return null; + } + + if ((int) $review->workspace_id !== (int) $case->workspace_id || (int) $review->managed_environment_id !== (int) $tenant->getKey()) { + return null; + } + + return $tenant; + } +} diff --git a/apps/platform/app/Providers/AuthServiceProvider.php b/apps/platform/app/Providers/AuthServiceProvider.php index 85782ecd..ffe6a2ba 100644 --- a/apps/platform/app/Providers/AuthServiceProvider.php +++ b/apps/platform/app/Providers/AuthServiceProvider.php @@ -5,22 +5,24 @@ use App\Models\AlertDelivery; use App\Models\AlertDestination; use App\Models\AlertRule; +use App\Models\EnvironmentReview; +use App\Models\ManagedEnvironment; +use App\Models\ManagedEnvironmentOnboardingSession; use App\Models\PlatformUser; use App\Models\ProviderConnection; use App\Models\ProviderResourceBinding; -use App\Models\ManagedEnvironment; -use App\Models\ManagedEnvironmentOnboardingSession; -use App\Models\EnvironmentReview; +use App\Models\ReviewPublicationResolutionCase; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceSetting; use App\Policies\AlertDeliveryPolicy; use App\Policies\AlertDestinationPolicy; use App\Policies\AlertRulePolicy; +use App\Policies\EnvironmentReviewPolicy; +use App\Policies\ManagedEnvironmentOnboardingSessionPolicy; use App\Policies\ProviderConnectionPolicy; use App\Policies\ProviderResourceBindingPolicy; -use App\Policies\ManagedEnvironmentOnboardingSessionPolicy; -use App\Policies\EnvironmentReviewPolicy; +use App\Policies\ReviewPublicationResolutionCasePolicy; use App\Policies\WorkspaceSettingPolicy; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; @@ -36,6 +38,7 @@ class AuthServiceProvider extends ServiceProvider ProviderResourceBinding::class => ProviderResourceBindingPolicy::class, ManagedEnvironmentOnboardingSession::class => ManagedEnvironmentOnboardingSessionPolicy::class, EnvironmentReview::class => EnvironmentReviewPolicy::class, + ReviewPublicationResolutionCase::class => ReviewPublicationResolutionCasePolicy::class, WorkspaceSetting::class => WorkspaceSettingPolicy::class, AlertDestination::class => AlertDestinationPolicy::class, AlertDelivery::class => AlertDeliveryPolicy::class, diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 4b808fe9..e2346fbe 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -115,6 +115,15 @@ enum AuditActionId: string case EnvironmentReviewExported = 'environment_review.exported'; case EnvironmentReviewAcknowledged = 'environment_review.acknowledged'; case EnvironmentReviewSuccessorCreated = 'environment_review.successor_created'; + case ReviewPublicationResolutionCreated = 'review_publication_resolution.created'; + case ReviewPublicationResolutionResumed = 'review_publication_resolution.resumed'; + case ReviewPublicationResolutionStepStarted = 'review_publication_resolution.step_started'; + case ReviewPublicationResolutionOperationLinked = 'review_publication_resolution.operation_linked'; + case ReviewPublicationResolutionStepCompleted = 'review_publication_resolution.step_completed'; + case ReviewPublicationResolutionStepFailed = 'review_publication_resolution.step_failed'; + case ReviewPublicationResolutionCompleted = 'review_publication_resolution.completed'; + case ReviewPublicationResolutionCancelled = 'review_publication_resolution.cancelled'; + case ReviewPublicationResolutionSuperseded = 'review_publication_resolution.superseded'; case CustomerReviewWorkspaceOpened = 'customer_review_workspace.opened'; case ReviewPackDownloaded = 'review_pack.downloaded'; case ManagementReportPdfGenerationRequested = 'management_report_pdf.generation_requested'; @@ -294,6 +303,15 @@ private static function labels(): array self::EnvironmentReviewExported->value => 'ManagedEnvironment review exported', self::EnvironmentReviewAcknowledged->value => 'ManagedEnvironment review acknowledged', self::EnvironmentReviewSuccessorCreated->value => 'ManagedEnvironment review next cycle created', + self::ReviewPublicationResolutionCreated->value => 'Review publication resolution created', + self::ReviewPublicationResolutionResumed->value => 'Review publication resolution resumed', + self::ReviewPublicationResolutionStepStarted->value => 'Review publication resolution step started', + self::ReviewPublicationResolutionOperationLinked->value => 'Review publication resolution operation linked', + self::ReviewPublicationResolutionStepCompleted->value => 'Review publication resolution step completed', + self::ReviewPublicationResolutionStepFailed->value => 'Review publication resolution step failed', + self::ReviewPublicationResolutionCompleted->value => 'Review publication resolution completed', + self::ReviewPublicationResolutionCancelled->value => 'Review publication resolution cancelled', + self::ReviewPublicationResolutionSuperseded->value => 'Review publication resolution superseded', self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened', self::ReviewPackDownloaded->value => 'Review pack downloaded', self::ManagementReportPdfGenerationRequested->value => 'Management report PDF generation requested', @@ -417,6 +435,15 @@ private static function summaries(): array self::EnvironmentReviewExported->value => 'ManagedEnvironment review exported', self::EnvironmentReviewAcknowledged->value => 'ManagedEnvironment review acknowledged', self::EnvironmentReviewSuccessorCreated->value => 'ManagedEnvironment review next cycle created', + self::ReviewPublicationResolutionCreated->value => 'Review publication resolution created', + self::ReviewPublicationResolutionResumed->value => 'Review publication resolution resumed', + self::ReviewPublicationResolutionStepStarted->value => 'Review publication resolution step started', + self::ReviewPublicationResolutionOperationLinked->value => 'Review publication resolution operation linked', + self::ReviewPublicationResolutionStepCompleted->value => 'Review publication resolution step completed', + self::ReviewPublicationResolutionStepFailed->value => 'Review publication resolution step failed', + self::ReviewPublicationResolutionCompleted->value => 'Review publication resolution completed', + self::ReviewPublicationResolutionCancelled->value => 'Review publication resolution cancelled', + self::ReviewPublicationResolutionSuperseded->value => 'Review publication resolution superseded', self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened', self::ReviewPackDownloaded->value => 'Review pack downloaded', self::ManagementReportPdfGenerationRequested->value => 'Management report PDF generation requested', diff --git a/apps/platform/app/Support/ResolutionGuidance/ReviewOutputResolveActionMapper.php b/apps/platform/app/Support/ResolutionGuidance/ReviewOutputResolveActionMapper.php index 8da762cc..af01e57f 100644 --- a/apps/platform/app/Support/ResolutionGuidance/ReviewOutputResolveActionMapper.php +++ b/apps/platform/app/Support/ResolutionGuidance/ReviewOutputResolveActionMapper.php @@ -176,6 +176,22 @@ private static function candidatePrimaryAction( } if ($review->isMutable() && $state !== ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY) { + if ($sourceSurface === 'environment_review_detail') { + return [ + 'key' => 'resolve_publication_blockers', + 'label' => 'Resolve publication blockers', + 'type' => ResolutionAction::TYPE_DOMAIN_ACTION, + 'url' => null, + 'icon' => 'heroicon-o-wrench-screwdriver', + 'kind' => 'environment_action', + 'action_name' => 'resolve_publication_blockers', + 'capability' => Capabilities::ENVIRONMENT_REVIEW_VIEW, + 'requires_confirmation' => false, + 'audit_event' => AuditActionId::ReviewPublicationResolutionCreated->value, + 'operation_run_type' => null, + ]; + } + if (! $canManageReview || ! self::supportsExecutableAction($sourceSurface, 'refresh_review')) { return null; } @@ -229,6 +245,7 @@ private static function supportsExecutableAction(string $sourceSurface, string $ return match ($sourceSurface) { 'customer_review_workspace' => $actionName === 'create_next_review', 'environment_review_detail' => in_array($actionName, [ + 'resolve_publication_blockers', 'refresh_review', 'publish_review', 'create_next_review', diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationProofResolver.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationProofResolver.php new file mode 100644 index 00000000..09f3e669 --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationProofResolver.php @@ -0,0 +1,85 @@ +loadMissing(['evidenceSnapshot', 'currentExportReviewPack', 'operationRun']); + + return match ($stepKey) { + ReviewPublicationResolutionStepKey::ValidateReviewReadiness, + ReviewPublicationResolutionStepKey::RefreshReviewComposition, + ReviewPublicationResolutionStepKey::ReturnToPublication => [ + 'proof_type' => 'environment_review', + 'proof_id' => (int) $review->getKey(), + 'proof_status' => (string) $review->status, + 'operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null, + ], + ReviewPublicationResolutionStepKey::CompleteRequiredReports, + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => $this->evidenceProof($review->evidenceSnapshot), + ReviewPublicationResolutionStepKey::GenerateReviewPack => $this->reviewPackProof($review->currentExportReviewPack), + }; + } + + /** + * @return array{proof_type:?string, proof_id:?int, proof_status:?string, operation_run_id:?int} + */ + public function evidenceProof(?EvidenceSnapshot $snapshot): array + { + if (! $snapshot instanceof EvidenceSnapshot) { + return [ + 'proof_type' => null, + 'proof_id' => null, + 'proof_status' => EvidenceSnapshotStatus::Failed->value, + 'operation_run_id' => null, + ]; + } + + return [ + 'proof_type' => 'evidence_snapshot', + 'proof_id' => (int) $snapshot->getKey(), + 'proof_status' => (string) $snapshot->status, + 'operation_run_id' => is_numeric($snapshot->operation_run_id) ? (int) $snapshot->operation_run_id : null, + ]; + } + + /** + * @return array{proof_type:?string, proof_id:?int, proof_status:?string, operation_run_id:?int} + */ + public function reviewPackProof(?ReviewPack $reviewPack): array + { + if (! $reviewPack instanceof ReviewPack) { + return [ + 'proof_type' => null, + 'proof_id' => null, + 'proof_status' => ReviewPackStatus::Failed->value, + 'operation_run_id' => null, + ]; + } + + return [ + 'proof_type' => 'review_pack', + 'proof_id' => (int) $reviewPack->getKey(), + 'proof_status' => (string) $reviewPack->status, + 'operation_run_id' => is_numeric($reviewPack->operation_run_id) ? (int) $reviewPack->operation_run_id : null, + ]; + } +} diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationReadinessEvaluator.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationReadinessEvaluator.php new file mode 100644 index 00000000..d75a568e --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationReadinessEvaluator.php @@ -0,0 +1,203 @@ +, + * evidence_incomplete:bool, + * evidence_state:string, + * report_dimension_states:array, + * review_requires_refresh:bool, + * review_status:string, + * review_completeness_state:string, + * publication_blockers:list, + * guidance_state:string, + * readiness:array, + * guidance:array, + * has_ready_export:bool, + * current_export_review_pack_id:?int, + * current_evidence_snapshot_id:?int, + * review_operation_run_id:?int, + * scope:array + * } + */ + public function evaluate(EnvironmentReview $review): array + { + $review->loadMissing([ + 'tenant', + 'sections', + 'evidenceSnapshot.items', + 'operationRun', + 'currentExportReviewPack.operationRun', + ]); + + $readiness = ReviewPackOutputResolutionGuidance::readinessForReview($review); + $guidance = ReviewPackOutputResolutionGuidance::fromReadiness($readiness); + $snapshot = $review->evidenceSnapshot; + $pack = $review->currentExportReviewPack; + $publicationBlockers = $this->publicationBlockers($review); + $canPublish = $this->readinessGate->canPublish($review); + $reportDimensionStates = $this->reportDimensionStates($snapshot); + $missingReportDimensions = $this->missingReportDimensions($reportDimensionStates); + $evidenceState = $snapshot instanceof EvidenceSnapshot + ? (string) $snapshot->completeness_state + : EvidenceCompletenessState::Missing->value; + $evidenceIncomplete = $snapshot === null + || $evidenceState !== EvidenceCompletenessState::Complete->value + || (int) data_get($snapshot->summary, 'missing_dimensions', 0) > 0 + || (int) data_get($snapshot->summary, 'stale_dimensions', 0) > 0; + $reviewStatus = (string) $review->status; + $reviewCompleteness = (string) $review->completeness_state; + $hasReadyExport = (bool) ($readiness['has_ready_export'] ?? false); + $canReturnToPublication = $canPublish && $reviewStatus === EnvironmentReviewStatus::Ready->value; + $reviewRequiresRefresh = ! $canReturnToPublication + || $reviewCompleteness !== EnvironmentReviewCompletenessState::Complete->value; + + $payload = [ + 'review_id' => (int) $review->getKey(), + 'review_status' => $reviewStatus, + 'review_completeness_state' => $reviewCompleteness, + 'review_fingerprint' => (string) $review->fingerprint, + 'review_updated_at' => $review->updated_at?->toJSON(), + 'evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null, + 'evidence_fingerprint' => $snapshot instanceof EvidenceSnapshot ? (string) $snapshot->fingerprint : null, + 'evidence_state' => $evidenceState, + 'evidence_generated_at' => $snapshot?->generated_at?->toJSON(), + 'report_dimension_states' => $reportDimensionStates, + 'section_states' => $this->sectionStates($review), + 'publication_blockers' => $publicationBlockers, + 'readiness_state' => (string) ($readiness['readiness_state'] ?? ''), + 'guidance_state' => (string) ($guidance['state'] ?? ''), + 'has_ready_export' => $hasReadyExport, + 'review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null, + 'review_pack_status' => $pack instanceof ReviewPack ? (string) $pack->status : null, + 'review_pack_fingerprint' => $pack instanceof ReviewPack ? (string) $pack->fingerprint : null, + ]; + + return [ + 'fingerprint' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), + 'has_publication_blockers' => ! $canPublish || $publicationBlockers !== [], + 'can_publish' => $canPublish, + 'can_return_to_publication' => $canReturnToPublication, + 'missing_report_dimensions' => $missingReportDimensions, + 'evidence_incomplete' => $evidenceIncomplete, + 'evidence_state' => $evidenceState, + 'report_dimension_states' => $reportDimensionStates, + 'review_requires_refresh' => $reviewRequiresRefresh, + 'review_status' => $reviewStatus, + 'review_completeness_state' => $reviewCompleteness, + 'publication_blockers' => $publicationBlockers, + 'guidance_state' => (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN), + 'readiness' => $readiness, + 'guidance' => $guidance, + 'has_ready_export' => $hasReadyExport, + 'current_export_review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null, + 'current_evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null, + 'review_operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null, + 'scope' => [ + 'workspace_id' => (int) $review->workspace_id, + 'managed_environment_id' => (int) $review->managed_environment_id, + 'environment_review_id' => (int) $review->getKey(), + ], + ]; + } + + /** + * @return list + */ + private function publicationBlockers(EnvironmentReview $review): array + { + return collect($this->readinessGate->blockersForReview($review)) + ->map(static fn (string $blocker): string => mb_substr(trim($blocker), 0, 240)) + ->filter(static fn (string $blocker): bool => $blocker !== '') + ->values() + ->all(); + } + + /** + * @return array + */ + private function reportDimensionStates(?EvidenceSnapshot $snapshot): array + { + $items = $snapshot instanceof EvidenceSnapshot + ? $snapshot->items->keyBy('dimension_key') + : new Collection; + + $states = []; + + foreach (self::REQUIRED_REPORT_DIMENSIONS as $dimension) { + $item = $items->get($dimension); + + $states[$dimension] = [ + 'state' => $item instanceof EvidenceSnapshotItem ? (string) $item->state : EvidenceCompletenessState::Missing->value, + 'source_record_id' => $item instanceof EvidenceSnapshotItem && is_numeric($item->source_record_id) + ? (int) $item->source_record_id + : null, + ]; + } + + return $states; + } + + /** + * @param array $states + * @return list + */ + private function missingReportDimensions(array $states): array + { + $missing = []; + + foreach ($states as $dimension => $state) { + if (($state['state'] ?? EvidenceCompletenessState::Missing->value) !== EvidenceCompletenessState::Complete->value || ($state['source_record_id'] ?? null) === null) { + $missing[] = $dimension; + } + } + + return $missing; + } + + /** + * @return list + */ + private function sectionStates(EnvironmentReview $review): array + { + return $review->sections + ->map(static fn ($section): array => [ + 'key' => (string) $section->section_key, + 'state' => (string) $section->completeness_state, + 'required' => (bool) $section->required, + 'source_fingerprint' => is_string($section->source_snapshot_fingerprint) ? $section->source_snapshot_fingerprint : null, + ]) + ->values() + ->all(); + } +} diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionActionService.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionActionService.php new file mode 100644 index 00000000..0b645587 --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionActionService.php @@ -0,0 +1,473 @@ +authorize('executeStep', $case); + + $case = $this->caseService->refreshCase($case, $actor); + Gate::forUser($actor)->authorize('executeStep', $case); + + $step = $case->currentStep(); + + if (! $step instanceof ReviewPublicationResolutionStep) { + throw new InvalidArgumentException('There is no actionable resolution step.'); + } + + $stepKey = ReviewPublicationResolutionStepKey::tryFrom((string) $step->step_key); + + if (! $stepKey instanceof ReviewPublicationResolutionStepKey) { + throw new InvalidArgumentException('The current resolution step is not recognized.'); + } + + $case->loadMissing(['environmentReview.tenant', 'tenant']); + $review = $case->environmentReview; + $tenant = $case->tenant; + + if (! $review instanceof EnvironmentReview || ! $tenant instanceof ManagedEnvironment) { + throw new InvalidArgumentException('The resolution case is missing its review context.'); + } + + if (! $this->stepAuthorizer->canExecuteStep($actor, $tenant, $step)) { + abort(403); + } + + $this->caseService->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionStepStarted, + actor: $actor, + metadata: [ + 'step_key' => $stepKey->value, + ], + ); + + try { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::CompleteRequiredReports => $this->completeRequiredReports($case, $step, $tenant, $actor), + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => $this->collectEvidence($case, $step, $tenant, $actor), + ReviewPublicationResolutionStepKey::RefreshReviewComposition => $this->refreshReviewComposition($case, $step, $review, $actor), + ReviewPublicationResolutionStepKey::GenerateReviewPack => $this->generateReviewPack($case, $step, $review, $actor), + ReviewPublicationResolutionStepKey::ReturnToPublication => $this->returnToPublication($case, $step, $actor), + ReviewPublicationResolutionStepKey::ValidateReviewReadiness => throw new InvalidArgumentException('Readiness validation is automatic and cannot be manually executed.'), + }; + } catch (Throwable $throwable) { + $step->forceFill([ + 'status' => ReviewPublicationResolutionStepStatus::Failed->value, + 'failed_at' => now(), + 'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [ + 'state_description' => 'Step failed before it could be queued.', + 'failure_code' => 'review_publication_resolution.step_failed_before_queue', + ]), + ])->save(); + + $case->forceFill([ + 'status' => ReviewPublicationResolutionCaseStatus::Blocked->value, + 'current_step_key' => $stepKey->value, + ])->save(); + + $this->caseService->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionStepFailed, + actor: $actor, + metadata: [ + 'step_key' => $stepKey->value, + 'failure_code' => 'review_publication_resolution.step_failed_before_queue', + ], + ); + + throw $throwable; + } + } + + /** + * @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string} + */ + private function completeRequiredReports( + ReviewPublicationResolutionCase $case, + ReviewPublicationResolutionStep $step, + ManagedEnvironment $tenant, + User $actor, + ): array { + $missingDimensions = array_values(array_filter( + (array) data_get($step->summary, 'missing_report_dimensions', []), + static fn (mixed $dimension): bool => is_string($dimension) && trim($dimension) !== '', + )); + $targetDimension = (string) ($missingDimensions[0] ?? ''); + + if ($targetDimension === '') { + $step->forceFill([ + 'status' => ReviewPublicationResolutionStepStatus::Completed->value, + 'completed_at' => now(), + 'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [ + 'state_description' => 'Required reports are already current.', + ]), + ])->save(); + + $this->caseService->refreshCase($case, $actor); + + return [ + 'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']), + 'step' => $step->fresh('operationRun'), + 'operation_run' => null, + 'operation_type' => null, + ]; + } + + if ($targetDimension === 'permission_posture') { + Gate::forUser($actor)->authorize(Capabilities::PROVIDER_RUN, $tenant); + + $result = $this->verification->providerConnectionCheckForTenant($tenant, $actor, [ + 'trigger' => 'review_publication_resolution', + 'review_publication_resolution_case_id' => (int) $case->getKey(), + 'environment_review_id' => (int) $case->environment_review_id, + ]); + + $this->markQueuedOrCompleted( + case: $case, + step: $step, + proofType: 'operation_run', + proofId: (int) $result->run->getKey(), + proofStatus: (string) $result->run->status, + operationRun: $result->run, + actor: $actor, + operationType: 'provider.connection.check', + summary: [ + 'target_report_dimension' => $targetDimension, + ], + ); + + return [ + 'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']), + 'step' => $step->fresh('operationRun'), + 'operation_run' => $result->run, + 'operation_type' => 'provider.connection.check', + ]; + } + + if ($targetDimension === 'entra_admin_roles') { + Gate::forUser($actor)->authorize(Capabilities::ENTRA_ROLES_MANAGE, $tenant); + + $operationRun = $this->operationRuns->ensureRunWithIdentity( + tenant: $tenant, + type: OperationRunType::EntraAdminRolesScan->value, + identityInputs: [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'trigger' => 'scan', + ], + context: [ + 'workspace_id' => (int) $tenant->workspace_id, + 'initiator_user_id' => (int) $actor->getKey(), + 'review_publication_resolution_case_id' => (int) $case->getKey(), + 'environment_review_id' => (int) $case->environment_review_id, + 'trigger' => 'review_publication_resolution', + ], + initiator: $actor, + ); + + if ($operationRun->wasRecentlyCreated) { + $this->operationRuns->dispatchOrFail( + $operationRun, + fn (): mixed => ScanEntraAdminRolesJob::dispatch( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + initiatorUserId: (int) $actor->getKey(), + ), + ); + } + + $this->markQueuedOrCompleted( + case: $case, + step: $step, + proofType: 'operation_run', + proofId: (int) $operationRun->getKey(), + proofStatus: (string) $operationRun->status, + operationRun: $operationRun, + actor: $actor, + operationType: OperationRunType::EntraAdminRolesScan->value, + summary: [ + 'target_report_dimension' => $targetDimension, + ], + ); + + return [ + 'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']), + 'step' => $step->fresh('operationRun'), + 'operation_run' => $operationRun, + 'operation_type' => OperationRunType::EntraAdminRolesScan->value, + ]; + } + + throw new InvalidArgumentException('The required report dimension is not executable by this workflow.'); + } + + /** + * @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string} + */ + private function collectEvidence( + ReviewPublicationResolutionCase $case, + ReviewPublicationResolutionStep $step, + ManagedEnvironment $tenant, + User $actor, + ): array { + Gate::forUser($actor)->authorize(Capabilities::EVIDENCE_MANAGE, $tenant); + + $snapshot = $this->evidenceSnapshots->generate($tenant, $actor); + $operationRun = $snapshot->operationRun; + + $this->markQueuedOrCompleted( + case: $case, + step: $step, + proofType: 'evidence_snapshot', + proofId: (int) $snapshot->getKey(), + proofStatus: (string) $snapshot->status, + operationRun: $operationRun, + actor: $actor, + operationType: OperationRunType::EvidenceSnapshotGenerate->value, + ); + + return [ + 'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']), + 'step' => $step->fresh('operationRun'), + 'operation_run' => $operationRun, + 'operation_type' => OperationRunType::EvidenceSnapshotGenerate->value, + ]; + } + + /** + * @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string} + */ + private function refreshReviewComposition( + ReviewPublicationResolutionCase $case, + ReviewPublicationResolutionStep $step, + EnvironmentReview $review, + User $actor, + ): array { + Gate::forUser($actor)->authorize('refresh', $review); + + $review = $this->environmentReviews->refresh($review, $actor); + $operationRun = $review->operationRun; + + $this->markQueuedOrCompleted( + case: $case, + step: $step, + proofType: 'environment_review', + proofId: (int) $review->getKey(), + proofStatus: (string) $review->status, + operationRun: $operationRun, + actor: $actor, + operationType: OperationRunType::EnvironmentReviewCompose->value, + ); + + return [ + 'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']), + 'step' => $step->fresh('operationRun'), + 'operation_run' => $operationRun, + 'operation_type' => OperationRunType::EnvironmentReviewCompose->value, + ]; + } + + /** + * @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string} + */ + private function generateReviewPack( + ReviewPublicationResolutionCase $case, + ReviewPublicationResolutionStep $step, + EnvironmentReview $review, + User $actor, + ): array { + Gate::forUser($actor)->authorize('export', $review); + + if (! $this->readinessGate->canExport($review)) { + throw new InvalidArgumentException('Review blockers must be resolved before generating the publication pack.'); + } + + $pack = $this->reviewPacks->generateFromReview($review, $actor, [ + 'include_pii' => false, + 'include_operations' => true, + ]); + $operationRun = $pack->operationRun; + + if ($pack->status === ReviewPackStatus::Ready->value && (int) $review->current_export_review_pack_id !== (int) $pack->getKey()) { + $review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save(); + } + + $this->markQueuedOrCompleted( + case: $case, + step: $step, + proofType: 'review_pack', + proofId: (int) $pack->getKey(), + proofStatus: (string) $pack->status, + operationRun: $operationRun, + actor: $actor, + operationType: OperationRunType::ReviewPackGenerate->value, + ); + + return [ + 'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']), + 'step' => $step->fresh('operationRun'), + 'operation_run' => $operationRun, + 'operation_type' => OperationRunType::ReviewPackGenerate->value, + ]; + } + + /** + * @return array{case:ReviewPublicationResolutionCase, step:ReviewPublicationResolutionStep, operation_run:?OperationRun, operation_type:?string} + */ + private function returnToPublication( + ReviewPublicationResolutionCase $case, + ReviewPublicationResolutionStep $step, + User $actor, + ): array { + $step->forceFill([ + 'status' => ReviewPublicationResolutionStepStatus::Completed->value, + 'completed_at' => now(), + 'proof_type' => 'environment_review', + 'proof_id' => (int) $case->environment_review_id, + 'proof_status' => 'ready', + 'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [ + 'state_description' => 'Returned to the publication workflow.', + ]), + ])->save(); + + $case->forceFill([ + 'status' => ReviewPublicationResolutionCaseStatus::Completed->value, + 'current_step_key' => null, + 'completed_at' => now(), + ])->save(); + + $this->caseService->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionStepCompleted, + actor: $actor, + metadata: [ + 'step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value, + ], + ); + $this->caseService->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionCompleted, + actor: $actor, + metadata: [ + 'environment_review_id' => (int) $case->environment_review_id, + ], + ); + + return [ + 'case' => $case->fresh(['steps.operationRun', 'environmentReview.tenant']), + 'step' => $step->fresh('operationRun'), + 'operation_run' => null, + 'operation_type' => null, + ]; + } + + private function markQueuedOrCompleted( + ReviewPublicationResolutionCase $case, + ReviewPublicationResolutionStep $step, + string $proofType, + int $proofId, + string $proofStatus, + ?OperationRun $operationRun, + User $actor, + string $operationType, + array $summary = [], + ): void { + $isReadyProof = in_array($proofStatus, ['active', 'ready', 'complete'], true); + + $step->forceFill([ + 'status' => $isReadyProof && ! $operationRun?->wasRecentlyCreated + ? ReviewPublicationResolutionStepStatus::Completed->value + : ReviewPublicationResolutionStepStatus::Running->value, + 'started_at' => $step->started_at ?? now(), + 'completed_at' => $isReadyProof && ! $operationRun?->wasRecentlyCreated + ? now() + : $step->completed_at, + 'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, + 'proof_type' => $proofType, + 'proof_id' => $proofId, + 'proof_status' => $proofStatus, + 'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [ + 'state_description' => $isReadyProof && ! $operationRun?->wasRecentlyCreated + ? 'Requirement is satisfied.' + : 'Queued for execution. Open the linked operation for progress.', + ], $summary), + ])->save(); + + $case->forceFill([ + 'status' => $step->status === ReviewPublicationResolutionStepStatus::Running->value + ? ReviewPublicationResolutionCaseStatus::WaitingForRun->value + : ReviewPublicationResolutionCaseStatus::InProgress->value, + 'current_step_key' => (string) $step->step_key, + ])->save(); + + if ($operationRun instanceof OperationRun) { + $this->caseService->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionOperationLinked, + actor: $actor, + metadata: [ + 'step_key' => (string) $step->step_key, + 'operation_type' => $operationType, + 'proof_type' => $proofType, + 'proof_id' => $proofId, + ], + operationRun: $operationRun, + ); + } + + if ($step->status === ReviewPublicationResolutionStepStatus::Completed->value) { + $this->caseService->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionStepCompleted, + actor: $actor, + metadata: [ + 'step_key' => (string) $step->step_key, + 'proof_type' => $proofType, + 'proof_id' => $proofId, + ], + operationRun: $operationRun, + ); + + $this->caseService->refreshCase($case, $actor); + } + } +} diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionCaseStatus.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionCaseStatus.php new file mode 100644 index 00000000..eec3a6a8 --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionCaseStatus.php @@ -0,0 +1,36 @@ + + */ + public static function activeValues(): array + { + return [ + self::Open->value, + self::InProgress->value, + self::WaitingForRun->value, + self::Blocked->value, + self::ReadyToContinue->value, + ]; + } + + public function isActive(): bool + { + return in_array($this->value, self::activeValues(), true); + } +} diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionService.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionService.php new file mode 100644 index 00000000..e797f3ee --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionService.php @@ -0,0 +1,314 @@ +authorize('view', $review); + Gate::forUser($actor)->authorize('refresh', $review); + + $review->loadMissing(['tenant', 'workspace']); + $readiness = $this->evaluator->evaluate($review); + $activeCase = $this->activeCaseForReview($review); + + if (! $activeCase instanceof ReviewPublicationResolutionCase && ! (bool) $readiness['has_publication_blockers']) { + return null; + } + + return DB::transaction(function () use ($review, $actor, $sourceSurface, $readiness): ReviewPublicationResolutionCase { + $lockedCases = ReviewPublicationResolutionCase::query() + ->forReview($review) + ->where('action_key', ReviewPublicationResolutionCase::ACTION_KEY) + ->active() + ->lockForUpdate() + ->get(); + + $case = $lockedCases->firstWhere('readiness_fingerprint', $readiness['fingerprint']); + + foreach ($lockedCases as $lockedCase) { + if ($case instanceof ReviewPublicationResolutionCase && (int) $lockedCase->getKey() === (int) $case->getKey()) { + continue; + } + + $lockedCase->forceFill([ + 'status' => ReviewPublicationResolutionCaseStatus::Superseded->value, + 'superseded_at' => now(), + ])->save(); + + $this->recordAudit( + case: $lockedCase, + action: AuditActionId::ReviewPublicationResolutionSuperseded, + actor: $actor, + metadata: [ + 'superseded_by_readiness_fingerprint' => (string) $readiness['fingerprint'], + 'environment_review_id' => (int) $review->getKey(), + ], + ); + } + + if ($case instanceof ReviewPublicationResolutionCase) { + $case->forceFill([ + 'assigned_to_user_id' => (int) $actor->getKey(), + 'metadata' => array_replace(is_array($case->metadata) ? $case->metadata : [], [ + 'last_source_surface' => $sourceSurface, + ]), + ])->save(); + + $case = $this->syncCase($case, $review, $readiness); + + $this->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionResumed, + actor: $actor, + metadata: [ + 'current_step_key' => $case->current_step_key, + 'status' => $case->status, + ], + ); + + return $case; + } + + $case = ReviewPublicationResolutionCase::query()->create([ + 'workspace_id' => (int) $review->workspace_id, + 'managed_environment_id' => (int) $review->managed_environment_id, + 'environment_review_id' => (int) $review->getKey(), + 'action_key' => ReviewPublicationResolutionCase::ACTION_KEY, + 'status' => ReviewPublicationResolutionCaseStatus::Open->value, + 'readiness_fingerprint' => (string) $readiness['fingerprint'], + 'created_by_user_id' => (int) $actor->getKey(), + 'assigned_to_user_id' => (int) $actor->getKey(), + 'started_at' => now(), + 'last_evaluated_at' => now(), + 'summary' => $this->caseSummary($readiness, null), + 'metadata' => [ + 'source_surface' => $sourceSurface, + 'readiness_contract' => 'review_publication_resolution.v1', + ], + ]); + + $case = $this->syncCase($case, $review, $readiness); + + $this->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionCreated, + actor: $actor, + metadata: [ + 'current_step_key' => $case->current_step_key, + 'status' => $case->status, + ], + ); + + return $case; + }); + } + + public function refreshCase(ReviewPublicationResolutionCase $case, ?User $actor = null): ReviewPublicationResolutionCase + { + $case->loadMissing('environmentReview'); + $review = $case->environmentReview; + + if (! $review instanceof EnvironmentReview) { + return $case; + } + + $previousStatus = (string) $case->status; + $case = $this->syncCase($case, $review, $this->evaluator->evaluate($review)); + + if ($case->status === ReviewPublicationResolutionCaseStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionCaseStatus::Completed->value) { + $this->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionCompleted, + actor: $actor, + metadata: [ + 'environment_review_id' => (int) $review->getKey(), + 'readiness_fingerprint' => (string) $case->readiness_fingerprint, + ], + ); + } + + return $case; + } + + public function cancel(ReviewPublicationResolutionCase $case, User $actor): ReviewPublicationResolutionCase + { + Gate::forUser($actor)->authorize('cancel', $case); + + $case->forceFill([ + 'status' => ReviewPublicationResolutionCaseStatus::Cancelled->value, + 'cancelled_at' => now(), + ])->save(); + + $case->steps() + ->whereNotIn('status', [ + ReviewPublicationResolutionStepStatus::Completed->value, + ReviewPublicationResolutionStepStatus::Superseded->value, + ]) + ->update([ + 'status' => ReviewPublicationResolutionStepStatus::Superseded->value, + 'superseded_at' => now(), + 'updated_at' => now(), + ]); + + $this->recordAudit( + case: $case, + action: AuditActionId::ReviewPublicationResolutionCancelled, + actor: $actor, + metadata: [ + 'environment_review_id' => (int) $case->environment_review_id, + ], + ); + + return $case->fresh(['steps.operationRun', 'environmentReview.tenant']); + } + + public function activeCaseForReview(EnvironmentReview $review): ?ReviewPublicationResolutionCase + { + return ReviewPublicationResolutionCase::query() + ->forReview($review) + ->where('action_key', ReviewPublicationResolutionCase::ACTION_KEY) + ->active() + ->latest('updated_at') + ->latest('id') + ->first(); + } + + public function recordAudit( + ReviewPublicationResolutionCase $case, + AuditActionId $action, + ?User $actor = null, + array $metadata = [], + ?OperationRun $operationRun = null, + ): void { + $case->loadMissing(['tenant.workspace', 'environmentReview']); + $tenant = $case->tenant; + + if (! $tenant instanceof ManagedEnvironment) { + return; + } + + $this->auditLogger->log( + workspace: $tenant->workspace, + action: $action, + context: [ + 'metadata' => array_filter(array_replace([ + 'case_id' => (int) $case->getKey(), + 'environment_review_id' => (int) $case->environment_review_id, + 'status' => (string) $case->status, + 'current_step_key' => is_string($case->current_step_key) ? $case->current_step_key : null, + 'readiness_fingerprint' => (string) $case->readiness_fingerprint, + ], $metadata), static fn (mixed $value): bool => $value !== null && $value !== ''), + ], + actor: $actor, + resourceType: 'review_publication_resolution_case', + resourceId: (string) $case->getKey(), + targetLabel: sprintf('Review publication resolution case #%d', (int) $case->getKey()), + operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, + tenant: $tenant, + ); + } + + /** + * @param array $readiness + */ + private function syncCase( + ReviewPublicationResolutionCase $case, + EnvironmentReview $review, + array $readiness, + ): ReviewPublicationResolutionCase { + $plan = $this->planner->plan($review, $readiness, $case->exists ? $case : null); + + foreach ($plan['steps'] as $stepPlan) { + $step = $case->steps()->firstOrNew(['step_key' => (string) $stepPlan['step_key']]); + $previousStatus = $step->exists ? (string) $step->status : null; + + $step->fill([ + 'position' => (int) $stepPlan['position'], + 'status' => (string) $stepPlan['status'], + 'primary_action_key' => $stepPlan['primary_action_key'], + 'operation_run_id' => $stepPlan['operation_run_id'], + 'proof_type' => $stepPlan['proof_type'], + 'proof_id' => $stepPlan['proof_id'], + 'proof_status' => $stepPlan['proof_status'], + 'summary' => $stepPlan['summary'], + 'metadata' => $stepPlan['metadata'], + ]); + + if ($step->status === ReviewPublicationResolutionStepStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Completed->value) { + if ($step->completed_at === null) { + $step->completed_at = now(); + } + } + + if ($step->status === ReviewPublicationResolutionStepStatus::Failed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Failed->value) { + if ($step->failed_at === null) { + $step->failed_at = now(); + } + } + + if ($step->status === ReviewPublicationResolutionStepStatus::Running->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Running->value) { + if ($step->started_at === null) { + $step->started_at = now(); + } + } + + $step->save(); + } + + $caseStatus = $plan['case_status']->value; + $case->forceFill([ + 'status' => $caseStatus, + 'current_step_key' => $plan['current_step_key'], + 'readiness_fingerprint' => (string) $readiness['fingerprint'], + 'last_evaluated_at' => now(), + 'completed_at' => $caseStatus === ReviewPublicationResolutionCaseStatus::Completed->value + ? ($case->completed_at ?? now()) + : null, + 'summary' => $this->caseSummary($readiness, $plan['current_step_key']), + ])->save(); + + return $case->fresh(['steps.operationRun', 'environmentReview.tenant', 'tenant.workspace']); + } + + /** + * @param array $readiness + * @return array + */ + private function caseSummary(array $readiness, ?string $currentStepKey): array + { + return [ + 'title' => 'Resolve review publication blockers', + 'reason' => 'Publication is waiting on review readiness, evidence, or output proof.', + 'impact' => 'Operators can resolve each prerequisite without publishing automatically.', + 'current_step_key' => $currentStepKey, + 'publication_blocker_count' => count((array) ($readiness['publication_blockers'] ?? [])), + 'publication_blockers' => array_slice((array) ($readiness['publication_blockers'] ?? []), 0, 5), + 'missing_report_dimensions' => array_values((array) ($readiness['missing_report_dimensions'] ?? [])), + 'evidence_state' => (string) ($readiness['evidence_state'] ?? ''), + 'review_status' => (string) ($readiness['review_status'] ?? ''), + 'review_completeness_state' => (string) ($readiness['review_completeness_state'] ?? ''), + 'guidance_state' => (string) ($readiness['guidance_state'] ?? ''), + 'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false), + ]; + } +} diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepAuthorizer.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepAuthorizer.php new file mode 100644 index 00000000..40cd1b29 --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepAuthorizer.php @@ -0,0 +1,69 @@ +loadMissing(['tenant', 'steps.operationRun']); + + $tenant = $case->tenant; + $step = $case->currentStep(); + + if (! $tenant instanceof ManagedEnvironment || ! $step instanceof ReviewPublicationResolutionStep) { + return false; + } + + return $this->canExecuteStep($user, $tenant, $step); + } + + public function canExecuteStep(User $user, ManagedEnvironment $tenant, ReviewPublicationResolutionStep $step): bool + { + $capability = $this->requiredCapability($step); + + return is_string($capability) && $this->capabilities->can($user, $tenant, $capability); + } + + public function requiredCapability(ReviewPublicationResolutionStep $step): ?string + { + $stepKey = $step->stepKeyEnum(); + + return match ($stepKey) { + ReviewPublicationResolutionStepKey::CompleteRequiredReports => $this->requiredReportCapability($step), + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => Capabilities::EVIDENCE_MANAGE, + ReviewPublicationResolutionStepKey::RefreshReviewComposition, + ReviewPublicationResolutionStepKey::GenerateReviewPack, + ReviewPublicationResolutionStepKey::ReturnToPublication => Capabilities::ENVIRONMENT_REVIEW_MANAGE, + ReviewPublicationResolutionStepKey::ValidateReviewReadiness, + null => null, + }; + } + + private function requiredReportCapability(ReviewPublicationResolutionStep $step): ?string + { + $missingDimensions = array_values(array_filter( + (array) data_get($step->summary, 'missing_report_dimensions', []), + static fn (mixed $dimension): bool => is_string($dimension) && trim($dimension) !== '', + )); + + return match ((string) ($missingDimensions[0] ?? '')) { + 'permission_posture' => Capabilities::PROVIDER_RUN, + 'entra_admin_roles' => Capabilities::ENTRA_ROLES_MANAGE, + default => null, + }; + } +} diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepKey.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepKey.php new file mode 100644 index 00000000..32fa4148 --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepKey.php @@ -0,0 +1,54 @@ + + */ + public static function ordered(): array + { + return [ + self::ValidateReviewReadiness, + self::CompleteRequiredReports, + self::CollectEvidenceSnapshot, + self::RefreshReviewComposition, + self::GenerateReviewPack, + self::ReturnToPublication, + ]; + } + + public function label(): string + { + return match ($this) { + self::ValidateReviewReadiness => 'Validate review readiness', + self::CompleteRequiredReports => 'Complete required reports', + self::CollectEvidenceSnapshot => 'Collect evidence snapshot', + self::RefreshReviewComposition => 'Refresh review composition', + self::GenerateReviewPack => 'Generate review pack', + self::ReturnToPublication => 'Return to publication', + }; + } + + public function primaryActionKey(): ?string + { + return match ($this) { + self::CompleteRequiredReports => 'complete_required_reports', + self::CollectEvidenceSnapshot => 'collect_evidence_snapshot', + self::RefreshReviewComposition => 'refresh_review_composition', + self::GenerateReviewPack => 'generate_review_pack', + self::ReturnToPublication => 'return_to_publication', + self::ValidateReviewReadiness => null, + }; + } +} diff --git a/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepStatus.php b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepStatus.php new file mode 100644 index 00000000..8a2acf25 --- /dev/null +++ b/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepStatus.php @@ -0,0 +1,15 @@ + $readiness + * @return array{ + * steps:list>, + * case_status:ReviewPublicationResolutionCaseStatus, + * current_step_key:?string + * } + */ + public function plan(EnvironmentReview $review, array $readiness, ?ReviewPublicationResolutionCase $case = null): array + { + $existing = $case instanceof ReviewPublicationResolutionCase + ? $case->loadMissing(['steps.operationRun'])->steps->keyBy('step_key') + : new Collection; + + $steps = []; + + foreach ($this->relevantStepKeys($readiness, $existing) as $index => $stepKey) { + $steps[] = $this->stepPlan( + review: $review, + readiness: $readiness, + stepKey: $stepKey, + position: $index + 1, + existingStep: $existing->get($stepKey->value), + ); + } + + $steps = $this->activateFirstIncompleteStep($steps); + $currentStep = collect($steps)->first( + static fn (array $step): bool => in_array($step['status'], [ + ReviewPublicationResolutionStepStatus::Actionable->value, + ReviewPublicationResolutionStepStatus::Running->value, + ReviewPublicationResolutionStepStatus::Failed->value, + ], true), + ); + + $caseStatus = $this->caseStatus($steps, is_array($currentStep) ? (string) $currentStep['step_key'] : null); + + return [ + 'steps' => $steps, + 'case_status' => $caseStatus, + 'current_step_key' => is_array($currentStep) ? (string) $currentStep['step_key'] : null, + ]; + } + + /** + * @param array $readiness + * @return array + */ + private function stepPlan( + EnvironmentReview $review, + array $readiness, + ReviewPublicationResolutionStepKey $stepKey, + int $position, + ?ReviewPublicationResolutionStep $existingStep, + ): array { + $status = $this->baseStatus($stepKey, $readiness, $existingStep); + $proof = $this->proofResolver->proofFor($stepKey, $review); + + if ($existingStep instanceof ReviewPublicationResolutionStep + && in_array($status, [ + ReviewPublicationResolutionStepStatus::Running, + ReviewPublicationResolutionStepStatus::Failed, + ], true)) { + $proof = [ + 'proof_type' => is_string($existingStep->proof_type) ? $existingStep->proof_type : $proof['proof_type'], + 'proof_id' => is_numeric($existingStep->proof_id) ? (int) $existingStep->proof_id : $proof['proof_id'], + 'proof_status' => is_string($existingStep->proof_status) ? $existingStep->proof_status : $proof['proof_status'], + 'operation_run_id' => is_numeric($existingStep->operation_run_id) ? (int) $existingStep->operation_run_id : $proof['operation_run_id'], + ]; + } + + return [ + 'position' => $position, + 'step_key' => $stepKey->value, + 'status' => $status->value, + 'primary_action_key' => $stepKey->primaryActionKey(), + 'operation_run_id' => $proof['operation_run_id'], + 'proof_type' => $proof['proof_type'], + 'proof_id' => $proof['proof_id'], + 'proof_status' => $proof['proof_status'], + 'summary' => $this->summary($stepKey, $readiness, $status), + 'metadata' => [ + 'readiness_fingerprint' => (string) $readiness['fingerprint'], + 'planned_at' => now()->toIso8601String(), + ], + ]; + } + + /** + * @param array $readiness + */ + private function baseStatus( + ReviewPublicationResolutionStepKey $stepKey, + array $readiness, + ?ReviewPublicationResolutionStep $existingStep, + ): ReviewPublicationResolutionStepStatus { + $readinessStatus = $this->readinessStatus($stepKey, $readiness); + + if ($readinessStatus === ReviewPublicationResolutionStepStatus::Completed) { + return ReviewPublicationResolutionStepStatus::Completed; + } + + if ($existingStep instanceof ReviewPublicationResolutionStep) { + if ($stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication + && $existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Completed) { + return ReviewPublicationResolutionStepStatus::Completed; + } + + if ($existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Running && $existingStep->operationRun?->status !== OperationRunStatus::Completed->value) { + return ReviewPublicationResolutionStepStatus::Running; + } + + if ($existingStep->operationRun?->status === OperationRunStatus::Completed->value + && in_array((string) $existingStep->operationRun->outcome, [OperationRunOutcome::Failed->value, OperationRunOutcome::Blocked->value], true)) { + return ReviewPublicationResolutionStepStatus::Failed; + } + + } + + return $readinessStatus; + } + + /** + * @param Collection $existing + * @return list + */ + private function relevantStepKeys(array $readiness, Collection $existing): array + { + $keys = [ + ReviewPublicationResolutionStepKey::ValidateReviewReadiness, + ]; + + if (((array) ($readiness['missing_report_dimensions'] ?? [])) !== [] + || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CompleteRequiredReports)) { + $keys[] = ReviewPublicationResolutionStepKey::CompleteRequiredReports; + } + + if ((bool) ($readiness['evidence_incomplete'] ?? true) + || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot)) { + $keys[] = ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot; + } + + if ((bool) ($readiness['review_requires_refresh'] ?? true) + || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::RefreshReviewComposition)) { + $keys[] = ReviewPublicationResolutionStepKey::RefreshReviewComposition; + } + + if (! (bool) ($readiness['has_ready_export'] ?? false) + || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::GenerateReviewPack)) { + $keys[] = ReviewPublicationResolutionStepKey::GenerateReviewPack; + } + + $keys[] = ReviewPublicationResolutionStepKey::ReturnToPublication; + + return $keys; + } + + /** + * @param Collection $existing + */ + private function hasExistingStep(Collection $existing, ReviewPublicationResolutionStepKey $stepKey): bool + { + $step = $existing->get($stepKey->value); + + return $step instanceof ReviewPublicationResolutionStep + && $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Superseded; + } + + /** + * @param array $readiness + */ + private function readinessStatus( + ReviewPublicationResolutionStepKey $stepKey, + array $readiness, + ): ReviewPublicationResolutionStepStatus { + return match ($stepKey) { + ReviewPublicationResolutionStepKey::ValidateReviewReadiness => ReviewPublicationResolutionStepStatus::Completed, + ReviewPublicationResolutionStepKey::CompleteRequiredReports => ((array) ($readiness['missing_report_dimensions'] ?? [])) === [] + ? ReviewPublicationResolutionStepStatus::Completed + : ReviewPublicationResolutionStepStatus::Pending, + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => (bool) ($readiness['evidence_incomplete'] ?? true) + ? ReviewPublicationResolutionStepStatus::Pending + : ReviewPublicationResolutionStepStatus::Completed, + ReviewPublicationResolutionStepKey::RefreshReviewComposition => (bool) ($readiness['review_requires_refresh'] ?? true) + ? ReviewPublicationResolutionStepStatus::Pending + : ReviewPublicationResolutionStepStatus::Completed, + ReviewPublicationResolutionStepKey::GenerateReviewPack => (bool) ($readiness['has_ready_export'] ?? false) + ? ReviewPublicationResolutionStepStatus::Completed + : ReviewPublicationResolutionStepStatus::Pending, + ReviewPublicationResolutionStepKey::ReturnToPublication => ReviewPublicationResolutionStepStatus::Pending, + }; + } + + /** + * @param list> $steps + * @return list> + */ + private function activateFirstIncompleteStep(array $steps): array + { + foreach ($steps as $index => $step) { + if ($step['status'] !== ReviewPublicationResolutionStepStatus::Pending->value) { + continue; + } + + $steps[$index]['status'] = ReviewPublicationResolutionStepStatus::Actionable->value; + $steps[$index]['summary']['state_description'] = 'Ready for operator action.'; + + break; + } + + return $steps; + } + + /** + * @param list> $steps + */ + private function caseStatus(array $steps, ?string $currentStepKey): ReviewPublicationResolutionCaseStatus + { + if ($currentStepKey === null) { + return ReviewPublicationResolutionCaseStatus::Completed; + } + + $currentStep = collect($steps)->firstWhere('step_key', $currentStepKey); + + if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Running->value) { + return ReviewPublicationResolutionCaseStatus::WaitingForRun; + } + + if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Failed->value) { + return ReviewPublicationResolutionCaseStatus::Blocked; + } + + if ($currentStepKey === ReviewPublicationResolutionStepKey::ReturnToPublication->value) { + return ReviewPublicationResolutionCaseStatus::ReadyToContinue; + } + + return ReviewPublicationResolutionCaseStatus::InProgress; + } + + /** + * @param array $readiness + * @return array + */ + private function summary( + ReviewPublicationResolutionStepKey $stepKey, + array $readiness, + ReviewPublicationResolutionStepStatus $status, + ): array { + return [ + 'label' => $stepKey->label(), + 'description' => match ($stepKey) { + ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Review readiness has been evaluated from current evidence and section state.', + ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required report-backed evidence dimensions must be current before publication can continue.', + ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'The evidence snapshot must be complete and current for the review output.', + ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Review sections must be recomposed from current evidence before publication.', + ReviewPublicationResolutionStepKey::GenerateReviewPack => 'A current review pack is required before returning to publication.', + ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to the review publication action after blockers are resolved.', + }, + 'state_description' => $status === ReviewPublicationResolutionStepStatus::Completed + ? 'Requirement is satisfied.' + : 'Waiting for prerequisite steps.', + 'publication_blocker_count' => count((array) ($readiness['publication_blockers'] ?? [])), + 'missing_report_dimensions' => array_values((array) ($readiness['missing_report_dimensions'] ?? [])), + 'evidence_state' => (string) ($readiness['evidence_state'] ?? ''), + 'review_status' => (string) ($readiness['review_status'] ?? ''), + 'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false), + ]; + } +} diff --git a/apps/platform/database/migrations/2026_06_18_000386_create_review_publication_resolution_workflow_tables.php b/apps/platform/database/migrations/2026_06_18_000386_create_review_publication_resolution_workflow_tables.php new file mode 100644 index 00000000..db677f92 --- /dev/null +++ b/apps/platform/database/migrations/2026_06_18_000386_create_review_publication_resolution_workflow_tables.php @@ -0,0 +1,90 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('managed_environment_id')->constrained('managed_environments')->cascadeOnDelete(); + $table->foreignId('environment_review_id')->constrained('environment_reviews')->cascadeOnDelete(); + $table->string('action_key')->default('review.publication'); + $table->string('status')->default('open'); + $table->string('current_step_key')->nullable(); + $table->string('readiness_fingerprint', 64); + $table->timestampTz('last_evaluated_at')->nullable(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('assigned_to_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('completed_at')->nullable(); + $table->timestampTz('cancelled_at')->nullable(); + $table->timestampTz('superseded_at')->nullable(); + $table->jsonb('summary')->default('{}'); + $table->jsonb('metadata')->default('{}'); + $table->timestamps(); + + $table->index(['workspace_id', 'managed_environment_id', 'environment_review_id'], 'review_publication_cases_scope_idx'); + $table->index(['workspace_id', 'managed_environment_id', 'status'], 'review_publication_cases_status_idx'); + $table->index(['environment_review_id', 'action_key', 'status'], 'review_publication_cases_review_action_idx'); + $table->index('created_by_user_id', 'review_publication_cases_created_by_idx'); + $table->index('assigned_to_user_id', 'review_publication_cases_assigned_to_idx'); + + $table + ->foreign(['managed_environment_id', 'workspace_id'], 'review_publication_cases_environment_workspace_fk') + ->references(['id', 'workspace_id']) + ->on('managed_environments') + ->cascadeOnDelete(); + }); + + Schema::create('review_publication_resolution_steps', function (Blueprint $table): void { + $table->id(); + $table->foreignId('case_id')->constrained('review_publication_resolution_cases')->cascadeOnDelete(); + $table->unsignedSmallInteger('position'); + $table->string('step_key'); + $table->string('status')->default('pending'); + $table->string('primary_action_key')->nullable(); + $table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete(); + $table->string('proof_type')->nullable(); + $table->unsignedBigInteger('proof_id')->nullable(); + $table->string('proof_status')->nullable(); + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('completed_at')->nullable(); + $table->timestampTz('failed_at')->nullable(); + $table->timestampTz('superseded_at')->nullable(); + $table->jsonb('summary')->default('{}'); + $table->jsonb('metadata')->default('{}'); + $table->timestamps(); + + $table->unique(['case_id', 'step_key'], 'review_publication_steps_case_step_unique'); + $table->index(['case_id', 'position'], 'review_publication_steps_case_position_idx'); + $table->index(['case_id', 'status'], 'review_publication_steps_case_status_idx'); + $table->index('operation_run_id', 'review_publication_steps_operation_idx'); + $table->index(['proof_type', 'proof_id'], 'review_publication_steps_proof_idx'); + }); + + if (DB::getDriverName() === 'pgsql') { + DB::statement(" + CREATE UNIQUE INDEX review_publication_resolution_cases_active_unique + ON review_publication_resolution_cases (workspace_id, managed_environment_id, environment_review_id, action_key, readiness_fingerprint) + WHERE status IN ('open', 'in_progress', 'waiting_for_run', 'blocked', 'ready_to_continue') + "); + + DB::statement('CREATE INDEX review_publication_resolution_cases_summary_gin ON review_publication_resolution_cases USING GIN (summary)'); + DB::statement('CREATE INDEX review_publication_resolution_steps_summary_gin ON review_publication_resolution_steps USING GIN (summary)'); + } + } + + public function down(): void + { + Schema::dropIfExists('review_publication_resolution_steps'); + Schema::dropIfExists('review_publication_resolution_cases'); + } +}; diff --git a/apps/platform/resources/views/filament/resources/environment-review-resource/pages/resolve-review-publication.blade.php b/apps/platform/resources/views/filament/resources/environment-review-resource/pages/resolve-review-publication.blade.php new file mode 100644 index 00000000..65942a15 --- /dev/null +++ b/apps/platform/resources/views/filament/resources/environment-review-resource/pages/resolve-review-publication.blade.php @@ -0,0 +1,153 @@ +@php + $state = $this->caseState(); + $case = is_array($state['case'] ?? null) ? $state['case'] : []; + $decision = is_array($state['decision'] ?? null) ? $state['decision'] : []; + $steps = is_array($state['steps'] ?? null) ? $state['steps'] : []; +@endphp + + +
+ +
+
+
+ + {{ (string) ($decision['status_badge_label'] ?? 'Publication blocked') }} + +
+ +
+ {{ (string) ($decision['blocked_summary'] ?? 'TenantPilot found publication requirements that must be resolved first.') }} +
+
+ +
+
+
+
Publication preparation
+
Safe checks before the review returns to the publish action.
+
+
+ +
+ @foreach ($steps as $step) +
($step['status'] ?? null) === 'actionable', + 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' => ($step['status'] ?? null) !== 'actionable', + ]) + data-testid="review-publication-preparation-step" + > +
+
+
+ {{ (string) ($step['operator_label'] ?? $step['label'] ?? $step['key'] ?? 'Step') }} +
+
+ {{ (string) ($step['operator_description'] ?? $step['description'] ?? '') }} +
+
+ + {{ $step['status_label'] ?? 'Unknown' }} + +
+
+ @endforeach +
+
+ +
+
+
Why publication is blocked
+
+ @forelse ((array) ($decision['blockers'] ?? []) as $blocker) +
{{ $blocker }}
+ @empty +
TenantPilot is waiting for the remaining publication preparation checks.
+ @endforelse +
+ +
+
Required reports
+
+ @forelse ((array) ($decision['missing_reports'] ?? []) as $report) + + {{ (string) $report }} + + @empty + Required reports ready + @endforelse +
+
+
+ +
+
Next safe action
+
+
+ {{ (string) ($decision['next_action_label'] ?? 'Continue preparation') }} +
+
+ {{ (string) ($decision['next_action_description'] ?? 'TenantPilot will continue the next safe publication preparation step. It will not publish the review.') }} +
+
+
+ +
+
What happens after this
+
+ {{ (string) ($decision['after_this'] ?? 'Publishing remains a separate action on the review page.') }} +
+
+
+
+
+ + +
+ @foreach ($steps as $step) +
+
+
+
+
+ {{ (string) ($step['operator_label'] ?? $step['label'] ?? $step['key'] ?? 'Step') }} +
+ + {{ $step['status_label'] ?? 'Unknown' }} + +
+
+ Proof and operation links are supporting evidence only. They do not publish the review. +
+
+
+ @if (! empty($step['proof_url'])) + + Proof + + @endif + + @if (! empty($step['operation_url'])) + + Operation + + @endif + + @if (empty($step['proof_url']) && empty($step['operation_url'])) + No linked proof yet + @endif +
+
+
+ @endforeach +
+
+
+
diff --git a/apps/platform/tests/Browser/Spec386ReviewPublicationResolutionWorkflowTest.php b/apps/platform/tests/Browser/Spec386ReviewPublicationResolutionWorkflowTest.php new file mode 100644 index 00000000..a732b013 --- /dev/null +++ b/apps/platform/tests/Browser/Spec386ReviewPublicationResolutionWorkflowTest.php @@ -0,0 +1,134 @@ +browser()->timeout(60_000); + +beforeEach(function (): void { + Storage::fake('exports'); +}); + +it('Spec386 smokes the blocked review publication resolution entry point', function (): void { + [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + $environment->forceFill(['name' => 'Spec386 Browser Resolution'])->save(); + + $review = composeEnvironmentReviewForTest( + $environment, + $user, + seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0), + ); + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Draft->value, + 'published_at' => null, + 'published_by_user_id' => null, + ])->save(); + + spec386AuthenticateBrowser($this, $user, $environment); + + $page = visit(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $environment)) + ->resize(1236, 900) + ->waitForText('Resolve publication blockers') + ->assertSee('Resolve publication blockers') + ->click('Resolve publication blockers') + ->waitForText('Review can\'t be published yet') + ->assertSee('Why publication is blocked') + ->assertSee('Next safe action') + ->assertSee('TenantPilot will update the missing required reports. It will not publish the review.') + ->assertSee('Publication preparation') + ->assertSee('Update required reports') + ->assertSee('Required reports') + ->assertSee('Permission posture') + ->assertSee('Technical proof and operation history') + ->assertSee('Back to review') + ->assertDontSee('Cancel resolution') + ->assertDontSee('Report-backed evidence') + ->click('Update required reports') + ->waitForText('TenantPilot will update the missing required reports. This will not publish the review.') + ->assertSee('TenantPilot will update the missing required reports. This will not publish the review.') + ->click('Cancel') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + $page->screenshot(true, spec386BrowserScreenshotName('01-resolution-page-desktop')); + spec386CopyBrowserScreenshot('01-resolution-page-desktop'); + + $mobilePage = $page + ->resize(390, 844) + ->waitForText('Publication preparation') + ->assertSee('Update required reports') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + $mobilePage->screenshot(true, spec386BrowserScreenshotName('02-resolution-page-mobile')); + spec386CopyBrowserScreenshot('02-resolution-page-mobile'); +}); + +function spec386BrowserScreenshotName(string $name): string +{ + return 'spec386-review-publication-resolution-'.$name; +} + +function spec386CopyBrowserScreenshot(string $name): void +{ + $filename = spec386BrowserScreenshotName($name).'.png'; + $primarySource = base_path('tests/Browser/Screenshots/'.$filename); + $fallbackSource = \Pest\Browser\Support\Screenshot::path($filename); + $targetDirectory = repo_path('specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots'); + + if (! is_dir($targetDirectory)) { + @mkdir($targetDirectory, 0755, true); + } + + $source = null; + + for ($attempt = 0; $attempt < 50 && $source === null; $attempt++) { + foreach ([$primarySource, $fallbackSource] as $candidate) { + if (is_file($candidate)) { + $source = $candidate; + + break; + } + } + + if ($source !== null) { + break; + } + + usleep(100_000); + clearstatcache(true, $primarySource); + clearstatcache(true, $fallbackSource); + } + + if (is_string($source) && is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) { + @copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png'); + } +} + +function spec386AuthenticateBrowser(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); +} diff --git a/apps/platform/tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php b/apps/platform/tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php index baef91ce..7463e746 100644 --- a/apps/platform/tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php +++ b/apps/platform/tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php @@ -6,10 +6,10 @@ use App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview; use App\Models\ManagedEnvironment; use App\Models\ReviewPack; -use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; -use Filament\Notifications\Notification; use App\Support\EnvironmentReviewStatus; +use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; use Filament\Actions\Action; +use Filament\Notifications\Notification; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; @@ -111,10 +111,11 @@ $state = EnvironmentReviewResource::outputGuidanceState($review->fresh(['tenant', 'sections', 'evidenceSnapshot', 'currentExportReviewPack.operationRun', 'operationRun'])); - expect(data_get($state, 'resolution_case.primary_action.key'))->toBe('refresh_review'); + expect(data_get($state, 'resolution_case.primary_action.key'))->toBe('resolve_publication_blockers'); Livewire::actingAs($owner) ->test(ViewEnvironmentReview::class, ['record' => $review->getKey()]) + ->assertActionVisible('resolve_publication_blockers') ->assertActionVisible('refresh_review') ->assertActionVisible('publish_review') ->assertActionDisabled('publish_review') diff --git a/apps/platform/tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php b/apps/platform/tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php new file mode 100644 index 00000000..8c9abec6 --- /dev/null +++ b/apps/platform/tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php @@ -0,0 +1,355 @@ +create(['name' => 'Spec386 Case']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + + expect($case)->toBeInstanceOf(ReviewPublicationResolutionCase::class) + ->and($case->status)->toBe(ReviewPublicationResolutionCaseStatus::InProgress->value) + ->and($case->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::ValidateReviewReadiness->value)?->status) + ->toBe(ReviewPublicationResolutionStepStatus::Completed->value) + ->and($case->current_step_key)->not->toBeNull(); + + expect(AuditLog::query() + ->where('action', AuditActionId::ReviewPublicationResolutionCreated->value) + ->where('resource_id', (string) $case->getKey()) + ->exists())->toBeTrue(); +}); + +it('does not create a resolution case when a mutable review is already publishable', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + $review = composeEnvironmentReviewForTest($tenant, $owner); + + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + + expect($case)->toBeNull() + ->and(ReviewPublicationResolutionCase::query()->count())->toBe(0); +}); + +it('does not let readonly actors create resolution cases', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Readonly No Create']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + [$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly', workspaceRole: 'readonly'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + + expect(fn () => app(ReviewPublicationResolutionService::class)->openOrResume($review, $readonly)) + ->toThrow(AuthorizationException::class) + ->and(ReviewPublicationResolutionCase::query()->forReview($review)->count())->toBe(0); + + setAdminEnvironmentContext($tenant); + + Livewire::actingAs($readonly) + ->test(ResolveReviewPublication::class, ['record' => $review->getKey()]) + ->assertForbidden(); + + expect(ReviewPublicationResolutionCase::query()->forReview($review)->count())->toBe(0); +}); + +it('supersedes an active resolution case when the readiness fingerprint changes', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Supersede']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + + $firstCase = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + + $section = $review->sections()->where('required', true)->firstOrFail(); + $section->forceFill([ + 'summary_payload' => array_replace_recursive(is_array($section->summary_payload) ? $section->summary_payload : [], [ + 'publication_blockers' => ['Spec386 changed blocker.'], + ]), + ])->save(); + + $secondCase = app(ReviewPublicationResolutionService::class)->openOrResume($review->fresh(['sections', 'evidenceSnapshot.items']), $owner); + + expect($secondCase)->not->toBeNull() + ->and($secondCase->getKey())->not->toBe($firstCase?->getKey()) + ->and($firstCase?->fresh()->status)->toBe(ReviewPublicationResolutionCaseStatus::Superseded->value) + ->and(AuditLog::query() + ->where('action', AuditActionId::ReviewPublicationResolutionSuperseded->value) + ->where('resource_id', (string) $firstCase?->getKey()) + ->exists())->toBeTrue(); +}); + +it('keeps readonly actors able to inspect but unable to execute resolution steps', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Readonly']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + [$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly', workspaceRole: 'readonly'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + + expect($readonly->can('view', $case))->toBeTrue() + ->and($readonly->can('executeStep', $case))->toBeFalse(); + + setAdminEnvironmentContext($tenant); + + Livewire::actingAs($readonly) + ->test(ResolveReviewPublication::class, ['record' => $review->getKey()]) + ->assertSee('Review can\'t be published yet') + ->assertSee('Publication preparation') + ->assertDontSee('Report-backed evidence') + ->assertActionVisible('execute_current_step') + ->assertActionDisabled('execute_current_step'); +}); + +it('requires confirmation before executing the current resolution step', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Confirm Execute']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + + app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + setAdminEnvironmentContext($tenant); + + Livewire::actingAs($owner) + ->test(ResolveReviewPublication::class, ['record' => $review->getKey()]) + ->assertActionExists('execute_current_step', fn (Action $action): bool => $action->isConfirmationRequired()); +}); + +it('authorizes provider report resolution from the provider capability instead of review manage', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Provider Capability']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + [$operator] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'operator', workspaceRole: 'operator'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + + expect($case?->current_step_key)->toBe(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) + ->and($operator->can(Capabilities::PROVIDER_RUN, $tenant))->toBeTrue() + ->and($operator->can(Capabilities::ENVIRONMENT_REVIEW_MANAGE, $tenant))->toBeFalse() + ->and($operator->can('executeStep', $case))->toBeTrue(); + + setAdminEnvironmentContext($tenant); + + Livewire::actingAs($operator) + ->test(ResolveReviewPublication::class, ['record' => $review->getKey()]) + ->assertActionVisible('execute_current_step') + ->assertActionEnabled('execute_current_step'); +}); + +it('plans only relevant required steps for the current blocker set', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Relevant Steps']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete); + $snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([ + 'state' => EvidenceCompletenessState::Complete->value, + ]); + $snapshot->items()->where('dimension_key', 'permission_posture')->update([ + 'state' => EvidenceCompletenessState::Missing->value, + 'source_record_id' => null, + 'source_fingerprint' => null, + ]); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); + + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + $stepKeys = $case?->steps->pluck('step_key')->all(); + + expect($stepKeys)->toContain(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) + ->and($stepKeys)->not->toContain(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value); +}); + +it('lets current readiness truth supersede stale failed operation proof on an existing step', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Stale Proof']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + $step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); + $failedRun = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + $step?->forceFill([ + 'status' => ReviewPublicationResolutionStepStatus::Failed->value, + 'operation_run_id' => (int) $failedRun->getKey(), + 'proof_type' => 'operation_run', + 'proof_id' => (int) $failedRun->getKey(), + 'proof_status' => OperationRunOutcome::Failed->value, + ])->save(); + + $snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete); + $snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([ + 'state' => EvidenceCompletenessState::Complete->value, + ]); + + app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview']), $owner); + + expect($step?->fresh()->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value); +}); + +it('queues the existing report operation when executing the required reports step', function (): void { + Queue::fake(); + + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Report Step']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([ + 'state' => EvidenceCompletenessState::Complete->value, + ]); + $snapshot->items()->where('dimension_key', 'entra_admin_roles')->update([ + 'state' => EvidenceCompletenessState::Missing->value, + 'source_record_id' => null, + 'source_fingerprint' => null, + ]); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + + $result = app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $owner); + $updatedCase = $result['case']->fresh('steps.operationRun'); + $runningStep = $updatedCase->steps->firstWhere('status', ReviewPublicationResolutionStepStatus::Running->value); + + expect($result['operation_type'])->toBe(OperationRunType::EntraAdminRolesScan->value) + ->and($updatedCase->status)->toBe(ReviewPublicationResolutionCaseStatus::WaitingForRun->value) + ->and($runningStep)->not->toBeNull() + ->and($runningStep?->step_key)->toBe(ReviewPublicationResolutionStepKey::CompleteRequiredReports->value) + ->and($runningStep?->operationRun?->type)->toBe(OperationRunType::EntraAdminRolesScan->value) + ->and(AuditLog::query() + ->where('action', AuditActionId::ReviewPublicationResolutionOperationLinked->value) + ->where('resource_id', (string) $updatedCase->getKey()) + ->exists())->toBeTrue(); + + Queue::assertPushed(ScanEntraAdminRolesJob::class); +}); + +it('does not persist raw source-service exception text in failed step audit payloads', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Redacted Failure']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + $review = markEnvironmentReviewCustomerSafeReady($review); + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Ready->value, + 'current_export_review_pack_id' => null, + ])->save(); + $review->sections()->get()->each(function ($section): void { + $summary = is_array($section->summary_payload) ? $section->summary_payload : []; + $baselineReadiness = is_array($summary['baseline_readiness'] ?? null) ? $summary['baseline_readiness'] : []; + $baselineReadiness['publication_blockers'] = []; + + $section->forceFill([ + 'completeness_state' => \App\Support\EnvironmentReviewCompletenessState::Complete->value, + 'summary_payload' => array_replace($summary, [ + 'publication_blockers' => [], + 'baseline_readiness' => $baselineReadiness, + ]), + ])->save(); + }); + $case = app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview']), $owner); + + expect($case->current_step_key)->toBe(ReviewPublicationResolutionStepKey::GenerateReviewPack->value); + + app()->bind(ReviewPackService::class, fn (): ReviewPackService => new class extends ReviewPackService + { + public function __construct() {} + + public function generateFromReview(\App\Models\EnvironmentReview $review, User $user, array $options = []): \App\Models\ReviewPack + { + throw new RuntimeException('secret-token=abc123 rawGraphPayload {"access_token":"xyz"}'); + } + }); + + expect(fn () => app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $owner)) + ->toThrow(RuntimeException::class); + + $failedStep = $case?->fresh('steps')->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::GenerateReviewPack->value); + $failureAudit = AuditLog::query() + ->where('action', AuditActionId::ReviewPublicationResolutionStepFailed->value) + ->where('resource_id', (string) $case?->getKey()) + ->latest('id') + ->firstOrFail(); + $auditPayload = json_encode($failureAudit->metadata, JSON_THROW_ON_ERROR); + $stepPayload = json_encode($failedStep?->summary ?? [], JSON_THROW_ON_ERROR); + + expect($failedStep?->summary)->toHaveKey('failure_code') + ->and($failedStep?->summary)->not->toHaveKey('failure') + ->and($auditPayload)->toContain('review_publication_resolution.step_failed_before_queue') + ->and($auditPayload)->not->toContain('secret-token', 'rawGraphPayload', 'access_token') + ->and($stepPayload)->not->toContain('secret-token', 'rawGraphPayload', 'access_token'); +}); + +it('queues the existing evidence operation when executing the evidence collection step', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Evidence Step']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([ + 'state' => EvidenceCompletenessState::Complete->value, + ]); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); + $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); + + $result = app(ReviewPublicationResolutionActionService::class)->executeCurrentStep($case, $owner); + $updatedCase = $result['case']->fresh('steps.operationRun'); + $runningStep = $updatedCase->steps->firstWhere('status', ReviewPublicationResolutionStepStatus::Running->value); + + expect($result['operation_type'])->toBe(OperationRunType::EvidenceSnapshotGenerate->value) + ->and($updatedCase->status)->toBe(ReviewPublicationResolutionCaseStatus::WaitingForRun->value) + ->and($runningStep)->not->toBeNull() + ->and($runningStep?->step_key)->toBe(ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value) + ->and($runningStep?->operationRun?->type)->toBe(OperationRunType::EvidenceSnapshotGenerate->value) + ->and(AuditLog::query() + ->where('action', AuditActionId::ReviewPublicationResolutionOperationLinked->value) + ->where('resource_id', (string) $updatedCase->getKey()) + ->exists())->toBeTrue(); +}); + +it('promotes blocked mutable reviews to the publication resolution page action', function (): void { + $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Header']); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); + $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); + + setAdminEnvironmentContext($tenant); + $expectedUrl = EnvironmentReviewResource::environmentScopedUrl('resolve-publication', ['record' => $review], $tenant); + + Livewire::actingAs($owner) + ->test(ViewEnvironmentReview::class, ['record' => $review->getKey()]) + ->assertActionExists('resolve_publication_blockers', fn (Action $action): bool => ! $action->isConfirmationRequired()) + ->assertActionVisible('resolve_publication_blockers') + ->callAction('resolve_publication_blockers') + ->assertRedirect($expectedUrl); + + expect(ReviewPublicationResolutionCase::query()->forReview($review)->active()->count())->toBe(1); +}); diff --git a/docs/ui-ux-enterprise-audit/design-coverage-matrix.md b/docs/ui-ux-enterprise-audit/design-coverage-matrix.md index 6c540660..0cc9ff9f 100644 --- a/docs/ui-ux-enterprise-audit/design-coverage-matrix.md +++ b/docs/ui-ux-enterprise-audit/design-coverage-matrix.md @@ -6,12 +6,12 @@ ## Summary | Metric | Count | Notes | | --- | ---: | --- | -| UI route/page inventory rows | 100 | Includes dynamic route families and utility/auth endpoints. | -| Unique page reports | 21 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. | -| Desktop screenshots | 17 | Route-inventory-linked desktop evidence, including strategic runtime captures, blocker evidence screenshots, and the Spec 366 rendered-report capture. | +| UI route/page inventory rows | 101 | Includes dynamic route families and utility/auth endpoints. | +| Unique page reports | 22 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. | +| Desktop screenshots | 18 | Route-inventory-linked desktop evidence, including strategic runtime captures, blocker evidence screenshots, and the Spec 366 rendered-report capture. | | Tablet screenshots | 0 | Deferred to later strategic mockup/implementation specs. | -| Mobile screenshots | 2 | Spec 366 adds mobile-ish rendered-report evidence for the customer technical profile; Spec 384 adds a narrow baseline subject resolution smoke capture. | -| Strategic Surface rows | 46 | Individual target treatment or explicit product decision required. | +| Mobile screenshots | 3 | Spec 366 adds mobile-ish rendered-report evidence for the customer technical profile; Spec 384 adds a narrow baseline subject resolution smoke capture; Spec 386 adds a narrow publication-resolution smoke capture. | +| Strategic Surface rows | 47 | Individual target treatment or explicit product decision required. | | Domain Pattern Surface rows | 45 | Can be handled through grouped pattern specs unless later evidence raises risk. | | Design-System Cleanup Surface rows | 7 | Tables/forms/states/copy cleanup, no individual target mockup expected by default. | | Internal / Deprecated / Hidden rows | 1 | Local-only smoke login routes. | @@ -53,7 +53,7 @@ ## Coverage By Area | Monitoring | 9 | Operations hub and alert delivery landing captured; record details and config forms remain pattern/manual review. | | Inventory | 8 | Route-discovered only; coverage, policy version detail, and raw-data exposure need later review. | | Evidence / audit | 8 | Audit log captured; evidence/report detail routes need customer-safe progressive-disclosure review. | -| Reviews | 7 | Review register, customer workspace, review pack detail, and the rendered-report route now have bounded browser evidence; Spec 366 adds rendered-report profile, print, and mobile-ish captures while deeper evidence/report surfaces still remain open elsewhere. | +| Reviews | 8 | Review register, customer workspace, review pack detail, rendered-report, and the Spec 386 publication-resolution workflow now have bounded browser evidence; Spec 366 adds rendered-report profile, print, and mobile-ish captures while deeper evidence/report surfaces still remain open elsewhere. | | Backup / restore | 6 | High-risk area; Spec 371 adds seeded browser proof for Backup Sets list/detail, while restore runs and create/failure workflow states remain unresolved. | | Settings / admin | 5 | Workspace and environment access are RBAC-sensitive and need later review. | | Provider / integration | 5 | Provider connections and required permissions are captured; create/edit/onboarding remain high-risk unresolved surfaces. | @@ -78,7 +78,7 @@ ## Coverage By Primary Archetype | Inventory | 8 | Needs raw provider payload disclosure rules and confidence/status language. | | Drift / Diff | 9 | Needs assignment, comparison, subject-resolution, snapshot, and evidence-gap hierarchy. | | Provider / Integration | 7 | Consent, credentials, permissions, and disconnect states require high trust clarity. | -| Reviews | 7 | Customer/auditor language, export context, and proof links are central. | +| Reviews | 8 | Customer/auditor language, export context, proof links, and source-owned publication resolution are central. | | Findings / Inbox | 6 | Needs triage, owner, SLA, exception, and close-state clarity. | | Backup / Restore | 6 | Highest safety burden: dry-run, confirmation, audit, and restore-point truth. | | Auth / Access | 6 | Guard, denial, external auth, and smoke/local flows should stay explicit. | @@ -93,7 +93,7 @@ ## Coverage By Design Depth | Design Depth | Rows | Gate Treatment | | --- | ---: | --- | -| Strategic Surface | 46 | Requires individual target artifact or explicit product decision before substantive UI implementation. | +| Strategic Surface | 47 | Requires individual target artifact or explicit product decision before substantive UI implementation. | | Domain Pattern Surface | 45 | Can be handled by grouped pattern specs and shared components. | | Design-System Cleanup Surface | 7 | Table/form/action/state cleanup can be folded into implementation waves. | | Manual Review Required | 1 | Must not be treated as product-ready until route/auth state is confirmed. | diff --git a/docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md b/docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md index fd74f7d1..cda5ddcd 100644 --- a/docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md +++ b/docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md @@ -90,3 +90,20 @@ ### Browser proof - Spec372 screenshot: `specs/372-customer-auditor-surface-safety-pass/artifacts/screenshots/002-environment-review-view-after.png` - Browser smoke verified section ordering and no JavaScript errors or console logs. + +## Spec 386 Follow-up + +Spec 386 changes the blocked mutable-review primary action from direct refresh to a subject-owned publication-resolution workflow. + +- blocked mutable reviews now show `Resolve publication blockers` as the dominant header action +- the action creates or resumes a review-publication-specific case and redirects to the Environment Review-owned resolution route +- refresh, report generation, evidence collection, review-pack generation, and return-to-publication remain source-owned actions inside the workflow +- publish remains blocked by the existing readiness gates and is not executed automatically +- the Environment Review resource remains excluded from global search + +### Browser proof + +- Spec386 screenshots: `specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/` +- Verified states: + - `01-resolution-page-desktop.png` shows the redirected workflow page with the current report step + - `02-resolution-page-mobile.png` verifies the same workflow is readable at a narrow viewport diff --git a/docs/ui-ux-enterprise-audit/page-reports/ui-101-review-publication-resolution.md b/docs/ui-ux-enterprise-audit/page-reports/ui-101-review-publication-resolution.md new file mode 100644 index 00000000..d38948c5 --- /dev/null +++ b/docs/ui-ux-enterprise-audit/page-reports/ui-101-review-publication-resolution.md @@ -0,0 +1,48 @@ +# UI-101 Review Publication Resolution + +| Field | Value | +| --- | --- | +| Route | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}/resolve-publication` | +| Source | `EnvironmentReviewResource::resolve-publication` | +| Area / scope | Reviews / environment record workflow | +| Archetype | Reviews | +| Design depth | Strategic Surface | +| Repo truth | browser-verified route; feature-tested | +| Screenshot | [desktop](../../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/01-resolution-page-desktop.png), [mobile](../../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/02-resolution-page-mobile.png) | +| Browser status | Browser smoke passed for blocked-review CTA handoff, decision-first resolution page rendering, compact preparation progress, confirmation copy, technical disclosure, and narrow viewport readability. | + +## First Five Seconds + +The page should answer five operator questions immediately: can I publish, why not, what is missing, what should I do now, and whether the action publishes automatically. + +## Productization Review + +- Decision-first: operator-state page title, blocked reason, missing required reports, next safe action, compact preparation progress, and no-auto-publish copy lead the page. +- Evidence-first: report, evidence, review, pack, and operation proof remain available without becoming the default workflow source of truth. +- Context: route is owned by the Environment Review record and remains environment-scoped. +- Customer/auditor safety: high, because internal remediation details stay in the admin plane and customer workspace does not expose the case. +- Diagnostics: proof and operation links are available in collapsed technical disclosure, secondary to the guided fix. + +## Information Inventory + +Default content shows publication blocked state, required reports, compact preparation progress, the next safe action, what happens after the action, and the back-to-review action. Case status, proof links, operation links, and implementation terms such as report-backed evidence are technical details behind disclosure or normalized out of operator copy. + +## Dangerous Actions + +`Cancel resolution` is destructive to the local resolution case only, is demoted into the grouped More action, and requires confirmation plus `ENVIRONMENT_REVIEW_MANAGE`. Step execution is high-impact and uses the owning source action: provider verification or Entra scan for required reports, evidence snapshot generation for evidence, review refresh for composition, review-pack generation for export proof, and a non-publishing return-to-review completion step. + +## Scores + +| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 9 | 8 | 9 | 8 | 9 | 9 | 8 | 8 | 8 | 8 | 9 | 7 | + +## Top Issues + +1. Long-running operation states should be rechecked once real queue workers and provider failures are exercised in staging. +2. Report-step copy may need more specific provider wording if operators repeatedly hit provider-verification prerequisites. +3. The page intentionally stays sequential in v1; parallel report actions would require a new spec update. + +## Target Direction + +Keep this as a subject-owned admin workflow attached to Environment Review. Do not promote it into top-level navigation, a generic workflow resource, global search, or a customer-facing remediation surface. diff --git a/docs/ui-ux-enterprise-audit/route-inventory.md b/docs/ui-ux-enterprise-audit/route-inventory.md index e78af0eb..58f34973 100644 --- a/docs/ui-ux-enterprise-audit/route-inventory.md +++ b/docs/ui-ux-enterprise-audit/route-inventory.md @@ -46,6 +46,7 @@ # Route Inventory | UI-038 | `/admin/reviews/workspace` | page | Customer Review Workspace | Customer review | workspace hub | reachable | workspace member | Customer Workspace | Reviews | Strategic Surface | browser-verified | [desktop](../../specs/372-customer-auditor-surface-safety-pass/artifacts/screenshots/001-customer-review-workspace-after.png) | [report](page-reports/ui-006-customer-review-workspace.md) | Spec 372 keeps the decision/evidence-first workspace and removes operation proof from the default customer evidence path. | | UI-039 | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews` | resource | Environment Reviews | Reviews | environment-bound | route exists | environment entitlement | Reviews | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Environment-scoped review list. | | UI-040 | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}` | resource | Environment Review Detail | Reviews | environment record | reachable | environment + record entitlement | Reviews | Evidence / Audit | Strategic Surface | browser-verified | [desktop](../../specs/372-customer-auditor-surface-safety-pass/artifacts/screenshots/002-environment-review-view-after.png) | [report](page-reports/ui-040-environment-review-detail.md) | Spec 372 verifies outcome/guidance/evidence before technical details. | +| UI-101 | `/admin/workspaces/{workspace}/environments/{environment}/environment-reviews/{record}/resolve-publication` | resource custom page | Review Publication Resolution | Reviews | environment record workflow | reachable from blocked Environment Review Detail CTA | environment + review view capability to inspect; `ENVIRONMENT_REVIEW_MANAGE` plus source-owned provider/evidence/review/export capabilities for mutations | Reviews | Evidence / Audit | Strategic Surface | browser-verified | [desktop](../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/01-resolution-page-desktop.png), [mobile](../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/02-resolution-page-mobile.png) | [report](page-reports/ui-101-review-publication-resolution.md) | Subject-driven operator workflow for publication blockers; no top-level nav, no generic resource, no global search surface, and no auto-publish path. | | UI-041 | `/admin/workspaces/{workspace}/environments/{environment}/review-packs` | resource | Review Packs | Reviews | environment-bound | route exists | environment entitlement | Reviews | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Export artifact list. | | UI-042 | `/admin/workspaces/{workspace}/environments/{environment}/review-packs/{record}` | resource | Review Pack Detail | Reviews | environment record | reachable | environment + record entitlement | Reviews | Evidence / Audit | Strategic Surface | browser-verified | [desktop](../../specs/372-customer-auditor-surface-safety-pass/artifacts/screenshots/003-review-pack-view-after.png) | [report](page-reports/ui-042-review-pack-detail.md) | Spec 372 verifies readiness/contents/evidence before technical pack metadata. | | UI-043 | `/admin/review-packs/{reviewPack}/download` | controller | Review Pack Download | Reviews | workspace/environment artifact | route exists | download authorization expected | Reviews | Evidence / Audit | Design-System Cleanup Surface | repo-verified | - | - | Action endpoint, not page; include in coverage due customer artifact impact. | diff --git a/specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/01-resolution-page-desktop.png b/specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/01-resolution-page-desktop.png new file mode 100644 index 00000000..93ef9a9b Binary files /dev/null and b/specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/01-resolution-page-desktop.png differ diff --git a/specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/02-resolution-page-mobile.png b/specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/02-resolution-page-mobile.png new file mode 100644 index 00000000..00128369 Binary files /dev/null and b/specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/02-resolution-page-mobile.png differ diff --git a/specs/386-review-publication-resolution-workflow-v1/checklists/requirements.md b/specs/386-review-publication-resolution-workflow-v1/checklists/requirements.md new file mode 100644 index 00000000..b332b221 --- /dev/null +++ b/specs/386-review-publication-resolution-workflow-v1/checklists/requirements.md @@ -0,0 +1,74 @@ +# Requirements Checklist: Spec 386 - Review Publication Resolution Workflow v1 + +**Purpose**: Preparation quality and constitution gate for Spec 386 before implementation. +**Created**: 2026-06-18 +**Feature**: `specs/386-review-publication-resolution-workflow-v1/spec.md` + +## Candidate And Scope + +- [x] CHK001 The selected candidate is directly user-provided and not invented from an empty auto-prep queue. +- [x] CHK002 The candidate is not already covered by an existing `specs/386-*` package. +- [x] CHK003 Completed dependency specs 350, 351, 367, and 385 are treated as read-only historical/context artifacts. +- [x] CHK004 The smallest viable slice is Review Publication resolution only. +- [x] CHK005 Generic workflow engine, generic action-resolution registry, auto-publish, customer self-resolution, and non-review adapters are explicitly out of scope. + +## Spec Approval Rubric + +- [x] CHK006 The Spec Candidate Check answers the operator workflow, trust/safety, smallest version, complexity, and why-now questions. +- [x] CHK007 The spec is classified as Core Enterprise. +- [x] CHK008 Red flags are named and defended. +- [x] CHK009 The score is at least 7/12 and the decision is approve. +- [x] CHK010 The proportionality review covers current problem, insufficiency, narrowest implementation, ownership cost, rejected alternative, and release truth. + +## Repository Truth + +- [x] CHK011 Existing affected sources are named from repo truth, including Environment Review, Evidence Snapshot, Stored Report, Review Pack, OperationRun, ResolutionGuidance, and ReviewPackOutputResolutionGuidance surfaces. +- [x] CHK012 Source-of-truth boundaries are preserved: OperationRun execution truth, evidence/report/review/pack artifact truth, and case/step workflow state only. +- [x] CHK013 Review-publication-specific persistence is preferred; generic `action_resolution_*` persistence requires a spec/plan/tasks update first. +- [x] CHK014 Pre-production compatibility posture rejects legacy shims, aliases, dual-write, and old payload readers. + +## UI And Surface Coverage + +- [x] CHK015 UI Surface Impact is completed and identifies existing page changes plus a new subject-driven workflow route/surface. +- [x] CHK016 UI/Productization Coverage names affected surfaces and coverage artifact expectations. +- [x] CHK017 Customer-safe non-leakage requirements are explicit. +- [x] CHK018 Dangerous/high-impact action review is required for queued/artifact/cancel/supersede step actions. +- [x] CHK019 Tasks include UI coverage/page-report/route-inventory/design-matrix updates for the new workflow surface. +- [x] CHK020 The spec includes a UI Action Matrix and a Filament v5 implementation close-out contract. + +## Shared Patterns And OperationRun + +- [x] CHK021 Cross-cutting shared pattern reuse names existing helpers before any local composition. +- [x] CHK022 Any new service family is bounded to Review Publication and barred from becoming generic workflow infrastructure. +- [x] CHK023 OperationRun remains execution truth; step links do not duplicate run status/outcome as canonical truth. +- [x] CHK024 Existing OperationRun start/link/presenter behavior remains delegated to shared/source-owned paths. +- [x] CHK025 Provider boundary rules keep provider identifiers internal/proof-only and primary workflow language provider-neutral. + +## RBAC, Security, And Disclosure + +- [x] CHK026 Workspace/environment entitlement and deny-as-not-found boundaries are required for case, step, subject, proof, operation, and artifact resolution. +- [x] CHK027 Viewing a case does not imply permission to execute every step. +- [x] CHK028 Customer-safe output forbids internal case details, step lists, failed OperationRun debug, permission blocker internals, raw report state, raw provider payloads, and raw evidence JSON. +- [x] CHK029 Audit events are specified for case and step lifecycle events. +- [x] CHK030 No Graph/provider calls are allowed during resolution page render or readiness display. + +## Test And Validation Readiness + +- [x] CHK031 Test purpose and lanes are explicit: Unit, Feature, Filament/Livewire, PostgreSQL, Browser. +- [x] CHK032 Tasks include tests before migrations/services/UI implementation. +- [x] CHK033 Tasks cover duplicate active cases, stale/current proof, failed/running runs, zero-results behavior, and case completion. +- [x] CHK034 Tasks include RBAC/isolation/audit/customer-leakage tests. +- [x] CHK035 Tasks include browser-smoke and screenshot artifact decisions. +- [x] CHK036 Validation commands are present in the spec, plan, and tasks. + +## Readiness Outcome + +- [x] CHK037 Candidate Selection Gate result: PASS. +- [x] CHK038 Spec Readiness Gate result: PASS for implementation preparation. +- [x] CHK039 Review outcome class: acceptable-special-case. +- [x] CHK040 Workflow outcome: keep. +- [x] CHK041 Final note location: implementation close-out entry `Review Publication Resolution Workflow`. + +## Notes + +Preparation is ready for implementation review. The later implementation loop must stop and update `spec.md`, `plan.md`, and `tasks.md` before adding a generic workflow engine, generic `action_resolution_*` schema, top-level navigation, global-search resource, customer-facing resolution workflow, auto-publish behavior, new Graph/provider call path, or non-review adapter. diff --git a/specs/386-review-publication-resolution-workflow-v1/plan.md b/specs/386-review-publication-resolution-workflow-v1/plan.md new file mode 100644 index 00000000..ca55efc5 --- /dev/null +++ b/specs/386-review-publication-resolution-workflow-v1/plan.md @@ -0,0 +1,358 @@ +# Implementation Plan: Spec 386 - Review Publication Resolution Workflow v1 + +**Branch**: `386-review-publication-resolution-workflow-v1` | **Date**: 2026-06-18 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/386-review-publication-resolution-workflow-v1/spec.md` + +## Summary + +Create a bounded, review-publication-specific resolution workflow for blocked Environment Reviews. The implementation should add narrow case/step persistence, derive a step plan from existing review/evidence/report/review-pack readiness, execute only source-owned domain actions, link OperationRun and artifact proof, support pause/resume/currentness, audit lifecycle events, and keep customer-facing surfaces free of internal resolution details. + +The plan explicitly excludes a generic workflow engine, top-level navigation, global search, CRUD resource exposure, auto-publish behavior, cross-domain adapters, customer self-resolution, and generic proof/currentness infrastructure. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12.52.0, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL via Sail/Dokploy +**Storage**: New review-publication-specific case/step tables plus existing `EnvironmentReview`, `EvidenceSnapshot`, `StoredReport`, `ReviewPack`, and `OperationRun` truth. +**Testing**: Pest Unit, Feature, Filament/Livewire Feature, PostgreSQL migration/constraint lane, and one bounded Browser smoke. +**Validation Lanes**: fast-feedback, confidence, pgsql, browser. +**Target Platform**: Laravel monolith in `apps/platform`, Sail locally, Dokploy for staging/production. +**Project Type**: Laravel/Filament web application inside `apps/platform`. +**Performance Goals**: Resolution page renders from DB-local readiness and proof references; no Graph/provider calls during render; planner evaluation remains bounded to one review and its known evidence/report/pack/run references. +**Constraints**: no generic workflow registry; no raw provider/report/evidence payloads in case metadata; no direct `OperationRun.status`/`outcome` transitions outside existing services; no new panel provider or assets by default; no auto-publish. +**Scale/Scope**: one blocked Environment Review at a time, one active current resolution case per subject/action/currentness, ordered sequential steps for review publication only. + +## Existing Repository Surfaces Likely Affected + +```text +apps/platform/database/migrations/ +apps/platform/app/Models/ +apps/platform/app/Policies/ +apps/platform/app/Services/EnvironmentReviews/ +apps/platform/app/Services/Evidence/ +apps/platform/app/Services/ReviewPacks/ +apps/platform/app/Services/OperationRunService.php +apps/platform/app/Support/ResolutionGuidance/ +apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php +apps/platform/app/Support/ReviewPacks/ReviewPackOutputReadiness.php +apps/platform/app/Support/OpsUx/OperationUxPresenter.php +apps/platform/app/Support/OperationRunLinks.php +apps/platform/app/Support/Rbac/UiEnforcement.php +apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php +apps/platform/app/Filament/Resources/EnvironmentReviewResource.php +apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php +apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +apps/platform/resources/views/filament/infolists/entries/environment-review-summary.blade.php +apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php +apps/platform/tests/Unit/ +apps/platform/tests/Feature/ +apps/platform/tests/Browser/ +docs/ui-ux-enterprise-audit/route-inventory.md +docs/ui-ux-enterprise-audit/design-coverage-matrix.md +docs/ui-ux-enterprise-audit/page-reports/ +``` + +Likely new implementation paths, subject to repo verification: + +```text +apps/platform/app/Models/ReviewPublicationResolutionCase.php +apps/platform/app/Models/ReviewPublicationResolutionStep.php +apps/platform/app/Policies/ReviewPublicationResolutionCasePolicy.php +apps/platform/app/Support/ReviewPublicationResolution/ +apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ResolveReviewPublication.php +``` + +If implementation proves an existing resource/page naming convention is better, use the repo convention and keep the subject-driven/no-top-level-nav boundary. + +## Data and Migration Plan + +Prefer review-specific table names: + +```text +review_publication_resolution_cases +review_publication_resolution_steps +``` + +Case fields should include: + +- `id` +- `workspace_id` required +- `managed_environment_id` or the repo-current environment FK required for review publication +- `environment_review_id` required or a subject reference constrained to Environment Review only +- `action_key` required and fixed to `review.publication` for v1 +- `status` +- `current_step_key` +- `readiness_fingerprint` +- `last_evaluated_at` +- `created_by` +- `assigned_to` +- `started_at`, `completed_at`, `cancelled_at`, `superseded_at` +- `summary` JSONB for safe derived summary +- `metadata` JSONB for safe workflow metadata only +- timestamps + +Step fields should include: + +- `id` +- case FK +- `position` +- `step_key` +- `status` +- `primary_action_key` +- `operation_run_id` nullable +- `proof_type`, `proof_id`, `proof_status` nullable +- lifecycle timestamps +- `summary` JSONB for safe step summary +- `metadata` JSONB for safe workflow metadata only +- timestamps + +Indexes/constraints: + +- PostgreSQL-enforced unique active/current case constraint for one active case per workspace/environment/review/action/currentness, backed by transactional service locking/idempotency for double-click and concurrent Livewire requests. +- unique `case_id` + `step_key`. +- indexes for workspace/environment/action/status/current step/operation run/proof reference. +- PostgreSQL JSONB lane validation for JSONB/default/index behavior. + +Do not use polymorphic subject persistence unless implementation updates this spec/plan/tasks first. A generic `action_resolution_*` schema requires a proportionality update before coding. + +No persisted `reason_code` column or reason-code enum is approved in v1. Step reasons remain evaluator/planner-derived and may appear only as safe summary text unless the spec/plan/tasks are updated with `STATE-001` consequences and tests. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: existing Environment Review blocked-state change, new subject-driven resolution workflow page/action, high-impact step actions, OperationRun/artifact proof links, and customer-safe non-leakage. +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: + - Environment Review detail + - new Review Publication Resolution page or page action route + - Customer Review Workspace non-leakage/preparation wording + - existing OperationRun/evidence/report/pack proof destinations +- **No-impact class, if applicable**: N/A. +- **Native vs custom classification summary**: use native Filament Resource/Page actions and shared primitives first. Custom Blade only where existing review summary/customer workspace views already use it. +- **Shared-family relevance**: next-action guidance, review readiness, OperationRun proof links, evidence/report viewers, audit/status messaging, customer-safe disclosure. +- **State layers in scope**: persisted case, persisted steps, page current step, detail action state, URL route parameters, proof links. +- **Audience modes in scope**: operator-MSP, workspace manager, support-platform, customer-safe viewer. +- **Decision/diagnostic/raw hierarchy plan**: decision-first blocked reason, missing requirements, next safe action, and no-auto-publish note by default; proof and diagnostics secondary/collapsed; raw/support detail never customer default. +- **Raw/support gating plan**: raw provider payloads, raw report content, full evidence JSON, internal reason families, and OperationRun context stay behind existing gated diagnostics or are not exposed. +- **One-primary-action / duplicate-truth control**: one primary CTA on blocked review and one primary action for the current step. Completed/secondary proof links are demoted into technical disclosure. +- **Handling modes by drift class or surface**: new strategic workflow surface is `review-mandatory`; high-impact actions are `exception-required` only if deviating from existing confirmation/audit patterns. +- **Repository-signal treatment**: UI-COV-001 applies because a new reachable route/action surface is planned. +- **Special surface test profiles**: workflow detail surface, shared-detail-family, and browser smoke. +- **Required tests or manual smoke**: Unit, Feature, Filament/Livewire, PostgreSQL, and Browser smoke. +- **Exception path and spread control**: no generic workflow exception approved. +- **Active feature PR close-out entry**: Review Publication Resolution Workflow. +- **UI/Productization coverage decision**: update route inventory, design coverage matrix, and page reports for the new workflow surface and affected review/customer surfaces. +- **Coverage artifacts to update**: + - `docs/ui-ux-enterprise-audit/route-inventory.md` + - `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` + - relevant `docs/ui-ux-enterprise-audit/page-reports/...` +- **No-impact rationale**: N/A. +- **Navigation / Filament provider-panel handling**: no top-level navigation and no panel provider changes. Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`. +- **Screenshot or page-report need**: yes for the new workflow and blocked/recovery/customer-safe states. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes. +- **Systems touched**: Review readiness, evidence/report/pack readiness, OperationRun proof, Filament action UX, audit logging, RBAC UI enforcement, customer-safe disclosure. +- **Shared abstractions reused**: `ReviewPackOutputResolutionGuidance`, `ReviewPackOutputReadiness`, `ResolutionGuidance` DTOs where useful, `OperationRunLinks`, `OperationUxPresenter`, `BadgeCatalog`, `UiEnforcement`, `WorkspaceUiEnforcement`, existing review/evidence/pack services. +- **New abstraction introduced? why?**: yes, a review-publication-specific evaluator/planner/case/action/proof service family. It exists because the workflow must persist/resume state and proof over time. +- **Why the existing abstraction was sufficient or insufficient**: existing derived guidance can explain and select actions but cannot persist case lifecycle, steps, proof references, currentness, or audit lifecycle. +- **Bounded deviation / spread control**: no registry, no generic adapters, no cross-domain workflows. Namespace/service names should include `ReviewPublication` to preserve scope. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes. +- **Central contract reused**: existing OperationRun service/job creation and link/presenter helpers. +- **Delegated UX behaviors**: queued toast, run link, artifact link, run-enqueued browser event, dedupe/already-running messaging, blocked/failed-to-start messaging, tenant/workspace-safe URL resolution, and terminal notifications stay in shared/source-owned paths. +- **Surface-owned behavior kept local**: internal case status/current step selection, operator-facing next-action explanation, collapsed proof reference display, "Open operation", "Retry", and "Return to review" placement. +- **Queued DB-notification policy**: unchanged unless spec is updated. +- **Terminal notification path**: unchanged central lifecycle mechanism. +- **Exception path**: none approved. + +Implementation must never mark `OperationRun.status` or `OperationRun.outcome` directly from the resolution page. It should link runs and re-evaluate domain truth. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes, indirectly. +- **Provider-owned seams**: raw provider IDs, raw Graph payloads, provider permission details, and provider-specific report internals. +- **Platform-core seams**: review publication, evidence basis, report requirement, operation proof, artifact proof, readiness currentness, customer-ready boundary. +- **Neutral platform terms / contracts preserved**: review publication, resolution case, resolution step, publication blocker, proof reference, evidence basis, report requirement, artifact proof. +- **Retained provider-specific semantics and why**: only inside existing proof/diagnostics where needed; never as primary customer or operator workflow vocabulary. +- **Bounded extraction or follow-up path**: follow-up-spec for provider onboarding adapter or generic proof/currentness. + +## Constitution Check + +- Inventory-first: readiness derives from last-observed evidence/review/report/pack truth; no provider calls during render. +- Read/write separation: publication is not automatic. Step actions that create/refresh TenantPilot artifacts or queue operations use existing services, explicit operator intent, authorization, audit, and tests. +- Graph contract path: no direct Graph calls. Any existing evidence/report generation must continue through approved services/jobs and `GraphClientInterface`. +- Deterministic capabilities: step action availability must derive from existing capabilities/policies and be testable. +- RBAC-UX: `/admin` and `/system` remain separated; non-members 404; entitled users missing capability get safe denial/disabled state; customer users cannot access case internals. +- Workspace isolation: cases and proof links are workspace scoped. +- Tenant/environment isolation: review publication cases are environment scoped; no cross-environment proof leakage. +- Global search: no global-search resource for cases in v1. +- Destructive/high-impact actions: high-impact queued/artifact actions, retry that queues/regenerates work, case cancellation, and operator-triggered supersede/restart use `->action(...)`, authorization, confirmation, audit, notification, and tests. URL-only actions are navigation only. The initial `Resolve publication blockers` CTA creates/resumes an internal TenantPilot case and does not require confirmation, but must communicate scope and audit create/resume. +- Run observability: long-running/queued step actions create or reuse `OperationRun` through existing paths and link the run to the step. +- OperationRun start UX: shared start/link/presenter paths are reused; no local queued-toast/run-link composition. +- Ops-UX lifecycle: no direct OperationRun lifecycle transitions outside service-owned paths. +- Ops-UX summary counts: no new summary count keys unless `OperationSummaryKeys::all()` and tests are updated. +- Data minimization: case and step metadata contain safe identifiers/summaries only. +- Test governance: Unit, Feature, Filament/Livewire, PostgreSQL, and Browser lanes are explicit. +- Proportionality: new persistence is justified by pause/resume, audit, currentness, and proof-linked workflow needs. +- No premature abstraction: namespace and persistence remain review-publication-specific. +- Persisted truth: new tables store workflow state, not readiness or artifact truth. +- Behavioral state: case/step statuses affect routing/actionability/audit/lifecycle; v1 does not persist `skipped`, `not_applicable`, or a separate reason-code family for steps. +- UI semantics: use existing badges/disclosure/action-group patterns, no local color/status framework. Default page copy stays operator-facing; technical proof and implementation terms remain behind explicit disclosure. +- Shared pattern first: existing guidance/proof/action helpers are reused before local composition. +- Provider boundary: platform-core workflow vocabulary stays provider-neutral. +- V1 explicitness / few layers: direct review-specific services, no registry. +- UI/Productization coverage: new reachable workflow surface must update UI coverage artifacts. + +Gate result for preparation: PASS. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: + - Unit: evaluator, planner, fingerprint, proof resolver. + - Feature: migrations/models, case service, step actions, audit, RBAC, scope, customer boundary. + - Filament/Livewire Feature: CTA, resolution page, action enabled/disabled/executed states. + - PostgreSQL: JSONB/index/unique/constraint behavior. + - Browser: critical workflow smoke and non-leakage. +- **Affected validation lanes**: fast-feedback, confidence, pgsql, browser. +- **Why this lane mix is the narrowest sufficient proof**: the feature adds stateful workflow persistence and a new operator workflow page; SQLite-only tests cannot prove all PostgreSQL constraints and feature tests cannot prove full browser workflow presentation. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/ReviewPublicationResolution` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPublicationResolution tests/Feature/EnvironmentReview tests/Feature/ReviewPack` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec386ReviewPublicationResolutionUiTest.php` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml --filter Spec386` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec386ReviewPublicationResolutionWorkflowTest.php` + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `git diff --check` +- **Fixture / helper / factory / seed / context cost risks**: review/evidence/report/pack/operation fixtures can grow expensive. Keep new fixtures local and explicit unless existing helpers already provide cheap setup. +- **Expensive defaults or shared helper growth introduced?**: none planned. +- **Heavy-family additions, promotions, or visibility changes**: none planned. +- **Surface-class relief / special coverage rule**: no standard-native relief for the new workflow page; it needs explicit page/action/browser coverage. +- **Closing validation and reviewer handoff**: verify no generic workflow spread, no stale proof, no customer leakage, correct audit/RBAC, and updated UI coverage artifacts. +- **Budget / baseline / trend follow-up**: document-in-feature if pgsql/browser runtime or fixture cost materially grows. +- **Review-stop questions**: duplicate truth, generic naming, proof currentness, unauthorized action execution, raw detail leakage, OperationRun lifecycle misuse, no-Graph-during-render. +- **Escalation path**: document-in-feature for bounded implementation choices; follow-up-spec for structural proof/currentness or additional adapters. +- **Active feature PR close-out entry**: Review Publication Resolution Workflow. +- **Why no dedicated follow-up spec is needed**: Spec 386 itself is the bounded review-publication workflow. Generic proof/currentness and other adapters are listed as future specs. + +## Project Structure + +### Documentation (this feature) + +```text +specs/386-review-publication-resolution-workflow-v1/ +├── checklists/ +│ └── requirements.md +├── plan.md +├── spec.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Models/ +│ │ ├── ReviewPublicationResolutionCase.php +│ │ └── ReviewPublicationResolutionStep.php +│ ├── Policies/ +│ │ └── ReviewPublicationResolutionCasePolicy.php +│ ├── Support/ +│ │ └── ReviewPublicationResolution/ +│ │ ├── ReviewPublicationReadinessEvaluator.php +│ │ ├── ReviewPublicationResolutionPlanner.php +│ │ ├── ReviewPublicationResolutionCaseService.php +│ │ ├── ReviewPublicationResolutionActionService.php +│ │ ├── ReviewPublicationResolutionProofResolver.php +│ │ └── DTOs and enums/value objects as needed +│ ├── Services/ +│ │ ├── EnvironmentReviews/ +│ │ ├── Evidence/ +│ │ └── ReviewPacks/ +│ └── Filament/ +│ └── Resources/ +│ └── EnvironmentReviewResource/ +│ └── Pages/ +│ └── ResolveReviewPublication.php +├── database/ +│ └── migrations/ +├── resources/views/ +└── tests/ + ├── Unit/ReviewPublicationResolution/ + ├── Feature/ReviewPublicationResolution/ + ├── Feature/Filament/ + └── Browser/ +``` + +**Structure Decision**: Laravel monolith under `apps/platform`; review-publication-specific support namespace; subject-driven Filament page/action under Environment Review; no top-level resource/navigation. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| New persisted case/step tables | Operator workflow must survive navigation, queued operation completion, permission fixes, audit review, and currentness changes | Derived cards cannot preserve pause/resume, proof linkage, lifecycle audit, or current step over time | +| New case/step statuses | Status changes drive next action, wait state, failure handling, completion, cancellation, and supersession | Presentation-only labels would not support resume, audit, or safe action routing | +| New review-specific service family | Evaluation, planning, persistence, action execution, and proof/currentness must remain thin and testable | Putting this behavior in Filament closures would bury business logic and make RBAC/audit hard to verify | + +## Implementation Phases + +1. Confirm current review/evidence/report/pack/OperationRun services, capabilities, audit helpers, and UI patterns. +2. Add migration/model/policy tests first, including isolation and pgsql-specific constraints. +3. Add review-publication-specific models, migrations, enums/value objects, and policy. +4. Implement readiness evaluator, planner, fingerprinting, case service, action service, and proof resolver. +5. Integrate Environment Review blocked-state CTA and subject-driven resolution page/action. +6. Wire step actions through existing services/jobs and link OperationRun/artifact proof. +7. Add audit events and customer-boundary safeguards. +8. Update UI coverage artifacts. +9. Run focused Unit/Feature/Filament/PostgreSQL/Browser/Pint/diff validation. +10. Record implementation close-out with Filament v5 output contract and deployment impact. + +## Data Truth Separation + +- **Execution truth**: `OperationRun`. +- **Artifact truth**: `EvidenceSnapshot`, `StoredReport`, `EnvironmentReview`, `ReviewPack`. +- **Backup/snapshot truth**: unchanged and out of scope. +- **Recovery/evidence truth**: existing evidence/report/review/pack services. +- **Operator next action**: Review Publication Resolution Case current step and planner output. + +The case may store a pointer to proof but must re-evaluate current truth before completing steps or the case. + +## Rollout and Deployment Considerations + +- Migrations are required and must be safe on PostgreSQL. +- Queue workers are already involved through existing evidence/report/review/pack operations; no new queue name is planned. +- No new environment variables are planned. +- No new scheduler entry is planned. +- No new storage volume is planned. +- No Filament asset registration is planned. If assets are added later, include `cd apps/platform && php artisan filament:assets` in deploy. +- Staging validation must include migration, focused tests, and browser smoke before production promotion. + +## Filament v5 Output Contract For Implementation Close-Out + +The implementation close-out must explicitly state: + +1. Livewire v4.0+ compliance is preserved; current repo uses Livewire 4.1.4. +2. Filament panel provider registration remains in `apps/platform/bootstrap/providers.php`; no provider registration change unless explicitly made. +3. No globally searchable Resolution Case resource is added. If any Resource is added unexpectedly, global search is disabled or a safe View/Edit page exists. +4. Destructive/high-impact actions: list all new case/step actions and explain confirmation, authorization, audit, and notification behavior. +5. Asset strategy: no assets by default; if assets are registered, deploy includes `php artisan filament:assets`. +6. Tests added/updated: Unit, Feature, Filament/Livewire, PostgreSQL, Browser. +7. Deployment impact: migrations, queues, scheduler, env vars, storage, Dokploy/staging notes. + +## Spec Readiness Gate + +Preparation readiness is expected to pass after `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md` are present and consistent. + +Implementation must stop and update artifacts first if it needs any of: + +- generic `action_resolution_*` persistence; +- generic workflow registry/adapter; +- top-level navigation; +- global-searchable case resource; +- customer self-resolution; +- auto-publish behavior; +- new Graph/provider call path; +- new OperationRun type outside existing services; +- new public state/status family beyond the case/step statuses listed in the spec. diff --git a/specs/386-review-publication-resolution-workflow-v1/spec.md b/specs/386-review-publication-resolution-workflow-v1/spec.md new file mode 100644 index 00000000..08e5fd22 --- /dev/null +++ b/specs/386-review-publication-resolution-workflow-v1/spec.md @@ -0,0 +1,547 @@ +# Feature Specification: Spec 386 - Review Publication Resolution Workflow v1 + +**Feature Branch**: `386-review-publication-resolution-workflow-v1` +**Created**: 2026-06-18 +**Status**: Draft / Ready for implementation planning review +**Input**: User-provided draft candidate "Spec 386 - Review Publication Resolution Workflow v1" from `/Users/ahmeddarrazi/.codex/attachments/48ede29c-82e5-412d-b014-976812779abd/pasted-text.txt`. + +## Repo-Truth Adjustment + +The user supplied a complete numbered draft for Spec 386 after Spec 385. Repo truth confirms: + +- `docs/product/spec-candidates.md` explicitly states that no safe automatic next-best-prep target remains in the active queue. +- The candidate is therefore treated as a direct manual promotion, not as an auto-selected backlog item. +- `specs/385-evidence-review-readiness/` is implemented and becomes dependency context only. +- Specs 350, 351, and 367 provide existing derived guidance and OperationRun actionability foundations, but they do not persist a review-publication-specific resolution workflow. +- No existing `specs/386-*` package or local/remote `386-*` branch was found before the Spec Kit create script ran. + +The draft proposed mildly generic `action_resolution_cases` and `action_resolution_steps` table names. This preparation narrows that by default: + +- V1 persistence should be review-publication-specific, for example `review_publication_resolution_cases` and `review_publication_resolution_steps`. +- A generic `action_resolution_*` schema is not approved by default because it would imply a broader workflow foundation before multiple real persisted cases exist. +- Implementation may use generic names only if it updates this spec/plan/tasks first with a bounded proportionality defense and constraints proving it remains `action_key=review.publication` only. + +## Candidate Selection Gate + +- **Selected candidate**: Spec 386 - Review Publication Resolution Workflow v1. +- **Source**: Direct user-provided candidate attachment. +- **Why selected**: The active automatic queue is empty, and the user provided the next numbered manual candidate. It directly follows implemented Spec 385 by turning specific publication blockers into a guided, auditable operator workflow. +- **Roadmap relationship**: Supports R2 Evidence & Exception Workflows, customer-safe review consumption, Governance-of-Record auditability, OperationRun-backed proof, and operator workflow compression. +- **Close alternatives deferred**: + - Management Report PDF runtime validation remains tied to Specs 378-380 and staging/Dokploy runtime validation. + - Governance artifact lifecycle retention runtime remains manual promotion only. + - Provider readiness onboarding productization remains optional manual promotion only. + - Cross-domain indicator runtime follow-through remains a broader guardrail lane. + - Generic Resolution Proof & Currentness Contract, Restore adapter, Provider onboarding adapter, and Cross-Tenant Promotion adapter are follow-up candidates only. +- **Completed-spec guardrail result**: + - `specs/350-operator-resolution-guidance-framework-v1/`, `specs/351-review-output-resolve-actions-v1/`, and `specs/385-evidence-review-readiness/` are dependency context only. + - `specs/381-*` through `specs/384-*` contain close-out or completed-task signals and must not be modified by this work. + - `specs/367-operationrun-actionability-system/` has an implementation close-out file and is treated as completed OperationRun context. +- **Smallest viable implementation slice**: Create or resume one persistent Review Publication Resolution Case for one blocked Environment Review, derive ordered steps from existing readiness truth, link OperationRun/artifact proof, support pause/resume/currentness, and return the operator to existing publication only when current readiness is satisfied. +- **Gate result**: PASS. The candidate is direct user input, unprepared, not completed, roadmap-aligned, bounded to one workflow, and the broader generic workflow follow-ups are explicitly out of scope. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: A blocked Environment Review can explain that publication is unsafe, but the operator still has to reconstruct the exact next safe action across Evidence, Stored Reports, Review Packs, OperationRuns, baseline readiness, permission posture, and technical detail surfaces. +- **Today's failure**: Operators see "Publication blocked" or "Output not customer-ready" states but must infer whether to collect evidence, generate reports, refresh review composition, inspect a failed run, generate a review pack, or wait for a current OperationRun. +- **User-visible improvement**: The Review surface exposes one dominant "Resolve publication blockers" action. The operator enters a persistent, proof-linked workflow that shows ordered steps, one current next action, proof state, and safe return to publication. +- **Smallest enterprise-capable version**: One review-publication-specific case and step persistence model; one evaluator/planner over existing review/evidence/report/pack readiness; one subject-driven Filament resolution page or page action; focused RBAC, audit, OperationRun/proof, customer-boundary, and browser coverage. +- **Explicit non-goals**: No generic workflow engine, no BPMN/process designer, no global resolution-case resource, no top-level navigation, no automatic publish, no customer self-resolution, no Restore/provider/baseline/report-delivery adapters, no AI workflow, no generic proof/currentness platform, no cross-tenant promotion workflow, and no broad dashboard or Governance Inbox rebuild. +- **Permanent complexity imported**: Two narrow persisted entities/tables, case and step status families, one readiness/result DTO family, one review-specific evaluator/planner/case/action/proof service set, policy/audit/tests, one new subject-driven operator surface, and browser smoke coverage. +- **Why now**: Spec 385 made publication blockers more precise. The remaining product gap is workflow compression: operators need TenantPilot to turn those blockers into a guided, resumable resolution path instead of raw troubleshooting. +- **Why not local**: A local CTA or copy change would still leave long-running actions, proof links, stale/currentness, pause/resume state, RBAC, and audit scattered across separate surfaces. Persistence is justified because an operator may leave and return after queued operations or permission fixes. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: New persistence, new status families, new DTO/service layer, and workflow-like terminology. Defense: the scope is one current operator workflow, persistence has independent lifecycle and audit need, execution/artifact truth remains in existing records, no generic engine is approved, and broader adapters are follow-up specs only. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12** +- **Decision**: approve as a bounded Core Enterprise workflow-compression slice with strict no-generic-engine constraints. + +## Problem Statement + +TenantPilot can already derive publication blockers and customer-readiness states for review output. However, blocked publication still reads like a diagnostic condition instead of an operator workflow. + +The operator must currently answer questions manually: + +- Which evidence dimension is missing or stale? +- Which StoredReport must be generated or refreshed? +- Is baseline drift posture missing, empty, stale, limited, or failed? +- Is permission posture missing or stale? +- Was an OperationRun already started? +- Did the run fail, or did a newer proof supersede it? +- Which artifact is current proof? +- Should the review be refreshed? +- Is a Review Pack/export still missing? +- When is publication safe again? + +Spec 386 turns those questions into one guided, resumable Review Publication Resolution workflow. + +## Business / Product Value + +- Compresses blocked review publication from cross-page troubleshooting into one guided operator flow. +- Keeps TenantPilot honest about customer readiness and avoids false "ready to publish" states. +- Uses OperationRun as execution truth and evidence/report/review/pack artifacts as output truth. +- Creates an audit trail for resolution lifecycle events. +- Supports long-running operations and pause/resume without inventing a generic workflow engine. +- Keeps customer-facing review surfaces clean and free of internal remediation details. + +## Primary Users / Operators + +- MSP or tenant operator preparing an Environment Review for customer publication. +- Workspace manager responsible for review output quality and customer-safe handoff. +- Support/platform operator diagnosing why publication is blocked. +- Read-only customer/auditor users who must not see internal resolution cases or technical OperationRun detail. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant-owned review/evidence/output workflow inside established workspace and managed-environment boundaries. +- **Primary Routes**: + - existing Environment Review detail route(s); + - one new subject-driven resolution route/page/action under the Environment Review flow, for example an existing resource page route like `/admin/.../environment-reviews/{record}/resolve-publication`; + - existing Evidence Snapshot, Stored Report, Review Pack, OperationRun, and Baseline Subject Resolution destinations as linked proof or next-action targets only; + - Customer Review Workspace only for non-leakage regression and safe preparation wording. +- **Data Ownership**: + - `EnvironmentReview`, `EvidenceSnapshot`, `StoredReport`, `ReviewPack`, and `OperationRun` remain source-of-truth records. + - Review Publication Resolution Case stores operator workflow state, current step, scope, actor metadata, proof references, and readiness fingerprint only. + - The case must not store raw provider payloads, full report content, full evidence JSON, secrets, tokens, or customer-private evidence beyond safe identifiers. +- **RBAC**: + - Workspace membership and managed-environment entitlement are required to view the resolution case. + - Viewing the case does not grant permission to execute every step. + - Each step action must enforce the same policy/capability as the underlying review, evidence, report, or review-pack operation. + - Non-member or non-entitled access is deny-as-not-found; entitled users missing a capability receive a safe blocked/forbidden state according to existing policy behavior. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: existing route-owned workspace/environment context remains. No retired `/admin/t` Tenant Panel behavior is revived. +- **Explicit entitlement checks preventing cross-tenant leakage**: every case, step, subject, proof, operation, and artifact reference must resolve through workspace and managed-environment scoped queries/policies before rendering or mutation. + +## 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 +- [x] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [x] New modal/drawer/wizard/action added +- [x] New table/form/state added +- [x] Customer-facing surface changed +- [x] Dangerous action changed +- [x] Status/evidence/review presentation changed +- [x] Workspace/environment context presentation changed + +Clarification: the "dangerous action" impact is high-impact workflow/action surfacing, not destructive external mutation. Publication remains controlled by the existing publish action and is never auto-executed by this spec. + +## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")* + +- **Route/page/surface**: + - existing Environment Review detail blocked-state / publication-readiness area; + - new Review Publication Resolution subject-driven page or page-action surface; + - new resolution case step cards/state presentation; + - existing OperationRun/evidence/report/review-pack linked proof destinations; + - Customer Review Workspace safe non-leakage state. +- **Current or new page archetype**: existing Environment Review detail plus new workflow/detail surface under the review flow. No top-level navigation or CRUD resource archetype. +- **Design depth**: Strategic Surface / Primary Decision Surface for the resolution page; Domain Pattern Surface for Environment Review blocked-state integration. +- **Repo-truth level**: repo-verified existing review/evidence/report/pack/operation truth; spec-only for the new persistent resolution case. +- **Existing pattern reused**: existing Environment Review detail, `ReviewPackOutputResolutionGuidance`, existing `ResolutionGuidance` derived case/action contract, `OperationRunLinks`, `OperationUxPresenter`, badge catalog/domain badges, `UiEnforcement`/`WorkspaceUiEnforcement`, and existing review/evidence/pack service actions. +- **New pattern required**: one review-publication-specific workflow page and persisted case/step state. No global resolution pattern, no generic CRUD resource, no top-level navigation. +- **Screenshot required**: yes. Implementation must capture the blocked CTA, open case, running/failed/proof-completed states, ready-to-return state, customer no-leakage path, and dark/mobile smoke where feasible. +- **Page audit required**: yes. Implementation must update the relevant existing page report or create a new page report for the resolution workflow, plus update the route inventory/design matrix for the new reachable review-owned route/page-action surface. +- **Customer-safe review required**: yes. Customer-facing surfaces must not expose internal case, step, run-debug, permission blocker, or raw technical details. +- **Dangerous-action review required**: yes for high-impact queued/actions and cancel/supersede behavior. Each mutating/high-impact action must preserve authorization, audit, the confirmation behavior defined below, notification, OperationRun link behavior, and tests. +- **Coverage files updated or explicitly not needed**: + - [x] `docs/ui-ux-enterprise-audit/route-inventory.md` + - [x] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` + - [x] `docs/ui-ux-enterprise-audit/page-reports/...` + - [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md` + - [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md` + - [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md` + - [ ] `N/A - no reachable UI surface impact` +- **No-impact rationale when applicable**: N/A. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory)* + +- **Cross-cutting feature?**: yes. +- **Interaction class(es)**: status messaging, next-action guidance, header/page actions, OperationRun proof links, evidence/report viewers, customer-safe disclosure, audit events, badge/status presentation. +- **Systems touched**: + - `EnvironmentReviewResource` / `ViewEnvironmentReview`; + - `EnvironmentReviewReadinessGate`, `EnvironmentReviewComposer`, and lifecycle/refresh services; + - `EvidenceSnapshotService` and evidence readiness paths; + - Stored report generation services such as permission posture/admin roles where repo-real; + - `ReviewPackService` / `GenerateReviewPackJob`; + - `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, reconciliation/actionability helpers; + - existing `ResolutionGuidance` DTO/derived case/action support; + - audit logging helpers and capability/UI enforcement helpers. +- **Existing pattern(s) to extend**: review-output resolution guidance, existing source-owned Filament actions, existing OperationRun start/link UX, and existing badge/disclosure patterns. +- **Shared contract / presenter / builder / renderer to reuse**: existing `ResolutionGuidance\ResolutionCase` and `ResolutionAction` may inform presentation, but persisted case state must be a new review-publication-specific model/service, not a replacement for existing derived guidance. +- **Why the existing shared path is sufficient or insufficient**: existing paths can explain blockers and select next actions, but they do not persist operator workflow state, step proof, pause/resume, or readiness fingerprint over long-running operations. +- **Allowed deviation and why**: one review-publication-specific persisted workflow service and page are allowed because the operator workflow must survive navigation and queued operation completion. A generic registry/adapter framework is not allowed. +- **Consistency impact**: the blocked review state, resolution page, step cards, OperationRun proof, ReviewPack readiness, and customer-safe output must agree about the same publication-readiness truth. +- **Review focus**: prevent duplicate truth, stale proof, fake enabled actions, raw technical leakage, missing policy checks, and generic workflow spread. + +## OperationRun UX Impact *(mandatory)* + +- **Touches OperationRun start/completion/link UX?**: yes. +- **Shared OperationRun UX contract/layer reused**: existing `OperationRunService`, jobs, `OperationRunLinks`, `OperationUxPresenter`, operation notifications, and reconciliation/actionability layers. +- **Delegated start/completion UX behaviors**: queued toast, run links, artifact links, dedupe/already-running messaging, blocked/failed-to-start messaging, browser event, terminal notifications, and tenant/workspace-safe URL resolution must use existing shared paths. +- **Local surface-owned behavior that remains**: internal case status/current step selection, operator-facing next-action explanation, collapsed proof reference display, and "Return to review" behavior. +- **Queued DB-notification policy**: no new queued DB-notification policy unless the underlying existing operation already uses one or this spec is updated before implementation. +- **Terminal notification path**: central lifecycle mechanism remains authoritative. +- **Exception required?**: none approved. Any exception must be documented in spec/plan/tasks before implementation. + +OperationRun remains execution truth. The resolution step may link an `operation_run_id`; it must not duplicate run status as canonical truth. + +## Provider Boundary / Platform Core Check *(mandatory)* + +- **Shared provider/platform boundary touched?**: yes, indirectly. +- **Boundary classification**: platform-core for review publication workflow, readiness, operation proof, artifact proof, and operator vocabulary; provider-owned for any raw Microsoft/provider identifiers or Graph payload detail. +- **Seams affected**: evidence/report readiness, baseline posture, permission posture, review output readiness, stored report proof, OperationRun proof, and provider-resource/baseline proof links. +- **Neutral platform terms preserved or introduced**: Review Publication Resolution Case, resolution step, publication blocker, evidence basis, report requirement, operation proof, artifact proof, readiness fingerprint, customer-ready output. +- **Provider-specific semantics retained and why**: provider/Microsoft identifiers may remain internal proof or support diagnostics only where existing services already expose them safely. They must not appear in customer-facing default output or primary operator copy. +- **Why this does not deepen provider coupling accidentally**: the workflow is anchored to Environment Review publication, not Microsoft-specific provider actions. It calls existing domain services and does not introduce Graph endpoints or provider-specific contracts. +- **Follow-up path**: provider onboarding/permissions adapter and generic proof/currentness contract remain future specs. + +## 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 | +|---|---|---|---|---|---|---| +| Environment Review blocked publication CTA | yes | Native Filament resource/detail plus existing Blade/infolist patterns | review readiness, next-action guidance | detail, action state | no | Existing detail route changed with one primary CTA | +| Review Publication Resolution workflow | yes | New Filament page or subject-driven action surface | workflow/detail, steps, proof links | page, persisted case, step state | yes | New strategic workflow surface, no top-level nav | +| Resolution step actions | yes | Filament actions using existing source-owned services | OperationRun UX, audit, RBAC | step action, run link, artifact link | yes | High-impact actions must preserve shared start/action contracts | +| Customer Review Workspace non-leakage | yes | Existing customer-safe workspace surface | customer-safe disclosure | page/readiness copy | no | No internal resolution detail by default | +| OperationRun/artifact proof links | yes | Existing OperationRun/evidence/report/pack destinations | proof/readiness viewers | URL/link state | no | Navigation only; no local OperationRun lifecycle handling | + +## 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 | +|---|---|---|---|---|---|---|---| +| Environment Review blocked state | Primary Decision Surface | Operator decides how to start resolving publication blockers | blocked state, reason summary, impact, one CTA | evidence basis, technical details, proof links | Primary because publication decision starts here | review publication workflow | removes cross-page blocker interpretation | +| Review Publication Resolution page | Primary Decision Surface | Operator resolves the current blocker step by step | blocked reason, missing requirements, next safe action, no-auto-publish note | completed steps, technical detail, operation/artifact proof | Primary because it owns the resolution workflow | pause/resume and proof-linked resolution | reduces search across evidence, reports, operations, and packs | +| OperationRun detail | Tertiary Evidence / Diagnostics | Operator inspects run proof or failure | run status and safe summary | full diagnostics per existing route/policy | Not primary because execution truth is proof, not the workflow owner | supports step proof | keeps failed-run detail out of default workflow cards | +| Customer Review Workspace | Secondary Context / Customer-Safe Surface | Customer or operator consumes ready output | safe preparation or latest customer-ready review state | none by default for internal resolution | Not primary for resolution | customer-safe review consumption | avoids exposing remediation/debug detail | + +## 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 | +|---|---|---|---|---|---|---|---| +| Review Publication Resolution page | operator-MSP, manager, support-platform | operator-state title, publication blocked state, missing required reports, next safe action, compact preparation progress, what happens after this, no-auto-publish note | technical proof and operation history, readiness fingerprint/currentness | raw provider/report/evidence payloads never default; support detail only through existing gated surfaces | yes | raw provider payloads, report contents, full evidence JSON, internal reason families, proof/operation links by default, implementation terms such as report-backed evidence | page states blocker once; proof sections add evidence only on demand | +| Environment Review detail | operator-MSP, manager | publication state, reason summary, CTA | blocker list, evidence basis, operation proof | raw/support detail existing/gated | resolve publication blockers | internal case detail hidden until CTA | same readiness evaluator drives CTA and case | +| Customer Review Workspace | customer-safe, operator-MSP | latest customer-ready review or safe "being prepared" state | none by default | no internal resolution case, failed run debug, permission internals | view/download current customer-ready output when available | resolution case, step list, OperationRun debug | customer copy does not duplicate operator blocker text | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Environment Review detail blocked state | Detail / Review Context | Existing review detail | resolve publication blockers | existing detail page | current behavior | proof/detail links below CTA | existing dangerous actions unchanged | existing review register | existing review detail | workspace + environment | Environment review | publication blocked, reason, impact, one CTA | none | +| Review Publication Resolution | Workflow / Primary Decision | Subject-driven workflow detail | execute current resolution step, then inspect proof only if needed | direct page/action route from review | N/A | proof links and technical details collapsed | cancel/supersede grouped under More and confirmation-gated if mutating | no collection route in v1 | review-owned resolve-publication route | workspace + environment + review | Review publication resolution | blocked reason, required reports, next safe action, compact preparation progress | special workflow surface; no CRUD resource | +| Resolution step card | Workflow / Step | Step card | run/open/retry the one current step action | expanded current step | N/A | supporting links in collapsed technical proof section | retry/cancel confirmation per action risk | N/A | same workflow page | inherited from case | Resolution step | operator step label, status, safe action meaning | one-primary-action exception to ordinary table patterns | +| Customer Review Workspace preparation state | Utility / Workspace Decision | Customer-safe review hub | wait for or open current customer-ready review | existing workspace page | N/A | existing customer-safe supporting links | none in scope | `/admin/reviews/workspace` | existing review/pack detail | workspace + environment filter | Customer review output | safe availability/preparation state | non-leakage guard only | + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance | Row Actions | Bulk Actions | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Environment Review detail | `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` and `Pages/ViewEnvironmentReview.php` | Add or reprioritize `Resolve publication blockers` only when blocked | Existing detail inspect model unchanged | Existing list row actions unchanged | Existing bulk behavior unchanged | Existing empty state unchanged | Publish/refresh/export actions remain guarded | N/A | Existing + new resolution lifecycle audit | The CTA must not compete with publish when blocked | +| Review Publication Resolution page | new review-owned Filament page or action surface | One primary current-step action, Back to review, grouped More menu for cancellation | Page is opened from subject review | N/A | none | Ready/no-blocker state returns to review | N/A | yes for case/step lifecycle | New strategic workflow surface; no resource/global search | +| Resolution step actions | new page/action class | One visible primary step action | N/A | N/A | none | N/A | N/A | N/A | yes for step start/link/complete/fail/cancel | Step actions call services; no business logic in Blade | +| Customer Review Workspace | existing page/view | unchanged except safe wording if needed | N/A | N/A | N/A | unchanged | existing links only | N/A | unchanged | Must not expose case/step/run-debug by default | + +Filament v5 requirements for implementation: + +- Livewire v4.0+ compliance is mandatory; this repo uses Livewire 4.1.4. +- Panel providers stay in `apps/platform/bootstrap/providers.php`; no provider registration change is planned. +- No global-searchable Resolution Case resource is approved. If a model-backed resource is created contrary to this spec, global search must be disabled unless it has a safe View/Edit page and scoped URLs. +- Destructive/high-impact actions must use `Action::make(...)->action(...)`, server-side authorization, the confirmation behavior defined in this spec, audit, notification, and tests. Do not assume confirmation behavior on URL-only actions. +- No Filament asset registration is planned. If implementation registers assets, deploy must include `cd apps/platform && php artisan filament:assets`. + +Action confirmation contract: + +| Action | Mutation / Scope | Confirmation Required? | Notes | +|---|---|---|---| +| `Resolve publication blockers` | Creates or resumes an internal TenantPilot resolution case only | No | Must communicate TenantPilot-only scope near the action and audit create/resume. | +| Current step action that queues report, evidence, review refresh/composition, review-pack generation, or regeneration | Queues TenantPilot artifact/workflow work through existing services and may create/reuse an `OperationRun` | Yes | Must use `Action::make(...)->action(...)`, server-side authorization, audit, notification, and OperationRun link behavior. | +| Retry failed actionable step | Same behavior as the underlying current step action | Yes when retry queues/regenerates work; no when it only opens an existing proof/run | Retry availability must follow underlying capability and stale-proof rules. | +| Open OperationRun, artifact proof, technical detail, or return to review | Navigation only | No | These may use URL/navigation actions and must not mutate state. | +| Cancel an active case | Mutates internal case lifecycle | Yes | Must be grouped/demoted, audited, and authorization-gated. | +| Operator-triggered supersede/restart of a stale case | Mutates internal case lifecycle and creates or resumes the current case | Yes | Automatic supersession during service re-evaluation may occur without modal but must be audited and must not publish. | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Environment Review blocked state | MSP/workspace operator | Start resolving blockers for a blocked review | review detail | Can this review be published, and if not what starts resolution? | publication blocked, blocker summary, impact, CTA | technical details, evidence basis, operation proof | review lifecycle, publication readiness, artifact readiness | TenantPilot only / queued internal work | Resolve publication blockers | existing publish remains blocked by gates | +| Review Publication Resolution page | MSP/workspace operator | Work through a guided resolution case | workflow detail | Why can this review not be published, and what should I safely do next? | operator-state title, blocked reason, missing required reports, next safe action, compact preparation progress, what happens after this | technical proof, operation/artifact references, fingerprint/currentness | review readiness, step status, run status, artifact currentness | TenantPilot state and existing queued operations; no auto-publish | current step action, return to review; proof links only on demand | cancel/supersede grouped under More and queued regeneration actions as high-impact | +| Customer Review Workspace preparation state | customer-safe viewer / operator | Consume only customer-ready review output | customer-safe review hub | Is customer-ready output available? | safe availability/preparation state | none by default | output readiness only | none | view/download current ready output | none in scope | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes, but only for operator resolution workflow state. It is not truth for readiness, execution, artifacts, or publication safety. +- **New persisted entity/table/artifact?**: yes. Review Publication Resolution Case and Step persistence are approved for this one workflow because the case must survive navigation, queued operations, and support audit. +- **New abstraction?**: yes. One review-specific evaluator/planner/case/action/proof service family is approved. Generic registries/adapters are not approved. +- **New enum/state/reason family?**: yes for case and step status families only. They are approved because they change routing, actionability, audit, and pause/resume behavior. No persisted reason-code family is approved in v1. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: blocked publication forces manual cross-surface troubleshooting and loses continuity after long-running operations. +- **Existing structure is insufficient because**: existing derived guidance does not persist case/step state, actor metadata, currentness/fingerprint, proof links, or pause/resume lifecycle. +- **Narrowest correct implementation**: one review-publication-specific persisted case/step model, one evaluator/planner over existing readiness truth, one subject-driven page/action, and direct reuse of existing services for execution. +- **Ownership cost**: migrations, models, policy, services, audit events, tests, one new workflow page, UI coverage artifacts, and ongoing status/currentness semantics. +- **Alternative intentionally rejected**: a generic action-resolution engine, transient-only guidance cards, page-local copy changes, or auto-running all blocker steps. +- **Release truth**: current-release product truth after implemented Spec 385. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility readers, old case/state aliases, migration shims, dual-write logic, legacy OperationRun readers, or old evidence/review payload compatibility are out of scope unless this spec is explicitly updated before implementation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit for readiness evaluator/planner/proof resolver; Feature for persistence, policy, audit, OperationRun/artifact proof, case lifecycle, and customer-boundary behavior; Filament/Livewire Feature for actions/page states; PostgreSQL for migrations/constraints/indexes; Browser for critical workflow smoke. +- **Validation lane(s)**: fast-feedback, confidence, pgsql, browser. +- **Why this classification and these lanes are sufficient**: the feature adds persistence, policy-sensitive workflow state, queued-operation proof, and a new operator workflow surface. Unit/Feature prove behavior; pgsql proves JSONB/index/constraint behavior; browser proves the real guided workflow is usable and non-leaky. +- **New or expanded test families**: new focused Spec 386 unit/feature/Filament/browser families. No heavy-governance family unless implementation widens discovery/surface scanning. +- **Fixture / helper cost impact**: review/evidence/report/OperationRun fixtures may be expensive; any new setup must remain explicit and local to Spec 386 tests unless existing helpers already cover it cheaply. +- **Heavy-family visibility / justification**: no heavy-governance family planned. Browser smoke is explicit and bounded. +- **Special surface test profile**: workflow detail surface plus shared-detail-family proof links. Native Filament relief applies only where existing resources are not structurally changed. +- **Standard-native relief or required special coverage**: the new workflow page requires special coverage for one-primary-action, step states, proof links, RBAC disabled/denied states, and customer non-leakage. +- **Reviewer handoff**: verify no generic engine, no stale proof, no customer leakage, correct 404/403 semantics, no Graph calls during render, and no direct OperationRun lifecycle transitions outside services. +- **Budget / baseline / trend impact**: pgsql and browser lanes are expected additions. Document-in-feature if fixture/runtime cost grows materially. +- **Escalation needed**: document-in-feature for bounded implementation decisions; follow-up-spec for generic proof/currentness, additional adapters, customer self-resolution, or lifecycle retention. +- **Active feature PR close-out entry**: Review Publication Resolution Workflow. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/ReviewPublicationResolution` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPublicationResolution tests/Feature/EnvironmentReview tests/Feature/ReviewPack` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec386ReviewPublicationResolutionUiTest.php` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml --filter Spec386` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec386ReviewPublicationResolutionWorkflowTest.php` + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `git diff --check` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Operator starts resolution from a blocked review (Priority: P1) + +As an operator, I need a blocked Environment Review to expose one clear primary action so I can start resolving publication blockers without searching across evidence, reports, operations, and packs. + +**Why this priority**: This is the entry point and visible workflow compression. + +**Independent Test**: Render a blocked Environment Review and verify one primary CTA, blocked reason summary, impact copy, and no misleading publish/refresh primary action. + +**Acceptance Scenarios**: + +1. **Given** an Environment Review has publication blockers, **When** I view the review, **Then** I see "Resolve publication blockers" as the one primary CTA with blocked reason and impact. +2. **Given** publication is blocked, **When** the review actions render, **Then** publish remains blocked by existing gates and refresh is not promoted as the main next step. +3. **Given** no blockers exist, **When** I view the review, **Then** no new resolution case is created and the UI shows ready/return behavior. + +### User Story 2 - Operator opens or resumes a persistent case (Priority: P1) + +As an operator, I need the resolution workflow to create or resume one active case so I can return after long-running operations or permission fixes. + +**Why this priority**: Persistence is the main justification for the new entity. + +**Independent Test**: Start resolution from a blocked review twice and assert the same active case resumes when the readiness fingerprint still matches. + +**Acceptance Scenarios**: + +1. **Given** blockers exist and no active current case exists, **When** I click the CTA, **Then** TenantPilot creates a Review Publication Resolution Case and ordered steps. +2. **Given** a current active case exists, **When** I open resolution again, **Then** the same case resumes. +3. **Given** readiness fingerprint changed materially, **When** I open resolution, **Then** the case re-evaluates and updates or supersedes stale steps safely. + +### User Story 3 - Operator follows ordered steps with proof (Priority: P1) + +As an operator, I need ordered steps with one current primary action and proof state so I know what to do next and what already happened. + +**Why this priority**: This turns diagnostics into a product workflow. + +**Independent Test**: Seed cases for missing reports, missing evidence, failed run, running run, completed proof, and ready-to-return states, then verify step status/action/proof behavior. + +**Acceptance Scenarios**: + +1. **Given** missing required reports, **When** the plan renders, **Then** a complete-required-reports step is actionable with one primary action. +2. **Given** a step starts an async operation, **When** an OperationRun is created, **Then** the step links that run and the case becomes waiting-for-run. +3. **Given** a run fails, **When** the case re-evaluates, **Then** the step becomes failed and the primary action opens the failed operation or offers retry only when allowed. +4. **Given** a required artifact is current, **When** the case re-evaluates, **Then** the step shows artifact proof and completes. + +### User Story 4 - Truth remains in existing domains (Priority: P1) + +As a platform operator, I need the case to point to evidence/report/review/pack/run truth instead of becoming a second readiness system. + +**Why this priority**: Avoiding duplicate truth is central to the architecture. + +**Independent Test**: Change current evidence/report/review/pack state after a case exists and assert final readiness follows current domain truth, not stale step metadata. + +**Acceptance Scenarios**: + +1. **Given** old successful proof exists, **When** relevant review/evidence/report state changes, **Then** old proof is not treated as current completion. +2. **Given** a newer successful proof supersedes an older failed run, **When** the case renders, **Then** the old failure is not the current blocker. +3. **Given** all blockers are resolved, **When** readiness is evaluated, **Then** the case completes only if current readiness says publication blockers are resolved. + +### User Story 5 - RBAC, audit, and customer boundaries hold (Priority: P1) + +As a workspace manager, I need resolution cases to respect workspace/environment scope, capabilities, auditability, and customer-safe disclosure. + +**Why this priority**: The workflow handles governance output and proof near customer handoff. + +**Independent Test**: Exercise view/start/step actions as owner/operator/readonly/foreign workspace/customer-safe users and assert 404/403/safe blocked states, audit events, and no customer leakage. + +**Acceptance Scenarios**: + +1. **Given** a user lacks workspace or environment entitlement, **When** they request a case or proof link, **Then** TenantPilot denies as not found. +2. **Given** a readonly user can view safe status but lacks a step capability, **When** the step renders, **Then** the action is not executable and shows a permission-safe explanation. +3. **Given** a step starts, links an operation, completes, fails, cancels, or supersedes, **When** the event occurs, **Then** an audit log entry records safe identifiers and no raw payloads. +4. **Given** a customer-safe user opens Customer Review Workspace, **When** a resolution case exists internally, **Then** no case, step, OperationRun debug, permission internals, or technical remediation details are exposed by default. + +## Functional Requirements *(mandatory)* + +- **FR-386-001**: A blocked Environment Review MUST show one primary "Resolve publication blockers" CTA with reason summary and impact. +- **FR-386-002**: The CTA MUST create or resume one active Review Publication Resolution Case for the same workspace, environment, subject, action, and current readiness fingerprint. +- **FR-386-003**: No case MUST be created when current readiness has no publication blockers. +- **FR-386-004**: The case MUST derive requirements from existing review/evidence/report/review-pack readiness truth and MUST NOT duplicate those domains as canonical truth. +- **FR-386-005**: The case MUST persist case status, current step, actor metadata, readiness fingerprint, and safe summary metadata. +- **FR-386-006**: Steps MUST persist ordered step state, one step key, one current primary action, one primary OperationRun proof link when relevant, and one primary artifact proof reference when relevant. +- **FR-386-007**: Step planning MUST include only relevant required steps plus a return-to-publication step; it MUST NOT always show every possible step. +- **FR-386-008**: V1 step keys MUST remain bounded to review publication: `validate_review_readiness`, `complete_required_reports`, `collect_evidence_snapshot`, `refresh_review_composition`, `generate_review_pack`, and `return_to_publication`. +- **FR-386-009**: Step actions MUST reuse existing domain services/actions/jobs for reports, evidence snapshots, review refresh/composition, and review-pack generation. +- **FR-386-010**: Step actions MUST NOT publish the review automatically. +- **FR-386-011**: OperationRun status/outcome MUST remain execution truth and MUST NOT be copied as canonical case truth. +- **FR-386-012**: EvidenceSnapshot, StoredReport, EnvironmentReview, and ReviewPack currentness MUST remain artifact truth. +- **FR-386-013**: Case completion MUST occur only after current readiness evaluation says publication blockers are resolved. +- **FR-386-014**: Old/stale OperationRuns or artifacts MUST NOT complete current steps after readiness fingerprint/currentness changes. +- **FR-386-015**: Zero findings or zero drift MUST be treated as complete only when the relevant source was evaluated successfully and current readiness allows it. +- **FR-386-016**: Existing publish/export gates MUST still run and MUST still block unsafe publication. +- **FR-386-017**: Customer-facing surfaces MUST NOT expose internal resolution case details, step lists, failed OperationRun debug, permission blocker internals, raw report state, raw provider payloads, or raw evidence JSON. +- **FR-386-018**: Workspace/environment isolation MUST be enforced on case, step, subject, proof, operation, and artifact resolution. +- **FR-386-019**: Each step action MUST enforce the underlying capability/policy server-side and expose safe disabled/blocked UI state. +- **FR-386-020**: Audit events MUST be emitted for case creation/resume, step start, operation link, step completion/failure, case completion, cancellation, and supersession. +- **FR-386-021**: The implementation MUST NOT add a top-level navigation item, generic CRUD resource, global search resource, or generic workflow registry for resolution cases in v1. +- **FR-386-022**: The implementation MUST update UI coverage artifacts for the new reachable workflow surface and affected review/customer-safe surfaces. + +## Non-Functional Requirements + +- **NFR-386-001 - Auditability**: Case and step lifecycle changes must be traceable through audit logs with safe identifiers. +- **NFR-386-002 - Source-of-Truth Separation**: Readiness, execution, and artifact truth stay in existing services/models; the case stores workflow state and proof references only. +- **NFR-386-003 - Customer Safety**: Customer-facing output remains free of internal resolution and debug details. +- **NFR-386-004 - Determinism**: The same current readiness inputs must produce the same step plan and readiness fingerprint. +- **NFR-386-005 - Performance**: Resolution rendering must be DB-local and must not call Graph/provider APIs during UI render. +- **NFR-386-006 - Accessibility and Usability**: The workflow must show one dominant current action, clear status, safe explanations, and progressive technical detail. +- **NFR-386-007 - Least Privilege**: View, execute, proof, and technical detail access must be separately authorized where needed. +- **NFR-386-008 - Bounded Architecture**: No generic workflow engine, registry, adapter framework, or cross-domain proof platform is introduced. + +## Key Entities / Truth Sources + +- **EnvironmentReview**: subject and review lifecycle/publication truth. +- **EvidenceSnapshot / EvidenceSnapshotItem**: evidence artifact truth. +- **StoredReport**: report artifact truth. +- **ReviewPack**: export/package artifact truth. +- **OperationRun**: execution proof truth. +- **Review Publication Resolution Case**: new workflow-state record for one blocked review publication resolution. +- **Review Publication Resolution Step**: new workflow-step record tied to a case. +- **Readiness fingerprint**: hash or stable key derived from current publication-relevant state. + +## Case and Step Statuses + +Case statuses: + +- `open`: case exists, no execution started. +- `in_progress`: operator is actively resolving blockers. +- `waiting_for_run`: a current step waits on an OperationRun. +- `blocked`: current step failed or needs external permission/action. +- `ready_to_continue`: prior action completed and the next operator step is available. +- `completed`: current readiness has no publication blockers. +- `cancelled`: operator cancelled the resolution case. +- `superseded`: subject/readiness changed enough that this case is no longer current. + +Step statuses: + +- `pending` +- `actionable` +- `running` +- `failed` +- `completed` +- `superseded` + +Requirement status may remain DTO-derived: + +- `passed` +- `warning` +- `blocked` +- `missing` +- `stale` +- `running` +- `failed` +- `not_applicable` +- `unknown` + +Every persisted status must have a behavioral consequence in actionability, routing, audit, or lifecycle handling. + +V1 does not persist `skipped`, `not_applicable`, or a separate `reason_code` family for steps. Inapplicable requirements are omitted from persisted steps or represented only as DTO-derived readiness status. Operator-facing reasons remain derived from the evaluator/planner and may be rendered or copied into safe summary text, but they must not become a persisted reason-code enum/column unless this spec, plan, and tasks are updated with `STATE-001` consequences and tests. + +## Assumptions + +- Spec 385 is implemented and review/evidence/readiness blockers are specific enough for a planner to consume. +- Existing review, evidence, report, review-pack, and OperationRun services provide the execution primitives; implementation verifies exact class names before coding. +- Review publication is environment-scoped, so environment scope is required for this workflow even if the schema technically allows nullable environment references for future/non-review cases. +- Existing `ResolutionGuidance` support can inform presentation but does not replace the need for persisted case/step state. +- Existing action authorization and UI enforcement helpers are sufficient for step capability handling. +- Existing Customer Review Workspace can safely render a generic preparation/unavailable state without exposing internal case details. + +## Out of Scope + +- Generic workflow engine, process designer, global action-resolution registry, or cross-domain resolution adapter framework. +- Restore readiness adapter. +- Provider onboarding/permissions resolution adapter. +- Baseline compare adapter beyond linked proof/next-action destinations. +- Report delivery readiness workflow. +- Cross-tenant promotion readiness workflow. +- Commercial billing resolution workflow. +- Private AI execution workflow. +- Customer-facing resolution workflow or customer self-remediation. +- Automatic execution of all resolution steps without operator intent. +- New top-level navigation for resolution cases. +- New generic CRUD resource or global search surface for resolution cases. +- Auto-publishing reviews. +- Raw provider/report/evidence payload storage in case metadata. +- Management Report PDF runtime changes. +- Legacy compatibility readers/shims for old readiness/case/status shapes. + +## Risks + +- **Overbuilding a generic workflow engine**: mitigated by review-publication-specific models/services and no registry/top-level resource. +- **Duplicating readiness or artifact truth**: mitigated by deriving readiness on demand and storing only case/step/proof references. +- **Stale proof**: mitigated by readiness fingerprint and re-evaluation before completion. +- **Customer leakage**: mitigated by customer-safe tests and default hidden technical details. +- **Button/action bloat**: mitigated by one primary CTA on review and one primary action per current step. +- **RBAC drift**: mitigated by policy tests for view/start/step/proof/technical detail access. +- **Fixture/test cost growth**: mitigated by focused local fixtures and explicit pgsql/browser lanes. + +## Success Criteria *(mandatory)* + +- A blocked review exposes one primary `Resolve publication blockers` action. +- The action creates/resumes one current case and ordered step plan. +- Steps persist and resume across navigation. +- Current step has one primary action and a decision-first explanation of what is safe to do next. +- The resolution page title and default copy use operator-facing publication language, not implementation terms such as case status or report-backed evidence. +- OperationRun/artifact proof links are available behind explicit technical disclosure but are not canonical truth. +- Stale proof does not complete current steps. +- Case completes only when current readiness is unblocked. +- Existing publish gate still controls final publication. +- Customer-facing surfaces do not reveal internal resolution details. +- Workspace/environment isolation, step capability checks, and audit events are covered by tests. +- UI coverage artifacts and browser smoke evidence are included during implementation. + +## Open Questions + +No product question blocks preparation. + +Implementation must update this spec, plan, and tasks before adding any generic `action_resolution_*` schema, top-level resource, global search exposure, new workflow registry, customer-facing resolution feature, or auto-execution behavior. + +## Follow-Up Spec Candidates + +- Spec 387 - Resolution Proof & Currentness Contract v1. +- Spec 388 - Governance Inbox Resolution Intake v1. +- Spec 389 - Restore Readiness Resolution Adapter v1. +- Spec 390 - Provider Onboarding & Permissions Resolution Adapter v1. +- Spec 391 - Evidence/Baseline Readiness Resolution Adapter v1. +- Spec 392 - Report Delivery Readiness Resolution v1. +- Spec 393 - Customer Review Workspace Resolution Consumption v1. +- Spec 394 - Cross-Tenant Promotion Readiness Resolution v1. + +These must not be implemented as part of Spec 386. diff --git a/specs/386-review-publication-resolution-workflow-v1/tasks.md b/specs/386-review-publication-resolution-workflow-v1/tasks.md new file mode 100644 index 00000000..8a2b4060 --- /dev/null +++ b/specs/386-review-publication-resolution-workflow-v1/tasks.md @@ -0,0 +1,224 @@ +# Tasks: Spec 386 - Review Publication Resolution Workflow v1 + +**Input**: Design documents from `/specs/386-review-publication-resolution-workflow-v1/` +**Prerequisites**: `spec.md`, `plan.md` +**Tests**: Required. This feature adds persistence, policy-sensitive workflow state, a new operator workflow surface, and browser-visible behavior. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in the smallest honest family, and any PostgreSQL/browser addition is explicit. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] The declared workflow-detail surface profile and shared-detail-family proof coverage are explicit. +- [ ] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR. + +## Phase 1: Preparation and Repo Truth + +**Purpose**: Confirm exact runtime surfaces and prevent scope bleed before implementation. + +- [x] T001 Confirm current branch/status and re-read `specs/386-review-publication-resolution-workflow-v1/spec.md`, `plan.md`, and `tasks.md` before runtime edits. +- [x] T002 Verify completed-spec guardrail for `specs/350-operator-resolution-guidance-framework-v1/`, `specs/351-review-output-resolve-actions-v1/`, `specs/367-operationrun-actionability-system/`, and `specs/385-evidence-review-readiness/`; do not modify those packages. +- [x] T003 Inspect exact Environment Review model, resource, page, policy, and route names before adding the resolution entry point. +- [x] T004 Inspect existing review readiness/publish/export gates and record which service(s) are authoritative for publication blockers. +- [x] T005 Inspect existing EvidenceSnapshotService and evidence generation OperationRun behavior before adding evidence step execution. +- [x] T006 Inspect existing StoredReport/report-generation services for permission posture, admin roles, findings summary, accepted-risk summary, and baseline posture before mapping report steps. +- [x] T007 Inspect existing Review Pack generation service/job/action behavior before mapping review-pack steps. +- [x] T008 Inspect existing OperationRun start/link/presenter helpers and confirm which shared path must be reused for queued/run-link UX. +- [x] T009 Inspect existing audit logging helpers and action IDs before adding resolution lifecycle events. +- [x] T010 Inspect existing capability constants, policies, and `UiEnforcement`/`WorkspaceUiEnforcement` patterns for the relevant review/evidence/report/pack operations. +- [x] T011 Confirm no generic workflow engine, top-level navigation, global-search resource, auto-publish behavior, customer self-resolution, or cross-domain adapter is needed; update spec/plan/tasks first if this is false. + +## Phase 2: Persistence and Policy Tests First + +**Purpose**: Define schema, isolation, and authorization behavior before adding implementation. + +- [ ] T012 [P] Add PostgreSQL migration tests for `review_publication_resolution_cases` and `review_publication_resolution_steps` JSONB/index/partial unique active-current constraints under the existing pgsql lane or nearest migration test family. +- [ ] T013 [P] Add model relationship tests for case-to-workspace, case-to-environment, case-to-review, case-to-steps, step-to-case, step-to-operation-run, and proof reference fields. +- [ ] T014 [P] Add policy tests proving non-workspace members receive deny-as-not-found for case view and step access. +- [ ] T015 [P] Add policy tests proving non-entitled environment access receives deny-as-not-found. +- [x] T016 [P] Add policy tests proving an entitled readonly actor can view safe status but cannot execute step actions. +- [ ] T017 [P] Add policy tests proving operator/manager/owner capabilities map to the underlying evidence/report/review-pack operations instead of a blanket case permission. +- [ ] T018 Add a transactional/concurrency test proving duplicate active/current cases are not created for the same workspace/environment/review/action/currentness. + +**Checkpoint**: These tests should fail before migrations/models/policies are implemented. + +## Phase 3: Migrations, Models, Statuses, and Policy + +**Purpose**: Add bounded review-publication-specific persistence. + +- [x] T019 Create reversible migration(s) for `review_publication_resolution_cases` and `review_publication_resolution_steps` in `apps/platform/database/migrations/`, including the `review.publication` action key and PostgreSQL partial unique active-current constraint. +- [x] T020 Add `ReviewPublicationResolutionCase` model with casts, relationships, scopes for workspace/environment/review/action/status/active/current, and safe metadata handling. +- [x] T021 Add `ReviewPublicationResolutionStep` model with casts, relationships, ordered-step helpers, proof reference helpers, and safe metadata handling. +- [x] T022 Add review-publication-specific case status value object/enum with only `open`, `in_progress`, `waiting_for_run`, `blocked`, `ready_to_continue`, `completed`, `cancelled`, and `superseded`. +- [x] T023 Add review-publication-specific step status value object/enum with only `pending`, `actionable`, `running`, `failed`, `completed`, and `superseded`; do not persist `skipped`, `not_applicable`, or a step `reason_code` family in v1. +- [x] T024 Add or extend policy registration for `ReviewPublicationResolutionCasePolicy` using deny-as-not-found for workspace/environment non-entitlement. +- [ ] T025 Ensure metadata/summary casts cannot store raw provider payloads, raw report content, full evidence JSON, secrets, or tokens by convention and tests. +- [ ] T026 Run the focused model/policy/pgsql tests and fix only in-scope issues. + +## Phase 4: Evaluator, Planner, and Fingerprint Tests First + +**Purpose**: Define derived readiness and step-plan behavior before services are implemented. + +- [ ] T027 [P] Add unit tests for no blockers -> ready/no case needed. +- [ ] T028 [P] Add unit tests for missing/stale reports -> `complete_required_reports`. +- [ ] T029 [P] Add unit tests for missing/stale evidence -> `collect_evidence_snapshot`. +- [ ] T030 [P] Add unit tests for output not customer-ready after inputs are current -> `refresh_review_composition`. +- [ ] T031 [P] Add unit tests for current review output with missing/stale pack/export -> `generate_review_pack`. +- [ ] T032 [P] Add unit tests for final unblocked state -> `return_to_publication`. +- [ ] T033 [P] Add unit tests proving irrelevant steps are omitted and v1 stays sequential. +- [ ] T034 [P] Add fingerprint tests proving relevant evidence/report/review/pack/run changes alter currentness while volatile UI-only fields do not. +- [ ] T035 [P] Add zero-findings/zero-drift tests proving evaluated successful empty results are complete and unevaluated empty states are not. + +**Checkpoint**: These tests should fail before evaluator/planner services are implemented. + +## Phase 5: Evaluator, Planner, and Proof Resolver + +**Purpose**: Build the derived planning layer without duplicating readiness or artifact truth. + +- [x] T036 Implement `ReviewPublicationReadinessEvaluator` under a review-publication-specific namespace to evaluate current publication readiness from existing services/artifacts. +- [x] T037 Ensure the evaluator does not create reports, collect evidence, refresh reviews, generate packs, publish reviews, or call Graph/provider APIs. +- [x] T038 Implement a stable readiness fingerprint from review, evidence, required report, review-pack/export, blocker, and readiness status inputs. +- [x] T039 Implement `ReviewPublicationResolutionPlanner` to map evaluator requirements to ordered v1 steps only. +- [x] T040 Ensure planner output has one primary actionable step unless implementation updates spec/plan/tasks to approve parallel report steps. +- [x] T041 Implement `ReviewPublicationResolutionProofResolver` to derive OperationRun and artifact proof/currentness without making the case canonical truth. +- [ ] T042 Ensure old failed runs do not remain current blockers after newer successful current proof exists. +- [ ] T043 Ensure old successful proof does not complete current steps after review/evidence/report/pack currentness changes. +- [ ] T044 Run focused evaluator/planner/proof unit tests and fix only in-scope issues. + +## Phase 6: Case Service Tests First + +**Purpose**: Define create/resume/update/complete/supersede behavior before implementation. + +- [x] T045 Add feature tests proving blocked review creates a case and ordered steps. +- [ ] T046 Add feature tests proving the same current active case resumes instead of duplicating. +- [x] T047 Add feature tests proving changed fingerprint re-evaluates and updates or supersedes stale case safely. +- [x] T048 Add feature tests proving no blockers complete or bypass case creation. +- [ ] T049 Add feature tests proving case completes only when current readiness evaluation is unblocked. +- [ ] T050 Add feature tests proving deleted or inaccessible review subject becomes superseded or inaccessible without leaking existence. +- [ ] T051 Add feature tests proving case scope cannot mix workspace/environment/review/proof records. + +## Phase 7: Case Service Implementation + +**Purpose**: Persist and maintain workflow state safely. + +- [x] T052 Implement `ReviewPublicationResolutionCaseService` create/resume behavior in a transaction. +- [x] T053 Persist initial steps from the planner with stable ordering and safe summaries. +- [x] T054 Update step statuses after readiness re-evaluation without overwriting proof truth incorrectly. +- [x] T055 Mark cases completed, cancelled, or superseded according to current lifecycle rules. +- [x] T056 Enforce workspace/environment/review consistency for all case and step operations. +- [x] T057 Add lock/idempotency handling to prevent duplicate active/current cases on double click or concurrent requests. +- [x] T058 Run focused case service tests and fix only in-scope issues. + +## Phase 8: Step Action Tests First + +**Purpose**: Define action execution, OperationRun linking, artifact proof, and capability behavior before implementation. + +- [x] T059 Add tests for `complete_required_reports` using existing report generation service/action paths and linking proof. +- [x] T060 Add tests for `collect_evidence_snapshot` using existing EvidenceSnapshotService/job paths and linking OperationRun/artifact proof. +- [ ] T061 Add tests for `refresh_review_composition` using existing review refresh/composition paths and linking OperationRun/review proof. +- [ ] T062 Add tests for `generate_review_pack` using existing ReviewPack service/job paths and linking OperationRun/pack proof. +- [ ] T063 Add tests proving step actions do not auto-publish and existing publish gates still block unsafe publication. +- [x] T064 Add tests proving running OperationRun sets step `running` and case `waiting_for_run`. +- [ ] T065 Add tests proving failed OperationRun sets step `failed` unless newer current successful proof exists. +- [ ] T066 Add tests proving capability denial renders safe blocked state and does not dispatch jobs, create reports, or create runs. + +## Phase 9: Step Action Implementation + +**Purpose**: Execute only source-owned actions and link proof. + +- [x] T067 Implement `ReviewPublicationResolutionActionService` to execute allowed current-step actions. +- [x] T068 Wire report-generation steps to existing report services/actions; do not invent fake `StoredReport` rows. +- [x] T069 Wire evidence snapshot steps to existing evidence services/jobs. +- [x] T070 Wire review refresh/composition steps to existing review services/jobs. +- [x] T071 Wire review-pack steps to existing review-pack services/jobs. +- [x] T072 Link the primary `operation_run_id` to the current step when an async operation is created or reused. +- [x] T073 Link the primary artifact proof reference when an artifact exists and currentness checks pass. +- [x] T074 Preserve existing shared OperationRun start UX for queued toasts, links, browser events, dedupe/already-running, and terminal notifications. +- [ ] T075 Ensure retry appears only for failed actionable steps and still enforces underlying capability, confirmation, audit, notification, and OperationRun link rules from the spec action confirmation contract. +- [x] T076 Run focused step-action tests and fix only in-scope issues. + +## Phase 10: Filament Entry Point and Workflow UI Tests First + +**Purpose**: Define the operator workflow and customer non-leakage surface before UI implementation. + +- [x] T077 Add Filament/Livewire tests proving blocked Environment Review shows one primary `Resolve publication blockers` CTA. +- [ ] T078 Add Filament/Livewire tests proving publish remains blocked/disabled when existing gates say blocked and refresh is not promoted as primary. +- [x] T079 Add Filament/Livewire tests proving clicking the CTA creates/resumes the case and redirects/opens the resolution workflow. +- [x] T080 Add Filament/Livewire tests proving the resolution page renders blocked reason, required reports, next safe action, preparation progress, and technical detail disclosure. +- [ ] T081 Add Filament/Livewire tests proving the current step has exactly one primary executable action where allowed and that queued/regeneration/cancel/supersede actions use the required confirmation behavior. +- [x] T082 Add Filament/Livewire tests proving readonly/capability-denied users see safe disabled/blocked state and no executable button. +- [ ] T083 Add Filament/Livewire tests proving completed/ready state shows `Return to review` and does not auto-publish. +- [ ] T084 Add customer workspace tests proving no internal case, step, OperationRun debug, permission internals, raw report state, or technical remediation details are visible by default. + +## Phase 11: Filament Entry Point and Workflow UI Implementation + +**Purpose**: Add the visible workflow while preserving Filament v5 and TenantPilot UX rules. + +- [x] T085 Add the review-owned resolution route/page/action under `EnvironmentReviewResource` using existing Filament discovery conventions. +- [x] T086 Add or update the blocked-state CTA on `ViewEnvironmentReview` or the current summary view. +- [x] T087 Keep the review surface one-primary-action: `Resolve publication blockers` while blocked. +- [x] T088 Render resolution page decision summary with blocked reason, required reports, next safe action, what happens after this, and no-auto-publish copy. +- [x] T089 Render compact preparation progress in the first decision section, with the actionable step visually emphasized and completed/pending steps secondary. +- [x] T090 Render proof links behind proof sections and technical detail behind explicit disclosure. +- [x] T091 Render safe empty/ready/already-published states without creating unnecessary cases. +- [ ] T092 Add or update Customer Review Workspace safe preparation/unavailable wording only if needed for non-leakage. +- [x] T093 Use native Filament actions/components/shared primitives first; avoid local semantic color/button/card systems. +- [x] T094 Ensure no new top-level navigation item, generic resource, bulk action, or global search surface is added. +- [x] T095 Run focused Filament/Livewire tests and fix only in-scope issues. + +## Phase 12: Audit and Security + +**Purpose**: Make the workflow attributable and safe. + +- [x] T096 Add audit events for `review_publication_resolution.created`. +- [x] T097 Add audit events for `review_publication_resolution.resumed`. +- [x] T098 Add audit events for `review_publication_resolution.step_started`. +- [x] T099 Add audit events for `review_publication_resolution.operation_linked`. +- [x] T100 Add audit events for `review_publication_resolution.step_completed`. +- [x] T101 Add audit events for `review_publication_resolution.step_failed`. +- [x] T102 Add audit events for `review_publication_resolution.case_completed`. +- [x] T103 Add audit events for `review_publication_resolution.cancelled`. +- [x] T104 Add audit events for `review_publication_resolution.superseded`. +- [x] T105 Ensure audit payloads include safe workspace/environment/case/subject/step/run/proof/status identifiers and derived safe reason summaries only. +- [ ] T106 Add audit tests proving raw provider payloads, secrets, tokens, full report content, and full evidence JSON are not logged. +- [ ] T107 Add no-Graph-during-render and bounded DB-local planner/render tests or guards for the resolution page and blocked review render path. +- [ ] T108 Confirm cross-plane `/system` access does not expose or mutate admin-plane resolution cases. + +## Phase 13: UI/Productization Coverage + +**Purpose**: Satisfy UI-COV-001 for the new workflow and affected surfaces. + +- [x] T109 Update `docs/ui-ux-enterprise-audit/route-inventory.md` for the new subject-driven resolution route or page-action surface. +- [x] T110 Update `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` for the new workflow surface. +- [x] T111 Update or create the relevant page report for Review Publication Resolution workflow. +- [x] T112 Update the Environment Review detail page report for the blocked-state CTA/action hierarchy change. +- [ ] T113 Update Customer Review Workspace page report if safe preparation/non-leakage wording changes materially. +- [x] T114 Record screenshot/browser-smoke artifact paths for blocked CTA, open case, current step, running, failed, completed proof, ready return, customer no-leakage, and dark/mobile smoke where feasible. +- [x] T115 Confirm the spec UI Action Matrix still matches implementation; update spec/plan/tasks before merge if action placement or dangerous-action behavior changes. + +## Phase 14: Validation + +**Purpose**: Prove the implementation and capture residual risk. + +- [ ] T116 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/ReviewPublicationResolution`. +- [ ] T117 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPublicationResolution tests/Feature/EnvironmentReview tests/Feature/ReviewPack`. +- [ ] T118 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec386ReviewPublicationResolutionUiTest.php`. +- [ ] T119 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml --filter Spec386`. +- [x] T120 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec386ReviewPublicationResolutionWorkflowTest.php` (local fallback used: `php artisan test --compact tests/Browser/Spec386ReviewPublicationResolutionWorkflowTest.php`). +- [x] T121 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` (local fallback used: `./vendor/bin/pint --dirty --test`). +- [x] T122 Run `git diff --check`. +- [ ] T123 Record implementation close-out with Livewire v4 compliance, provider registration location, global search status, destructive/high-impact action handling, asset strategy, tests run, browser smoke result, and deployment impact. + +## Explicit Non-Goals + +- [x] NT001 Do not modify completed dependency specs except as read-only context. +- [x] NT002 Do not create a generic workflow engine, registry, adapter framework, or generic action-resolution CRUD resource. +- [x] NT003 Do not add top-level navigation or global search for resolution cases. +- [x] NT004 Do not auto-publish reviews or bypass existing publish/export gates. +- [x] NT005 Do not expose internal resolution case details to customer-facing surfaces. +- [x] NT006 Do not store raw provider payloads, raw report content, full evidence JSON, secrets, or tokens in case/step/audit metadata. +- [ ] NT007 Do not call Graph/provider APIs during UI render or readiness display. +- [ ] NT008 Do not transition `OperationRun.status` or `OperationRun.outcome` outside existing services. +- [ ] NT009 Do not implement Restore, Provider Onboarding, Baseline Compare, Report Delivery, Customer Portal, AI, Billing, or Cross-Tenant Promotion resolution adapters in this spec. +- [ ] NT010 Do not use generic `action_resolution_*` persistence without first updating spec/plan/tasks with proportionality justification and review-publication-only constraints.