diff --git a/apps/platform/app/Filament/Pages/Monitoring/Operations.php b/apps/platform/app/Filament/Pages/Monitoring/Operations.php index 01960cdd..7d3afe07 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/Operations.php +++ b/apps/platform/app/Filament/Pages/Monitoring/Operations.php @@ -21,9 +21,9 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\Operations\OperationLifecyclePolicy; use App\Support\OpsUx\OperationRunProgressContract; use App\Support\OpsUx\OperationUxPresenter; -use App\Support\Operations\OperationLifecyclePolicy; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; @@ -305,10 +305,10 @@ public function landingHierarchySummary(): array return [ 'scope_label' => $operateHubShell->scopeLabel(request()), 'scope_body' => $filteredTenant instanceof ManagedEnvironment - ? 'Operations Hub is workspace-scoped and filtered by an explicit environment filter.' + ? 'Filtered to one environment in this workspace.' : ($activeEnvironment instanceof ManagedEnvironment - ? 'Operations Hub is currently narrowed to one environment inside the active workspace.' - : 'Operations Hub is showing workspace-wide execution records across all entitled environments.'), + ? 'Showing the active environment inside this workspace.' + : 'Showing workspace-wide execution records across entitled environments.'), 'return_label' => $returnLabel, 'return_body' => $returnBody, 'scope_reset_label' => $activeEnvironment instanceof ManagedEnvironment ? __('localization.shell.show_all_environments') : null, @@ -445,6 +445,8 @@ private function workbenchOperationPayload(OperationRun $run, bool $hasAttention { $progress = OperationRunProgressContract::forRun($run); $decisionTruth = OperationUxPresenter::decisionZoneTruth($run); + $actionDecision = OperationRunResource::actionDecision($run); + $primaryAction = is_array($actionDecision['primary_action'] ?? null) ? $actionDecision['primary_action'] : null; $tenant = $run->tenant; return [ @@ -461,9 +463,11 @@ private function workbenchOperationPayload(OperationRun $run, bool $hasAttention 'environment' => $tenant instanceof ManagedEnvironment ? (string) $tenant->name : 'Workspace-level operation', 'timing' => $this->operationTiming($run), 'proof_label' => 'Operation detail available', - 'proof_body' => 'Open operation for stored proof, related links, and authorized diagnostics. Artifact or evidence links are unavailable here unless the detail surface proves them.', - 'primary_action_label' => OperationRunLinks::openLabel(), - 'primary_action_url' => OperationRunLinks::tenantlessView($run), + 'proof_body' => (string) ($actionDecision['attention_reason'] ?? 'Open operation for stored proof, related links, and authorized diagnostics.'), + 'primary_action_label' => is_string($primaryAction['label'] ?? null) + ? (string) $primaryAction['label'] + : OperationRunLinks::openLabel(), + 'primary_action_url' => OperationRunResource::primaryActionUrl($run), 'progress' => $progress, 'progress_label' => is_string($progress['label'] ?? null) ? $progress['label'] : null, 'show_progress_bar' => ($progress['display'] ?? null) === OperationRunProgressContract::COUNTED, @@ -609,7 +613,7 @@ public function table(Table $table): Table $allowedTenantIds = $this->allowedTenantIdsForWorkspaceScope($workspaceId); $query = OperationRun::query() - ->with('user') + ->with(['tenant', 'user']) ->latest('id') ->when( $workspaceId, diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index f8079464..9d9fdca5 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -13,6 +13,7 @@ use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Baselines\BaselineEvidenceCaptureResumeService; +use App\Services\Operations\OperationRunOperatorActionService; use App\Services\Tenants\TenantOperabilityService; use App\Support\Auth\Capabilities; use App\Support\Navigation\CanonicalNavigationContext; @@ -21,6 +22,7 @@ use App\Support\OperationCatalog; use App\Support\OperationRunLinks; use App\Support\OperationRunType; +use App\Support\Operations\OperationRunActionEligibility; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\RunDetailPolling; @@ -150,6 +152,12 @@ protected function getHeaderActions(): array return $actions; } + $primaryAction = $this->primaryOperationAction(); + + if ($primaryAction instanceof Action) { + $actions[] = $primaryAction; + } + $related = $this->relatedLinks(); $relatedActions = []; @@ -181,6 +189,85 @@ protected function getHeaderActions(): array return $actions; } + private function primaryOperationAction(): ?Action + { + $decision = $this->operationActionDecision(); + $primary = is_array($decision['primary_action'] ?? null) ? $decision['primary_action'] : null; + + if (! is_array($primary)) { + return null; + } + + $key = (string) ($primary['key'] ?? ''); + + if ($key === '' || $key === 'view_details') { + return null; + } + + if ($key === 'reconcile') { + return $this->reconcileOperationRunAction($primary); + } + + $url = $primary['url'] ?? null; + + if (! is_string($url) || trim($url) === '') { + return null; + } + + return Action::make('primary_'.$key) + ->label((string) ($primary['label'] ?? OperationRunLinks::openLabel())) + ->icon(is_string($primary['icon'] ?? null) ? (string) $primary['icon'] : 'heroicon-o-arrow-top-right-on-square') + ->color(is_string($primary['color'] ?? null) ? (string) $primary['color'] : 'primary') + ->url($url); + } + + /** + * @param array $primary + */ + private function reconcileOperationRunAction(array $primary): Action + { + return Action::make('reconcileOperationRun') + ->label((string) ($primary['label'] ?? __('localization.operations.actions.reconcile'))) + ->icon(is_string($primary['icon'] ?? null) ? (string) $primary['icon'] : 'heroicon-o-wrench-screwdriver') + ->color(is_string($primary['color'] ?? null) ? (string) $primary['color'] : 'warning') + ->requiresConfirmation() + ->modalHeading((string) ($primary['modal_heading'] ?? __('localization.operations.actions.reconcile_heading'))) + ->modalDescription((string) ($primary['modal_description'] ?? __('localization.operations.actions.reconcile_description'))) + ->modalSubmitActionLabel(__('localization.operations.actions.reconcile_submit')) + ->action(function (): void { + $this->reconcileOperationRun(); + }); + } + + private function reconcileOperationRun(): void + { + $user = $this->resolveViewerActor(); + + $result = app(OperationRunOperatorActionService::class)->reconcile($this->run, $user); + + $freshRun = $this->run->fresh(['workspace', 'tenant', 'user']); + + if ($freshRun instanceof OperationRun) { + $this->run = $freshRun; + } + + if (($result['applied'] ?? false) !== true) { + Notification::make() + ->title(__('localization.operations.actions.reconcile_noop_title')) + ->body(__('localization.operations.actions.reconcile_noop_body')) + ->warning() + ->send(); + + return; + } + + Notification::make() + ->title(__('localization.operations.actions.reconcile_success_title')) + ->body(__('localization.operations.actions.reconcile_success_body')) + ->success() + ->send(); + } + /** * @return array{ * scope_label: string, @@ -213,7 +300,15 @@ public function monitoringDetailSummary(): array ? 'Open keeps secondary drilldowns grouped under one control when downstream context exists.' : 'Open keeps secondary drilldowns grouped under one control: '.implode(', ', $relatedLabels).'.'; - $followUpLabel = $this->canResumeCapture() ? 'Resume capture' : null; + $actionDecision = $this->operationActionDecision(); + $primaryAction = $actionDecision['primary_action'] ?? null; + $hasOperatorFollowUp = is_array($primaryAction) + && ($primaryAction['key'] ?? null) !== 'view_details' + && is_string($primaryAction['label'] ?? null); + $canResumeCapture = $this->canResumeCapture(); + $followUpLabel = $canResumeCapture + ? 'Resume capture' + : ($hasOperatorFollowUp ? (string) $primaryAction['label'] : null); return [ 'scope_label' => $operateHubShell->scopeLabel(request()), @@ -222,13 +317,23 @@ public function monitoringDetailSummary(): array 'navigation_body' => $navigationBody, 'utility_body' => 'Refresh keeps the current run state accurate without changing scope.', 'related_body' => $relatedBody, - 'follow_up_body' => $followUpLabel !== null - ? 'Resume capture only appears when this run supports additional evidence collection.' - : 'No run-specific follow-up is currently available.', + 'follow_up_body' => match (true) { + $canResumeCapture => 'Resume capture only appears when this run supports additional evidence collection.', + $hasOperatorFollowUp => (string) ($actionDecision['attention_reason'] ?? 'Run-specific follow-up is available from the header.'), + default => 'No run-specific follow-up is currently available.', + }, 'follow_up_label' => $followUpLabel, ]; } + /** + * @return array + */ + public function operationActionDecision(): array + { + return app(OperationRunActionEligibility::class)->forRun($this->run, $this->resolveViewerActor()); + } + private function openSupportDiagnosticsAction(): Action { $action = Action::make('openSupportDiagnostics') diff --git a/apps/platform/app/Filament/Resources/OperationRunResource.php b/apps/platform/app/Filament/Resources/OperationRunResource.php index 1b00ee86..2c13c789 100644 --- a/apps/platform/app/Filament/Resources/OperationRunResource.php +++ b/apps/platform/app/Filament/Resources/OperationRunResource.php @@ -28,6 +28,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; +use App\Support\Operations\OperationRunActionEligibility; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\SummaryCountsNormalizer; @@ -55,6 +56,7 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Str; use UnitEnum; class OperationRunResource extends Resource @@ -106,7 +108,7 @@ public static function getEloquentQuery(): Builder $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); return parent::getEloquentQuery() - ->with('user') + ->with(['tenant', 'user']) ->latest('id') ->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId)) ->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0')); @@ -145,18 +147,33 @@ public static function table(Table $table): Table ->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color) ->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon) ->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor) - ->description(fn (OperationRun $record): ?string => static::lifecycleAttentionSummary($record)), + ->description(fn (OperationRun $record): ?string => static::historyStatusDescription($record)) + ->visibleFrom('xl') + ->width('10rem'), Tables\Columns\TextColumn::make('type') ->label('Operation') ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) + ->description(fn (OperationRun $record): string => static::historyOperationContext($record)) + ->lineClamp(1) + ->width('18rem') + ->wrap() ->searchable() ->sortable(), + Tables\Columns\TextColumn::make('scope') + ->label('Scope') + ->getStateUsing(fn (OperationRun $record): string => static::targetScopeDisplay($record) ?? 'Workspace-level operation') + ->wrap() + ->visibleFrom('2xl') + ->toggleable(), Tables\Columns\TextColumn::make('initiator_name') ->label('Initiator') + ->visibleFrom('2xl') ->searchable(), Tables\Columns\TextColumn::make('created_at') ->label('Started') ->since() + ->visibleFrom('xl') + ->width('8rem') ->sortable(), Tables\Columns\TextColumn::make('duration') ->getStateUsing(function (OperationRun $record): string { @@ -165,14 +182,26 @@ public static function table(Table $table): Table } return '—'; - }), + }) + ->visibleFrom('2xl') + ->width('7rem'), Tables\Columns\TextColumn::make('outcome') ->badge() ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label) ->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color) ->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon) ->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor) - ->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)), + ->description(fn (OperationRun $record): ?string => static::historyOutcomeDescription($record)) + ->width('14rem'), + Tables\Columns\TextColumn::make('next_action') + ->label('Next action') + ->getStateUsing(fn (OperationRun $record): string => static::primaryActionLabel($record)) + ->description(fn (OperationRun $record): ?string => static::historyActionDescription($record)) + ->icon(fn (OperationRun $record): ?string => data_get(static::actionDecision($record), 'primary_action.icon')) + ->color(fn (OperationRun $record): string => (string) (data_get(static::actionDecision($record), 'primary_action.color') ?: 'gray')) + ->url(fn (OperationRun $record): string => static::primaryActionUrl($record)) + ->width('14rem') + ->wrap(), ]) ->filters([ Tables\Filters\SelectFilter::make('managed_environment_id') @@ -704,6 +733,21 @@ private static function resolvePrimaryNextStep( ?OperatorExplanationPattern $operatorExplanation, ): array { $candidates = []; + $actionDecision = static::actionDecision($record); + $primaryAction = is_array($actionDecision['primary_action'] ?? null) ? $actionDecision['primary_action'] : null; + + if (static::shouldPromotePrimaryActionToNextStep($primaryAction)) { + $actionText = trim((string) ($primaryAction['label'] ?? '')); + $attentionReason = trim((string) ($actionDecision['attention_reason'] ?? '')); + + if ($actionText !== '') { + static::pushNextStepCandidate( + $candidates, + $attentionReason !== '' ? $actionText.'. '.$attentionReason : $actionText, + 'operator_action', + ); + } + } static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation'); static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth'); @@ -745,6 +789,23 @@ private static function resolvePrimaryNextStep( ]; } + /** + * @param array|null $primaryAction + */ + private static function shouldPromotePrimaryActionToNextStep(?array $primaryAction): bool + { + return is_array($primaryAction) + && in_array((string) ($primaryAction['key'] ?? ''), [ + 'reconcile', + 'view_review', + 'view_evidence', + 'view_report', + 'view_restore_details', + 'view_backup_details', + 'view_affected_families', + ], true); + } + /** * @param array $candidates */ @@ -805,6 +866,7 @@ private static function guidanceLabel(string $source): string 'artifact_truth' => 'Artifact guidance', 'blocked_reason' => 'Blocked prerequisite', 'lifecycle_attention' => 'Lifecycle guidance', + 'operator_action' => 'Safe operator action', default => 'General guidance', }; } @@ -849,6 +911,10 @@ private static function decisionFacts( mixed $restoreContinuation, ?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary, ): array { + $actionDecision = static::actionDecision($record); + $primaryActionLabel = static::primaryActionLabel($record); + $disabledReason = static::primaryDisabledReason($record); + if (! $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary) { return array_values(array_filter([ $factory->keyFact( @@ -861,6 +927,15 @@ private static function decisionFacts( $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor), ), + $factory->keyFact( + 'Safe next action', + $primaryActionLabel, + (string) ($actionDecision['attention_reason'] ?? 'Action availability is derived from stored run truth.'), + tone: ($actionDecision['high_risk'] ?? false) ? 'warning' : null, + ), + $disabledReason !== null + ? $factory->keyFact('Unavailable action', $disabledReason) + : null, static::artifactTruthFact($factory, $artifactTruth), $operatorExplanation instanceof OperatorExplanationPattern ? $factory->keyFact( @@ -900,6 +975,15 @@ private static function decisionFacts( $diagnosticSummary->executionOutcomeLabel, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor), ), + $factory->keyFact( + 'Safe next action', + $primaryActionLabel, + (string) ($actionDecision['attention_reason'] ?? 'Action availability is derived from stored run truth.'), + tone: ($actionDecision['high_risk'] ?? false) ? 'warning' : null, + ), + $disabledReason !== null + ? $factory->keyFact('Unavailable action', $disabledReason) + : null, static::artifactTruthFact( $factory, $artifactTruth, @@ -1327,6 +1411,150 @@ private static function verificationReportViewData(OperationRun $record): array ]; } + /** + * @return array + */ + public static function actionDecision(OperationRun $record): array + { + $user = auth()->user(); + + return app(OperationRunActionEligibility::class)->forRun( + $record, + $user instanceof User ? $user : null, + ); + } + + public static function primaryActionLabel(OperationRun $record): string + { + $primaryAction = static::actionDecision($record)['primary_action'] ?? null; + + if (is_array($primaryAction) && is_string($primaryAction['label'] ?? null) && trim((string) $primaryAction['label']) !== '') { + return trim((string) $primaryAction['label']); + } + + return __('localization.operations.actions.no_safe_action'); + } + + public static function primaryActionUrl(OperationRun $record): string + { + $primaryAction = static::actionDecision($record)['primary_action'] ?? null; + $url = is_array($primaryAction) && is_string($primaryAction['url'] ?? null) + ? trim((string) $primaryAction['url']) + : ''; + + return $url !== '' ? $url : OperationRunLinks::tenantlessView($record); + } + + private static function primaryDisabledReason(OperationRun $record): ?string + { + $decision = static::actionDecision($record); + $disabledReasons = is_array($decision['disabled_reasons'] ?? null) ? $decision['disabled_reasons'] : []; + + foreach (['retry', 'reconcile'] as $key) { + if (is_string($disabledReasons[$key] ?? null) && trim((string) $disabledReasons[$key]) !== '') { + return trim((string) $disabledReasons[$key]); + } + } + + return null; + } + + private static function historyStatusDescription(OperationRun $record): ?string + { + $freshness = static::freshnessLabel($record); + + if ($freshness !== null) { + return $freshness; + } + + return static::lifecycleAttentionSummary($record); + } + + private static function historyOperationContext(OperationRun $record): string + { + $status = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label; + $scope = static::compactTargetScopeDisplay($record); + $started = $record->created_at?->diffForHumans() ?? 'Timing unavailable'; + + return implode(' · ', array_values(array_filter([ + $status, + $scope, + $started, + ], static fn (?string $part): bool => $part !== null && $part !== ''))); + } + + private static function historyOutcomeDescription(OperationRun $record): ?string + { + if ($record->isCurrentlyActive()) { + return 'Execution in progress'; + } + + if ($record->isLifecycleReconciled()) { + return 'Reconciled truth'; + } + + return match ((string) $record->outcome) { + OperationRunOutcome::Succeeded->value => static::hasFollowUpPrimaryAction($record) + ? 'Execution complete; follow-up separate' + : 'Execution complete', + OperationRunOutcome::PartiallySucceeded->value => 'Partial result', + OperationRunOutcome::Failed->value => 'Needs failure review', + OperationRunOutcome::Blocked->value => 'Blocked prerequisite', + default => static::surfaceGuidance($record), + }; + } + + private static function historyActionDescription(OperationRun $record): ?string + { + $decision = static::actionDecision($record); + $primaryAction = is_array($decision['primary_action'] ?? null) ? $decision['primary_action'] : null; + $key = is_string($primaryAction['key'] ?? null) ? (string) $primaryAction['key'] : ''; + + return match ($key) { + 'reconcile' => 'Metadata-only repair', + 'view_review' => 'Review proof', + 'view_evidence' => 'Evidence proof', + 'view_report' => 'Report proof', + 'view_backup_details' => 'Backup proof', + 'view_restore_details' => ($decision['high_risk'] ?? false) ? 'Inspect only' : 'Restore proof', + 'view_affected_families' => 'Coverage proof', + 'view_details' => $record->requiresOperatorReview() ? 'Run proof' : null, + default => null, + }; + } + + private static function hasFollowUpPrimaryAction(OperationRun $record): bool + { + $primaryAction = static::actionDecision($record)['primary_action'] ?? null; + $key = is_array($primaryAction) && is_string($primaryAction['key'] ?? null) + ? (string) $primaryAction['key'] + : ''; + + return in_array($key, [ + 'reconcile', + 'view_review', + 'view_evidence', + 'view_report', + 'view_restore_details', + 'view_backup_details', + 'view_affected_families', + ], true); + } + + private static function compactTargetScopeDisplay(OperationRun $record): string + { + $tenant = $record->relationLoaded('tenant') ? $record->tenant : null; + $scope = $tenant instanceof ManagedEnvironment && trim((string) $tenant->name) !== '' + ? (string) $tenant->name + : static::targetScopeDisplay($record); + + if ($scope === null || $scope === '') { + $scope = static::targetScopeDisplay($record); + } + + return Str::limit($scope ?: 'Workspace-level operation', 42); + } + /** * @return array */ diff --git a/apps/platform/app/Policies/OperationRunPolicy.php b/apps/platform/app/Policies/OperationRunPolicy.php index 82a82934..ab8a7687 100644 --- a/apps/platform/app/Policies/OperationRunPolicy.php +++ b/apps/platform/app/Policies/OperationRunPolicy.php @@ -2,13 +2,14 @@ namespace App\Policies; -use App\Models\OperationRun; use App\Models\ManagedEnvironment; +use App\Models\OperationRun; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Services\Auth\ManagedEnvironmentAccessScopeResolver; use App\Support\Operations\OperationRunCapabilityResolver; +use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\Response; @@ -100,4 +101,57 @@ public function view(User $user, OperationRun $run): Response|bool return true; } + + public function reconcile(User $user, OperationRun $run): Response|bool + { + $view = $this->view($user, $run); + + if ($view instanceof Response && $view->denied()) { + return $view; + } + + if ($view === false) { + return Response::denyAsNotFound(); + } + + if (app(OperationRunReconciliationRegistry::class)->forType($run->canonicalOperationType()) === null) { + return Response::deny('Operation type does not support reconciliation.'); + } + + $requiredCapability = app(OperationRunCapabilityResolver::class) + ->requiredExecutionCapabilityForType((string) $run->type); + + if (! is_string($requiredCapability) || $requiredCapability === '') { + return Response::deny('Operation type has no reconcile capability.'); + } + + $workspaceId = (int) ($run->workspace_id ?? 0); + $tenantId = (int) ($run->managed_environment_id ?? 0); + + if (str_starts_with($requiredCapability, 'workspace')) { + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + return Gate::forUser($user)->allows($requiredCapability, $workspace) + ? Response::allow() + : Response::deny(); + } + + if ($tenantId <= 0) { + return Response::denyAsNotFound(); + } + + $tenant = ManagedEnvironment::query()->withTrashed()->whereKey($tenantId)->first(); + + if (! $tenant instanceof ManagedEnvironment || (int) $tenant->workspace_id !== $workspaceId) { + return Response::denyAsNotFound(); + } + + return Gate::forUser($user)->allows($requiredCapability, $tenant) + ? Response::allow() + : Response::deny(); + } } diff --git a/apps/platform/app/Services/Operations/OperationRunOperatorActionService.php b/apps/platform/app/Services/Operations/OperationRunOperatorActionService.php new file mode 100644 index 00000000..cf7905d9 --- /dev/null +++ b/apps/platform/app/Services/Operations/OperationRunOperatorActionService.php @@ -0,0 +1,196 @@ + + */ + public function reconcile(OperationRun $run, User $actor): array + { + $run->loadMissing(['workspace', 'tenant']); + + $authorization = Gate::forUser($actor)->inspect('reconcile', $run); + + if ($authorization->denied()) { + $this->recordAction( + run: $run, + actor: $actor, + action: 'operation.reconcile_denied', + status: 'denied', + reasonCode: 'policy_denied', + before: $this->state($run), + after: $this->state($run), + ); + + abort($authorization->status() ?: 403); + } + + $decision = $this->eligibility->forRun($run, $actor); + + if (! $this->hasEnabledAction($decision, 'reconcile')) { + $this->recordAction( + run: $run, + actor: $actor, + action: 'operation.reconcile_denied', + status: 'denied', + reasonCode: array_key_exists('reconcile', $decision['disabled_reasons']) + ? 'reconcile_unavailable' + : 'reconcile_not_primary', + before: $this->state($run), + after: $this->state($run), + ); + + abort(403); + } + + $before = $this->state($run); + $change = $this->reconciler->reconcileOperationRun($run, false); + + $run->refresh(); + $after = $this->state($run); + + if (! is_array($change) || ($change['applied'] ?? false) !== true) { + $this->recordAction( + run: $run, + actor: $actor, + action: 'operation.reconcile_noop', + status: 'warning', + reasonCode: is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'no_reconciliation_applied', + before: $before, + after: $after, + ); + + return [ + 'applied' => false, + 'reason_code' => is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'no_reconciliation_applied', + 'change' => $change, + ]; + } + + $this->recordAction( + run: $run, + actor: $actor, + action: 'operation.reconciled_by_operator', + status: 'success', + reasonCode: is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'operator_reconcile_applied', + before: $before, + after: $after, + ); + + return [ + 'applied' => true, + 'reason_code' => is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'operator_reconcile_applied', + 'change' => $change, + ]; + } + + /** + * @param array $decision + */ + private function hasEnabledAction(array $decision, string $key): bool + { + $primary = $decision['primary_action'] ?? null; + + if (is_array($primary) && ($primary['key'] ?? null) === $key) { + return true; + } + + foreach ($decision['secondary_actions'] ?? [] as $action) { + if (is_array($action) && ($action['key'] ?? null) === $key) { + return true; + } + } + + return false; + } + + /** + * @return array{status:string,outcome:string} + */ + private function state(OperationRun $run): array + { + return [ + 'status' => (string) $run->status, + 'outcome' => (string) $run->outcome, + ]; + } + + /** + * @param array{status:string,outcome:string} $before + * @param array{status:string,outcome:string} $after + */ + private function recordAction( + OperationRun $run, + User $actor, + string $action, + string $status, + string $reasonCode, + array $before, + array $after, + ): void { + $workspace = $run->workspace instanceof Workspace ? $run->workspace : null; + $tenant = $run->tenant instanceof ManagedEnvironment ? $run->tenant : null; + + if (! $workspace instanceof Workspace) { + return; + } + + $this->auditRecorder->record( + action: $action, + context: [ + 'metadata' => [ + 'operator_action' => 'reconcile', + 'operation_run_id' => (int) $run->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'managed_environment_id' => $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null, + 'actor_user_id' => (int) $actor->getKey(), + 'operation_type' => OperationCatalog::canonicalCode((string) $run->type), + 'previous_status' => $before['status'], + 'previous_outcome' => $before['outcome'], + 'resulting_status' => $after['status'], + 'resulting_outcome' => $after['outcome'], + 'reason_code' => $reasonCode, + 'requested_at' => now()->toIso8601String(), + 'mutation_scope' => 'tenantpilot_operation_metadata_only', + ], + ], + workspace: $workspace, + tenant: $tenant, + actor: AuditActorSnapshot::human($actor), + target: new AuditTargetSnapshot( + type: 'operation_run', + id: (int) $run->getKey(), + label: OperationCatalog::label((string) $run->type).' #'.$run->getKey(), + ), + outcome: $status, + summary: match ($status) { + 'success' => 'Operation run reconciled by operator', + 'warning' => 'Operation run reconcile action had no effect', + default => 'Operation run reconcile action denied', + }, + operationRunId: (int) $run->getKey(), + ); + } +} diff --git a/apps/platform/app/Support/Operations/OperationRunActionEligibility.php b/apps/platform/app/Support/Operations/OperationRunActionEligibility.php new file mode 100644 index 00000000..f285ada6 --- /dev/null +++ b/apps/platform/app/Support/Operations/OperationRunActionEligibility.php @@ -0,0 +1,456 @@ +|null, + * secondary_actions: list>, + * disabled_actions: list>, + * disabled_reasons: array, + * attention_reason: string, + * mutation_scope: ?string, + * high_risk: bool, + * freshness_state: string + * } + */ + public function forRun(OperationRun $run, ?User $user = null): array + { + $run->loadMissing(['workspace', 'tenant']); + + $canonicalType = $run->canonicalOperationType(); + $freshnessState = $run->freshnessState(); + $highRisk = $this->isHighRisk($canonicalType); + $disabledReasons = []; + $secondaryActions = []; + + if ($user instanceof User && Gate::forUser($user)->inspect('view', $run)->denied()) { + return $this->result( + primaryAction: null, + secondaryActions: [], + disabledReasons: [ + 'view_details' => __('localization.operations.actions.disabled.scope_unavailable'), + 'reconcile' => __('localization.operations.actions.disabled.scope_unavailable'), + 'retry' => __('localization.operations.actions.disabled.scope_unavailable'), + ], + attentionReason: __('localization.operations.actions.attention.scope_unavailable'), + mutationScope: null, + highRisk: $highRisk, + freshnessState: $freshnessState->value, + ); + } + + $relatedPrimary = $this->primaryRelatedAction($run); + $canReconcile = $this->canOfferReconcile($run, $user); + $reconcileAction = $this->reconcileAction($run); + + if (! $canReconcile) { + $disabledReasons['reconcile'] = $this->reconcileDisabledReason($run, $user); + } + + $retryReason = $this->retryDisabledReason($run, $highRisk); + $disabledReasons['retry'] = $retryReason; + + if ($this->canViewDiagnostics($run, $user)) { + $secondaryActions[] = $this->diagnosticsAction(); + } else { + $disabledReasons['open_support_diagnostics'] = __('localization.operations.actions.disabled.missing_diagnostics_capability'); + } + + $primaryAction = match (true) { + $canReconcile && ! $highRisk => $reconcileAction, + $relatedPrimary !== null => $relatedPrimary, + default => $this->viewDetailsAction($run), + }; + + if ($canReconcile && $primaryAction['key'] !== 'reconcile') { + array_unshift($secondaryActions, $reconcileAction); + } + + return $this->result( + primaryAction: $primaryAction, + secondaryActions: $secondaryActions, + disabledReasons: $disabledReasons, + attentionReason: $this->attentionReason($run, $primaryAction, $retryReason), + mutationScope: $primaryAction['mutation_scope'] ?? null, + highRisk: $highRisk, + freshnessState: $freshnessState->value, + ); + } + + /** + * @param list> $secondaryActions + * @param array $disabledReasons + * @return array{ + * primary_action: array|null, + * secondary_actions: list>, + * disabled_actions: list>, + * disabled_reasons: array, + * attention_reason: string, + * mutation_scope: ?string, + * high_risk: bool, + * freshness_state: string + * } + */ + private function result( + ?array $primaryAction, + array $secondaryActions, + array $disabledReasons, + string $attentionReason, + ?string $mutationScope, + bool $highRisk, + string $freshnessState, + ): array { + return [ + 'primary_action' => $primaryAction, + 'secondary_actions' => array_values($secondaryActions), + 'disabled_actions' => array_map( + static fn (string $key, string $reason): array => [ + 'key' => $key, + 'label' => __('localization.operations.actions.disabled_labels.'.$key), + 'disabled_reason' => $reason, + ], + array_keys($disabledReasons), + array_values($disabledReasons), + ), + 'disabled_reasons' => $disabledReasons, + 'attention_reason' => $attentionReason, + 'mutation_scope' => $mutationScope, + 'high_risk' => $highRisk, + 'freshness_state' => $freshnessState, + ]; + } + + private function canOfferReconcile(OperationRun $run, ?User $user): bool + { + if (! $run->isCurrentlyActive()) { + return false; + } + + $adapter = $this->reconciliationRegistry->forType($run->canonicalOperationType()); + + if (! $adapter instanceof OperationRunReconciliationAdapter) { + return false; + } + + if (! $run->freshnessState()->isLikelyStale()) { + return false; + } + + if ($run->isLifecycleReconciled()) { + return false; + } + + if ($user instanceof User && Gate::forUser($user)->inspect('reconcile', $run)->denied()) { + return false; + } + + return $this->hasSufficientReconciliationProof($adapter, $run); + } + + private function reconcileDisabledReason(OperationRun $run, ?User $user): string + { + if ($run->isLifecycleReconciled()) { + return __('localization.operations.actions.disabled.already_reconciled'); + } + + if (! $run->isCurrentlyActive()) { + return __('localization.operations.actions.disabled.terminal_run'); + } + + $adapter = $this->reconciliationRegistry->forType($run->canonicalOperationType()); + + if (! $adapter instanceof OperationRunReconciliationAdapter) { + return __('localization.operations.actions.disabled.unsupported_reconcile'); + } + + if (! $run->freshnessState()->isLikelyStale()) { + return __('localization.operations.actions.disabled.lifecycle_fresh'); + } + + if ($user instanceof User && Gate::forUser($user)->inspect('reconcile', $run)->denied()) { + return __('localization.operations.actions.disabled.missing_capability'); + } + + if (! $this->hasSufficientReconciliationProof($adapter, $run)) { + return __('localization.operations.actions.disabled.insufficient_proof'); + } + + return __('localization.operations.actions.disabled.insufficient_proof'); + } + + private function hasSufficientReconciliationProof( + OperationRunReconciliationAdapter $adapter, + OperationRun $run, + ): bool { + try { + $result = $adapter->reconcile($run); + } catch (Throwable) { + return false; + } + + return $result instanceof ReconciliationResult + && $result->safeForAutoCompletion + && $result->shouldFinalizeRun() + && $result->decision !== 'attention_required'; + } + + private function retryDisabledReason(OperationRun $run, bool $highRisk): string + { + if ($highRisk) { + return __('localization.operations.actions.disabled.high_risk_retry'); + } + + if ((string) $run->status === OperationRunStatus::Completed->value + && (string) $run->outcome === OperationRunOutcome::Succeeded->value) { + return __('localization.operations.actions.disabled.completed_succeeded'); + } + + return __('localization.operations.actions.disabled.retry_deferred'); + } + + private function canViewDiagnostics(OperationRun $run, ?User $user): bool + { + if (! $user instanceof User) { + return false; + } + + $tenant = $run->tenant; + + if ($tenant instanceof ManagedEnvironment) { + return $this->tenantCapabilities->isMember($user, $tenant) + && $this->tenantCapabilities->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW); + } + + $workspace = $run->workspace; + + return $workspace instanceof Workspace + && $this->workspaceCapabilities->isMember($user, $workspace) + && $this->workspaceCapabilities->can($user, $workspace, Capabilities::AUDIT_VIEW); + } + + private function isHighRisk(string $canonicalType): bool + { + if (in_array($canonicalType, [ + OperationRunType::RestoreExecute->value, + OperationRunType::PromotionExecute->value, + ], true)) { + return true; + } + + if (! array_key_exists($canonicalType, OperationCatalog::canonicalInventory())) { + return true; + } + + foreach (['restore', 'delete', 'purge', 'force_delete', 'promotion'] as $needle) { + if (str_contains($canonicalType, $needle)) { + return true; + } + } + + return false; + } + + /** + * @return array|null + */ + private function primaryRelatedAction(OperationRun $run): ?array + { + $tenant = $run->tenant instanceof ManagedEnvironment ? $run->tenant : null; + $links = OperationRunLinks::related($run, $tenant); + unset($links[OperationRunLinks::collectionLabel()]); + + if ($links === []) { + return null; + } + + $canonicalType = $run->canonicalOperationType(); + $preferred = match ($canonicalType) { + OperationRunType::EnvironmentReviewCompose->value => ['ManagedEnvironment Review', 'Review'], + OperationRunType::EvidenceSnapshotGenerate->value => ['Evidence Snapshot'], + OperationRunType::ReviewPackGenerate->value => ['Review Pack', 'Report'], + OperationRunType::RestoreExecute->value => ['Restore Run', 'Restore Runs'], + OperationRunType::BackupScheduleExecute->value => ['Backup Set', 'Backup Sets'], + OperationRunType::InventorySync->value => ['Inventory Coverage', 'Inventory'], + default => [], + }; + + $label = null; + $url = null; + + foreach ($preferred as $candidate) { + if (isset($links[$candidate])) { + $label = $candidate; + $url = $links[$candidate]; + break; + } + } + + if ($label === null || $url === null) { + $label = array_key_first($links); + $url = $label !== null ? $links[$label] : null; + } + + if (! is_string($label) || ! is_string($url) || $url === '') { + return null; + } + + return [ + 'key' => $this->relatedActionKey($canonicalType, $label), + 'label' => $this->relatedActionLabel($canonicalType, $label), + 'type' => 'url', + 'url' => $url, + 'icon' => 'heroicon-o-arrow-top-right-on-square', + 'color' => $canonicalType === OperationRunType::RestoreExecute->value ? 'warning' : 'primary', + 'requires_confirmation' => false, + 'mutation_scope' => null, + 'related_label' => $label, + ]; + } + + private function relatedActionKey(string $canonicalType, string $label): string + { + return match ($canonicalType) { + OperationRunType::EnvironmentReviewCompose->value => 'view_review', + OperationRunType::EvidenceSnapshotGenerate->value => 'view_evidence', + OperationRunType::ReviewPackGenerate->value => 'view_report', + OperationRunType::RestoreExecute->value => 'view_restore_details', + OperationRunType::BackupScheduleExecute->value => 'view_backup_details', + OperationRunType::InventorySync->value => 'view_affected_families', + default => 'view_'.str($label)->slug('_')->toString(), + }; + } + + private function relatedActionLabel(string $canonicalType, string $fallbackLabel): string + { + return match ($canonicalType) { + OperationRunType::EnvironmentReviewCompose->value => __('localization.operations.actions.view_review'), + OperationRunType::EvidenceSnapshotGenerate->value => __('localization.operations.actions.view_evidence'), + OperationRunType::ReviewPackGenerate->value => __('localization.operations.actions.view_report'), + OperationRunType::RestoreExecute->value => __('localization.operations.actions.view_restore_details'), + OperationRunType::BackupScheduleExecute->value => __('localization.operations.actions.view_backup_details'), + OperationRunType::InventorySync->value => __('localization.operations.actions.view_affected_families'), + default => $fallbackLabel, + }; + } + + /** + * @return array + */ + private function reconcileAction(OperationRun $run): array + { + return [ + 'key' => 'reconcile', + 'label' => __('localization.operations.actions.reconcile'), + 'type' => 'action', + 'url' => null, + 'icon' => 'heroicon-o-wrench-screwdriver', + 'color' => 'warning', + 'requires_confirmation' => true, + 'mutation_scope' => __('localization.operations.actions.mutation_scope_reconcile'), + 'modal_heading' => __('localization.operations.actions.reconcile_heading'), + 'modal_description' => __('localization.operations.actions.reconcile_description'), + ]; + } + + /** + * @return array + */ + private function diagnosticsAction(): array + { + return [ + 'key' => 'open_support_diagnostics', + 'label' => __('localization.operations.actions.open_diagnostics'), + 'type' => 'modal', + 'url' => null, + 'icon' => 'heroicon-o-lifebuoy', + 'color' => 'gray', + 'requires_confirmation' => false, + 'mutation_scope' => null, + ]; + } + + /** + * @return array + */ + private function viewDetailsAction(OperationRun $run): array + { + return [ + 'key' => 'view_details', + 'label' => __('localization.operations.actions.view_details'), + 'type' => 'url', + 'url' => OperationRunLinks::tenantlessView($run), + 'icon' => 'heroicon-o-document-magnifying-glass', + 'color' => 'gray', + 'requires_confirmation' => false, + 'mutation_scope' => null, + ]; + } + + /** + * @param array|null $primaryAction + */ + private function attentionReason(OperationRun $run, ?array $primaryAction, string $retryReason): string + { + if ($run->isLifecycleReconciled() && $run->reconciledRelatedType() !== null) { + return __('localization.operations.actions.attention.related_available'); + } + + if ($run->freshnessState()->isLikelyStale()) { + return ($primaryAction['key'] ?? null) === 'reconcile' + ? __('localization.operations.actions.attention.reconcile_available') + : __('localization.operations.actions.attention.stale_review'); + } + + if ($this->isHighRisk($run->canonicalOperationType())) { + return $retryReason; + } + + if ((string) $run->outcome === OperationRunOutcome::PartiallySucceeded->value) { + return __('localization.operations.actions.attention.partial'); + } + + if ((string) $run->outcome === OperationRunOutcome::Blocked->value) { + return __('localization.operations.actions.attention.blocked'); + } + + if ((string) $run->outcome === OperationRunOutcome::Failed->value) { + return __('localization.operations.actions.attention.failed'); + } + + if ($run->isCurrentlyActive()) { + return __('localization.operations.actions.attention.active'); + } + + return __('localization.operations.actions.attention.default'); + } +} diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php index 61bd751f..05291307 100644 --- a/apps/platform/lang/de/localization.php +++ b/apps/platform/lang/de/localization.php @@ -1281,6 +1281,60 @@ 'policies' => 'Richtlinien', ], ], + 'operations' => [ + 'actions' => [ + 'view_details' => 'Details anzeigen', + 'view_review' => 'Review anzeigen', + 'view_evidence' => 'Evidence anzeigen', + 'view_report' => 'Report anzeigen', + 'view_backup_details' => 'Backup-Details anzeigen', + 'view_restore_details' => 'Wiederherstellungsdetails anzeigen', + 'view_affected_families' => 'Betroffene Familien anzeigen', + 'reconcile' => 'Lauf abgleichen', + 'reconcile_heading' => 'Diesen OperationRun abgleichen?', + 'reconcile_description' => 'TenantPilot prüft vorhandene Repository-Proofs und aktualisiert nur TenantPilot-OperationRun- und Aktionsmetadaten, wenn der Adapter den finalen Laufzustand belegen kann. Dies startet keinen Retry und verändert den Microsoft-Tenant nicht.', + 'reconcile_submit' => 'Lauf abgleichen', + 'reconcile_success_title' => 'Operation abgeglichen', + 'reconcile_success_body' => 'TenantPilot hat den Lauf aus vorhandenen Repository-Proofs aktualisiert.', + 'reconcile_noop_title' => 'Kein Abgleich angewendet', + 'reconcile_noop_body' => 'Der Adapter konnte keine sichere Zustandsänderung für diesen Lauf belegen.', + 'open_diagnostics' => 'Diagnose öffnen', + 'no_safe_action' => 'Keine sichere Aktion', + 'mutation_scope_reconcile' => 'Nur TenantPilot-OperationRun- und Aktionsmetadaten.', + 'attention' => [ + 'active' => 'Die Operation liegt noch im erwarteten Lebenszyklusfenster.', + 'blocked' => 'Prüfen Sie die blockierte Voraussetzung, bevor überlappende Arbeit gestartet wird.', + 'default' => 'Öffnen Sie die Operationsdetails für gespeicherte Proofs und nächsten Kontext.', + 'failed' => 'Prüfen Sie den Fehler-Proof, bevor überlappende Arbeit gestartet wird.', + 'partial' => 'Prüfen Sie betroffene Elemente, bevor Sie sich auf das Ergebnis verlassen.', + 'reconcile_available' => 'Vorhandene Repository-Proofs können diesen stale Lauf sicher abgleichen.', + 'related_available' => 'Das zugehörige Artefakt ist über kanonische Metadaten verfügbar.', + 'scope_unavailable' => 'Die Operation liegt außerhalb des aktuellen Benutzerkontexts.', + 'stale_review' => 'Dieser Lauf liegt außerhalb seines Lebenszyklusfensters und muss vor einem Retry geprüft werden.', + ], + 'disabled' => [ + 'already_reconciled' => 'Dieser Lauf hat bereits Reconciliation-Metadaten.', + 'completed_succeeded' => 'Erfolgreich abgeschlossene Läufe sind in dieser Ansicht nicht retryfähig.', + 'forbidden' => 'Diese Aktion ist für OperationRuns verboten.', + 'high_risk' => 'High-Risk-Operationen dürfen keine erfolgserzwingenden oder destruktiven Aktionen anbieten.', + 'high_risk_retry' => 'High-Risk-Operationen können in dieser Ansicht nicht erneut gestartet werden.', + 'insufficient_proof' => 'Gespeicherte Proofs reichen für einen sicheren Abgleich nicht aus.', + 'lifecycle_fresh' => 'Die Operation liegt noch im erwarteten Lebenszyklusfenster.', + 'missing_capability' => 'Ihnen fehlt die erforderliche Berechtigung für diese Operator-Aktion.', + 'missing_diagnostics_capability' => 'Support-Diagnosen erfordern die Support-Diagnose-Berechtigung.', + 'retry_deferred' => 'Retry ist nicht verfügbar, weil für diese Operationsfamilie kein sicherer repo-verifizierter Retry-Seam existiert.', + 'scope_unavailable' => 'Die Operation liegt außerhalb des aktuellen Benutzerkontexts.', + 'terminal_run' => 'Terminale Läufe können über diese Aktion nicht abgeglichen werden.', + 'unsupported_reconcile' => 'Dieser Operationstyp hat keinen Reconciliation-Adapter.', + ], + 'disabled_labels' => [ + 'open_support_diagnostics' => 'Diagnose öffnen', + 'reconcile' => 'Lauf abgleichen', + 'retry' => 'Lauf erneut starten', + 'view_details' => 'Details anzeigen', + ], + ], + ], 'notifications' => [ 'locale_override_saved' => 'Sprachüberschreibung angewendet.', 'locale_override_cleared' => 'Sprachüberschreibung gelöscht.', diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php index 79c55f02..1ac0f2cc 100644 --- a/apps/platform/lang/en/localization.php +++ b/apps/platform/lang/en/localization.php @@ -1281,6 +1281,60 @@ 'policies' => 'Policies', ], ], + 'operations' => [ + 'actions' => [ + 'view_details' => 'View details', + 'view_review' => 'View review', + 'view_evidence' => 'View evidence', + 'view_report' => 'View report', + 'view_backup_details' => 'View backup details', + 'view_restore_details' => 'View restore details', + 'view_affected_families' => 'View affected families', + 'reconcile' => 'Reconcile run', + 'reconcile_heading' => 'Reconcile this operation run?', + 'reconcile_description' => 'TenantPilot will inspect existing repository proof and update only TenantPilot OperationRun/action metadata when the adapter can prove the final run state. This does not retry or change the Microsoft tenant.', + 'reconcile_submit' => 'Reconcile run', + 'reconcile_success_title' => 'Operation reconciled', + 'reconcile_success_body' => 'TenantPilot updated the run from existing repository proof.', + 'reconcile_noop_title' => 'No reconciliation applied', + 'reconcile_noop_body' => 'The adapter could not prove a safe state change for this run.', + 'open_diagnostics' => 'Open diagnostics', + 'no_safe_action' => 'No safe action', + 'mutation_scope_reconcile' => 'TenantPilot-only OperationRun/action metadata.', + 'attention' => [ + 'active' => 'The operation is still inside its expected lifecycle window.', + 'blocked' => 'Review the blocked prerequisite before starting overlapping work.', + 'default' => 'Open the operation detail for stored proof and next-step context.', + 'failed' => 'Review the failure proof before starting overlapping work.', + 'partial' => 'Review affected items before relying on the result.', + 'reconcile_available' => 'Existing repository proof may safely reconcile this stale run.', + 'related_available' => 'The related artifact is already available from canonical metadata.', + 'scope_unavailable' => 'The operation is outside the current user scope.', + 'stale_review' => 'This run is past its lifecycle window and needs review before retrying.', + ], + 'disabled' => [ + 'already_reconciled' => 'This run already has reconciliation metadata.', + 'completed_succeeded' => 'Completed successful runs are not retryable from this view.', + 'forbidden' => 'This action is forbidden for OperationRuns.', + 'high_risk' => 'High-risk operations cannot expose success-forcing or destructive actions.', + 'high_risk_retry' => 'High-risk operations cannot be retried from this view.', + 'insufficient_proof' => 'Stored proof is insufficient for safe reconciliation.', + 'lifecycle_fresh' => 'The operation is still within its expected lifecycle window.', + 'missing_capability' => 'You do not have the capability required for this operator action.', + 'missing_diagnostics_capability' => 'Support diagnostics require the support diagnostics capability.', + 'retry_deferred' => 'Retry is unavailable because no safe repo-verified retry seam exists for this operation family.', + 'scope_unavailable' => 'The operation is outside the current user scope.', + 'terminal_run' => 'Terminal runs cannot be reconciled from this action.', + 'unsupported_reconcile' => 'This operation type has no reconciliation adapter.', + ], + 'disabled_labels' => [ + 'open_support_diagnostics' => 'Open diagnostics', + 'reconcile' => 'Reconcile run', + 'retry' => 'Retry run', + 'view_details' => 'View details', + ], + ], + ], 'notifications' => [ 'locale_override_saved' => 'Language override applied.', 'locale_override_cleared' => 'Language override cleared.', diff --git a/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php b/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php index 80dc64e9..a1582a52 100644 --- a/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php +++ b/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php @@ -8,6 +8,12 @@ $diagnostics = $workbench['diagnostics'] ?? []; $staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION; $terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP; + $attentionBadgeColor = match (true) { + $selectedOperation === null => 'gray', + ($workbench['has_attention'] ?? false) === true => 'warning', + ($selectedOperation['progress']['display'] ?? null) !== \App\Support\OpsUx\OperationRunProgressContract::NONE => 'info', + default => 'gray', + }; @endphp
@@ -15,21 +21,21 @@

Operations Hub

-

Execution follow-up workbench

+

Execution follow-up

- OperationRuns are execution truth. This page prioritizes stored operation outcomes, proof paths, and follow-up without claiming environment or governance health. + Scan active, stale, failed, and partial OperationRuns. Open proof or the one safe next action without exposing diagnostics by default.

-
+
@if ($landingHierarchy['scope_label'] !== __('localization.shell.all_environments')) - + {{ $landingHierarchy['scope_label'] }} - + @endif - +

{{ $landingHierarchy['scope_body'] }} - +

@@ -56,22 +62,17 @@
@if ($selectedOperation !== null) - ($workbench['has_attention'] ?? false) === true, - 'bg-info-50 text-info-700 dark:bg-info-950/40 dark:text-info-300' => ($workbench['has_attention'] ?? false) === false && ($selectedOperation['progress']['display'] ?? null) !== \App\Support\OpsUx\OperationRunProgressContract::NONE, - 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200' => ($workbench['has_attention'] ?? false) === false && ($selectedOperation['progress']['display'] ?? null) === \App\Support\OpsUx\OperationRunProgressContract::NONE, - ])> + {{ $selectedOperation['attention_label'] }} - + @endif
@if (! ($workbench['has_attention'] ?? false))
-

No operations need attention

+

No operations need follow-up

- No failed, blocked, partial, or stale OperationRuns are visible in this scope. This is execution follow-up only, not an environment health claim. + No failed, blocked, partial, or stale OperationRuns are visible in this scope.

@endif @@ -87,12 +88,12 @@
- + {{ $selectedOperation['identifier'] }} - - + + Environment: {{ $selectedOperation['environment'] }} - +

@@ -230,9 +231,9 @@ class="mt-2 w-full"
-

Operations history

+

Recent runs

- Secondary context for scanning OperationRun history after the top decision path is clear. + Chronological operation record. Use tabs for attention states; open a row for proof and authorized diagnostics.

@@ -296,7 +297,7 @@ class="mt-2 w-full"

- Environment filters and the selected operations tab remain shareable through the URL. Additional table filters still restore from the last compatible session state. + Tabs are shareable through the URL. Table filters restore from your session.

@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0) diff --git a/apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php b/apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php index b6c08bde..94c5c685 100644 --- a/apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php +++ b/apps/platform/tests/Browser/Spec198MonitoringPageStateSmokeTest.php @@ -100,8 +100,8 @@ 'activeTab' => 'active', ])) ->waitForText('Operations Hub') - ->assertSee('Environment filters and the selected operations tab remain shareable through the URL.') - ->assertSee('Open operation') + ->assertSee('Tabs are shareable through the URL.') + ->assertSee('Next action') ->assertNoJavaScriptErrors() ->assertNoConsoleLogs(); diff --git a/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php b/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php index 3345ec86..bca2e4d1 100644 --- a/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php +++ b/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php @@ -21,7 +21,7 @@ ->waitForText('Operations Hub') ->assertDontSee(__('localization.shell.no_environment_selected')) ->assertDontSee('Environment filter:') - ->assertSee('Execution follow-up workbench') + ->assertSee('Execution follow-up') ->assertSee('Which operation needs attention now?') ->assertSee('Decision workbench') ->assertSee('Needs attention') @@ -41,9 +41,10 @@ ->assertSee('Proof') ->assertSee('Operation detail available') ->assertSee('Primary next action') - ->assertSee('Open operation') - ->assertSee('Operations history') + ->assertSee('Next action') + ->assertSee('Recent runs') ->assertSee('Policy sync') + ->assertSee('View affected families') ->assertDontSee('tenant filter') ->assertDontSee('current tenant') ->assertDontSee('entitled tenant') @@ -54,6 +55,23 @@ ->assertDontSee('debug metadata should stay hidden') ->assertDontSee('internal exception should stay hidden') ->assertScript('document.querySelector("[data-testid=\"operations-hub-diagnostics\"]")?.open === false', true) + ->assertScript('(() => { + const labels = Array.from(document.querySelectorAll("td.fi-ta-cell-next-action p")) + .filter((element) => element.textContent?.includes("View affected families")); + + if (labels.length === 0) { + return false; + } + + return labels.every((element) => { + const styles = getComputedStyle(element); + + return element.scrollWidth <= element.clientWidth + 1 + && element.scrollHeight <= element.clientHeight + 1 + && styles.textOverflow !== "ellipsis" + && styles.webkitLineClamp !== "1"; + }); + })()', true) ->assertScript('(() => { const summaryCards = document.querySelector("[data-testid=\"operations-hub-summary-cards\"]"); const nativeStats = summaryCards?.querySelector(".fi-wi-stats-overview"); @@ -205,8 +223,8 @@ visit(OperationRunLinks::index(workspace: $environment->workspace)) ->waitForText('Operations Hub') - ->assertSee('No operations need attention') - ->assertSee('This is execution follow-up only, not an environment health claim.') + ->assertSee('No operations need follow-up') + ->assertSee('No failed, blocked, partial, or stale OperationRuns are visible in this scope.') ->assertSee('Operation detail available') ->assertDontSee('environment is healthy') ->assertDontSee('governance health is complete') diff --git a/apps/platform/tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php b/apps/platform/tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php new file mode 100644 index 00000000..1169acd1 --- /dev/null +++ b/apps/platform/tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php @@ -0,0 +1,301 @@ +browser()->timeout(60_000); + +it('Spec365 smokes confirmed review reconciliation guidance on operations surfaces', function (): void { + [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + + spec365BrowserAuthenticate($this, $user, $environment); + + $snapshot = seedEnvironmentReviewEvidence($environment, operationRunCount: 0); + $fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($environment, $snapshot); + $run = spec365BrowserCreateStaleReviewRun($environment, $user, $fingerprint, $snapshot); + + spec365BrowserCreateReadyReviewTruth($environment, $user, $snapshot, $fingerprint); + + visit(OperationRunLinks::index($environment)) + ->resize(1440, 1100) + ->waitForText('Operations Hub') + ->assertSee('Reconcile run') + ->assertSee('Existing repository proof may safely reconcile this stale run.') + ->assertDontSee('SQLSTATE') + ->assertDontSee('access token') + ->assertDontSee('client secret') + ->assertDontSee('serialized job') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + visit(OperationRunLinks::tenantlessView($run)) + ->waitForText('Monitoring detail') + ->assertSee('Reconcile run') + ->click('Reconcile run') + ->assertSee('Reconcile this operation run?') + ->assertSee('This does not retry or change the Microsoft tenant.') + ->assertDontSee('SQLSTATE') + ->assertDontSee('environment_reviews_fingerprint_mutable_unique') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); + +it('Spec365 smokes related proof actions across evidence report sync and backup states', function (): void { + [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + + spec365BrowserAuthenticate($this, $user, $environment); + spec365BrowserCreateEvidenceRun($environment, $user); + spec365BrowserCreateReviewPackRun($environment, $user); + spec365BrowserCreatePartialInventoryRun($environment, $user); + spec365BrowserCreateBlockedBackupRun($environment, $user); + + visit(OperationRunLinks::index($environment)) + ->resize(1440, 1100) + ->waitForText('Operations Hub') + ->assertSee('View evidence') + ->assertSee('View report') + ->assertSee('View affected families') + ->assertSee('View backup details') + ->assertDontSee('SQLSTATE') + ->assertDontSee('Guzzle') + ->assertDontSee('stack trace') + ->assertDontSee('access token') + ->assertDontSee('client secret') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); + +it('Spec365 smokes high-risk restore guidance without unsafe operator actions', function (): void { + [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + + spec365BrowserAuthenticate($this, $user, $environment); + + $run = spec365BrowserCreateStaleRestoreRun($environment, $user); + + visit(OperationRunLinks::tenantlessView($run)) + ->resize(1440, 1100) + ->waitForText('Monitoring detail') + ->assertSee('View restore details') + ->assertSee('High-risk operations cannot be retried from this view.') + ->assertDontSee('Retry restore') + ->assertDontSee('Force complete') + ->assertDontSee('Mark succeeded') + ->assertDontSee('Delete run') + ->assertDontSee('Purge run') + ->assertDontSee('stack trace') + ->assertDontSee('access token') + ->assertDontSee('client secret') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); + +function spec365BrowserAuthenticate(mixed $test, User $user, ManagedEnvironment $environment): void +{ + $workspaceId = (int) $environment->workspace_id; + + $test->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [ + (string) $workspaceId => (int) $environment->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [ + (string) $workspaceId => (int) $environment->getKey(), + ]); + + setAdminPanelContext($environment); +} + +function spec365BrowserCreateStaleReviewRun( + ManagedEnvironment $environment, + User $user, + string $fingerprint, + EvidenceSnapshot $snapshot, +): OperationRun { + return OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + 'context' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'review_fingerprint' => $fingerprint, + ], + ]); +} + +function spec365BrowserCreateReadyReviewTruth( + ManagedEnvironment $environment, + User $user, + EvidenceSnapshot $snapshot, + string $fingerprint, +): EnvironmentReview { + $publishedRun = OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinutes(5), + 'context' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'review_fingerprint' => $fingerprint, + ], + ]); + + return EnvironmentReview::factory()->ready()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'operation_run_id' => (int) $publishedRun->getKey(), + 'fingerprint' => $fingerprint, + 'summary' => [ + 'finding_count' => 4, + 'report_count' => 2, + 'operation_count' => 1, + ], + ]); +} + +function spec365BrowserCreateStaleRestoreRun(ManagedEnvironment $environment, User $user): OperationRun +{ + $backupSet = BackupSet::factory()->for($environment)->create(['status' => 'completed']); + $run = OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'restore.execute', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + ]); + + $restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory() + ->for($environment, 'tenant') + ->for($backupSet) + ->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'operation_run_id' => (int) $run->getKey(), + 'status' => RestoreRunStatus::Completed->value, + 'is_dry_run' => false, + ])); + + $run->forceFill([ + 'context' => [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + ], + ])->save(); + + return $run->fresh(); +} + +function spec365BrowserCreateEvidenceRun(ManagedEnvironment $environment, User $user): OperationRun +{ + $run = OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinutes(3), + ]); + + EvidenceSnapshot::query()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => hash('sha256', 'spec365-browser-evidence-'.$run->getKey()), + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['source' => 'spec365-browser'], + 'generated_at' => now()->subMinutes(3), + ]); + + return $run->fresh(); +} + +function spec365BrowserCreateReviewPackRun(ManagedEnvironment $environment, User $user): OperationRun +{ + $run = OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinutes(4), + ]); + + ReviewPack::factory()->ready()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + ]); + + return $run->fresh(); +} + +function spec365BrowserCreatePartialInventoryRun(ManagedEnvironment $environment, User $user): OperationRun +{ + return OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory.sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + 'completed_at' => now()->subMinutes(5), + 'summary_counts' => [ + 'total' => 4, + 'processed' => 4, + 'succeeded' => 2, + 'failed' => 1, + 'skipped' => 1, + ], + ]); +} + +function spec365BrowserCreateBlockedBackupRun(ManagedEnvironment $environment, User $user): OperationRun +{ + $backupSet = BackupSet::factory()->for($environment)->create(['status' => 'completed']); + + return OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'backup.schedule.execute', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Blocked->value, + 'completed_at' => now()->subMinutes(6), + 'context' => [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'reason_code' => 'missing_provider_permission', + ], + ]); +} diff --git a/apps/platform/tests/Feature/Monitoring/OperationsDbOnlyTest.php b/apps/platform/tests/Feature/Monitoring/OperationsDbOnlyTest.php index 31f4f0de..c96015cb 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationsDbOnlyTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationsDbOnlyTest.php @@ -28,7 +28,7 @@ ->assertOk() ->assertSee('Operations Hub') ->assertSee('Which operation needs attention now?') - ->assertSee('Operations history') + ->assertSee('Recent runs') ->assertSee('All') ->assertSee('Active') ->assertSee('Likely stale') diff --git a/apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php b/apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php index c9fce617..ebb2fc92 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use App\Models\OperationRun; use App\Models\ManagedEnvironment; +use App\Models\OperationRun; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\OperationRunLinks; use App\Support\Workspaces\WorkspaceContext; @@ -29,10 +29,10 @@ ->get(route('admin.operations.index', ['workspace' => $tenant->workspace])) ->assertOk() ->assertSee('Operations Hub') - ->assertSee('Execution follow-up workbench') + ->assertSee('Execution follow-up') ->assertSee('Which operation needs attention now?') - ->assertSee('Operations history') - ->assertSee('Open operation'); + ->assertSee('Recent runs') + ->assertSee('Next action'); }); it('surfaces canonical return context separately from the operations work lane', function (): void { @@ -57,5 +57,5 @@ ->assertSee('Which operation needs attention now?') ->assertSee('Back to backup set') ->assertSee('/admin/tenant/backup-sets/1', false) - ->assertSee('Operations history'); + ->assertSee('Recent runs'); }); diff --git a/apps/platform/tests/Feature/Monitoring/OperationsHubProductizationTest.php b/apps/platform/tests/Feature/Monitoring/OperationsHubProductizationTest.php index 9414155a..7b6b0c06 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationsHubProductizationTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationsHubProductizationTest.php @@ -71,7 +71,9 @@ ->get(OperationRunLinks::index(workspace: $environment->workspace)) ->assertOk() ->assertSee('Operations Hub') - ->assertSee('Execution follow-up workbench') + ->assertSee('Execution follow-up') + ->assertSee('data-testid="operations-hub-scope-body"', false) + ->assertSee('Showing workspace-wide execution records across entitled environments.') ->assertSee('Which operation needs attention now?') ->assertSee('Decision workbench') ->assertSee('Needs attention') @@ -97,13 +99,15 @@ ->assertSee('Reason') ->assertSee('Impact') ->assertSee('Environment: Spec328 Environment Alpha') + ->assertSee('class="fi-badge fi-size-sm"', false) + ->assertSee('fi-badge-label', false) ->assertSee('Proof') ->assertSee('Operation detail available') ->assertSee('Next action') - ->assertSee('Open operation') + ->assertSee('Next action') ->assertSee('Operation summary') - ->assertSee('Operations history') - ->assertSee('Secondary context for scanning OperationRun history') + ->assertSee('Recent runs') + ->assertSee('Chronological operation record') ->assertSee('Diagnostics') ->assertSee('Collapsed') ->assertDontSee('raw payload should stay hidden') @@ -116,6 +120,8 @@ ->assertDontSee('sparkline') ->assertDontSee('trend') ->assertDontSee('wire:poll', false) + ->assertDontSee('inline-flex items-center rounded-lg bg-gray-100', false) + ->assertDontSee('inline-flex w-fit items-center rounded-lg px-2.5 py-1', false) ->assertDontSee('tenant filter') ->assertDontSee('current tenant') ->assertDontSee('entitled tenant') @@ -187,8 +193,8 @@ ->get(OperationRunLinks::index(workspace: $environment->workspace)) ->assertOk() ->assertSee('Which operation needs attention now?') - ->assertSee('No operations need attention') - ->assertSee('This is execution follow-up only, not an environment health claim.') + ->assertSee('No operations need follow-up') + ->assertSee('No failed, blocked, partial, or stale OperationRuns are visible in this scope.') ->assertSee('Operation detail available') ->assertDontSee('environment is healthy') ->assertDontSee('governance health is complete') diff --git a/apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php b/apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php new file mode 100644 index 00000000..6760037e --- /dev/null +++ b/apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php @@ -0,0 +1,244 @@ +forSnapshot($tenant, $snapshot); + + $run = spec365StaleReviewComposeRun($tenant, $user, $fingerprint, (int) $snapshot->getKey()); + $review = spec365ReadyReviewTruth($tenant, $user, $snapshot, $fingerprint); + + Filament::setTenant(null, true); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertActionVisible('reconcileOperationRun') + ->callAction('reconcileOperationRun') + ->assertStatus(200); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) + ->and($run->reconciliationAdapter())->toBe('environment_review_compose') + ->and($run->reconciledRelatedReviewId())->toBe((int) $review->getKey()); + + $postDecision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect(data_get($postDecision, 'primary_action.key'))->toBe('view_review') + ->and($postDecision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.already_reconciled')); + + $audit = AuditLog::query() + ->where('action', 'operation.reconciled_by_operator') + ->where('operation_run_id', (int) $run->getKey()) + ->first(); + + expect($audit)->not->toBeNull(); + + $metadata = is_array($audit?->metadata) ? $audit->metadata : []; + $encodedMetadata = json_encode($metadata, JSON_THROW_ON_ERROR); + + expect($metadata)->toMatchArray([ + 'operator_action' => 'reconcile', + 'operation_run_id' => (int) $run->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'actor_user_id' => (int) $user->getKey(), + 'operation_type' => 'environment.review.compose', + 'previous_status' => OperationRunStatus::Queued->value, + 'previous_outcome' => OperationRunOutcome::Pending->value, + 'resulting_status' => OperationRunStatus::Completed->value, + 'resulting_outcome' => OperationRunOutcome::Succeeded->value, + 'mutation_scope' => 'tenantpilot_operation_metadata_only', + ])->and($encodedMetadata)->not->toContain('access_token', 'client_secret', 'refresh_token'); +}); + +it('denies direct reconcile attempts for review viewers without manage capability and records a denied audit in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly'); + $snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0); + $fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot); + + $run = spec365StaleReviewComposeRun($tenant, $user, $fingerprint, (int) $snapshot->getKey()); + spec365ReadyReviewTruth($tenant, $user, $snapshot, $fingerprint); + + Filament::setTenant(null, true); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertDontSee(__('localization.operations.actions.reconcile')); + + try { + app(OperationRunOperatorActionService::class)->reconcile($run, $user); + $this->fail('Readonly users should not be able to reconcile review-compose operation runs.'); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(403); + } + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value) + ->and($run->isLifecycleReconciled())->toBeFalse() + ->and(AuditLog::query() + ->where('action', 'operation.reconcile_denied') + ->where('operation_run_id', (int) $run->getKey()) + ->exists())->toBeTrue(); +}); + +it('denies unsupported reconcile attempts without mutating run state in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'unknown.operation', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + ]); + + $decision = app(OperationRunActionEligibility::class)->forRun($run, $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_details') + ->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.unsupported_reconcile')); + + try { + app(OperationRunOperatorActionService::class)->reconcile($run, $user); + $this->fail('Unsupported operation runs should not reconcile.'); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(403); + } + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value) + ->and($run->isLifecycleReconciled())->toBeFalse(); +}); + +it('returns not found for direct reconcile attempts outside workspace scope in Spec365', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + [$outsider] = createUserWithTenant(role: 'owner'); + $snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0); + $fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot); + + $run = spec365StaleReviewComposeRun($tenant, $owner, $fingerprint, (int) $snapshot->getKey()); + + try { + app(OperationRunOperatorActionService::class)->reconcile($run, $outsider); + $this->fail('Cross-workspace users should not be able to reconcile operation runs.'); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(404); + } + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value); +}); + +it('returns not found for direct reconcile attempts outside managed environment scope in Spec365', function (): void { + [$user, $allowedTenant] = createUserWithTenant(role: 'owner'); + $deniedTenant = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $allowedTenant->workspace_id, + ]); + + expect(ManagedEnvironmentMembership::query() + ->where('user_id', (int) $user->getKey()) + ->where('managed_environment_id', (int) $allowedTenant->getKey()) + ->exists())->toBeTrue(); + + $snapshot = seedEnvironmentReviewEvidence($deniedTenant, operationRunCount: 0); + $fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($deniedTenant, $snapshot); + + $run = spec365StaleReviewComposeRun($deniedTenant, $user, $fingerprint, (int) $snapshot->getKey()); + spec365ReadyReviewTruth($deniedTenant, $user, $snapshot, $fingerprint); + + $decision = app(OperationRunActionEligibility::class)->forRun($run, $user); + + expect($decision['primary_action'])->toBeNull() + ->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.scope_unavailable')); + + try { + app(OperationRunOperatorActionService::class)->reconcile($run, $user); + $this->fail('Cross-environment users should not be able to reconcile operation runs.'); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(404); + } + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value); +}); + +function spec365StaleReviewComposeRun(ManagedEnvironment $tenant, User $user, string $fingerprint, int $snapshotId): OperationRun +{ + return OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + 'context' => [ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'evidence_snapshot_id' => $snapshotId, + 'review_fingerprint' => $fingerprint, + ], + ]); +} + +function spec365ReadyReviewTruth(ManagedEnvironment $tenant, User $user, EvidenceSnapshot $snapshot, string $fingerprint): EnvironmentReview +{ + $publishedRun = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinutes(5), + 'context' => [ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'review_fingerprint' => $fingerprint, + ], + ]); + + return EnvironmentReview::factory()->ready()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'operation_run_id' => (int) $publishedRun->getKey(), + 'fingerprint' => $fingerprint, + 'summary' => [ + 'finding_count' => 4, + 'report_count' => 2, + 'operation_count' => 1, + ], + ]); +} diff --git a/apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php b/apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php new file mode 100644 index 00000000..4ff27182 --- /dev/null +++ b/apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php @@ -0,0 +1,268 @@ +forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinute(), + ]); + + $decision = spec365OperationRunDecision($run, $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_details') + ->and($decision['freshness_state'])->toBe('fresh_active') + ->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.lifecycle_fresh')) + ->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred')); +}); + +it('fails closed for stale supported review-compose runs without canonical proof in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0); + $fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + 'context' => [ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'review_fingerprint' => $fingerprint, + ], + ]); + + $decision = spec365OperationRunDecision($run, $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_details') + ->and(spec365OperationRunActionKeys($decision['secondary_actions']))->not->toContain('reconcile') + ->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.insufficient_proof')) + ->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred')); +}); + +it('offers a confirmed reconcile action for stale supported review-compose runs when canonical proof exists in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0); + $fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + 'context' => [ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'review_fingerprint' => $fingerprint, + ], + ]); + + spec365UnitReadyReviewTruth($tenant, $user, $fingerprint, (int) $snapshot->getKey()); + + $decision = spec365OperationRunDecision($run, $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('reconcile') + ->and(data_get($decision, 'primary_action.requires_confirmation'))->toBeTrue() + ->and($decision['mutation_scope'])->toBe(__('localization.operations.actions.mutation_scope_reconcile')) + ->and($decision['attention_reason'])->toBe(__('localization.operations.actions.attention.reconcile_available')) + ->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred')); +}); + +it('offers reconcile for stale running supported review-compose runs in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0); + $fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'started_at' => now()->subMinutes(20), + 'created_at' => now()->subMinutes(20), + 'context' => [ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'review_fingerprint' => $fingerprint, + ], + ]); + + spec365UnitReadyReviewTruth($tenant, $user, $fingerprint, (int) $snapshot->getKey()); + + $decision = spec365OperationRunDecision($run, $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('reconcile') + ->and($decision['freshness_state'])->toBe('likely_stale'); +}); + +it('fails closed for unsupported operation types in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'unknown.operation', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + ]); + + $decision = spec365OperationRunDecision($run, $user); + + expect($decision['high_risk'])->toBeTrue() + ->and(data_get($decision, 'primary_action.key'))->toBe('view_details') + ->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.unsupported_reconcile')) + ->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry')); +}); + +it('fails closed for review viewers who lack the execution capability in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + ]); + + $decision = spec365OperationRunDecision($run, $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_details') + ->and(spec365OperationRunActionKeys($decision['secondary_actions']))->not->toContain('reconcile') + ->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.missing_capability')); +}); + +it('returns no enabled actions outside the actor workspace scope in Spec365', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + $outsider = User::factory()->create(); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $owner->getKey(), + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + ]); + + $decision = spec365OperationRunDecision($run, $outsider); + + expect($decision['primary_action'])->toBeNull() + ->and($decision['disabled_reasons']['view_details'])->toBe(__('localization.operations.actions.disabled.scope_unavailable')) + ->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.scope_unavailable')) + ->and($decision['attention_reason'])->toBe(__('localization.operations.actions.attention.scope_unavailable')); +}); + +it('keeps high-risk restore runs off primary mutation actions and forbids success-forcing controls in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'restore.execute', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + ]); + + $decision = spec365OperationRunDecision($run, $user); + $enabledKeys = array_merge( + [data_get($decision, 'primary_action.key')], + spec365OperationRunActionKeys($decision['secondary_actions']), + ); + + expect($decision['high_risk'])->toBeTrue() + ->and(data_get($decision, 'primary_action.key'))->toBe('view_restore_details') + ->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry')) + ->and($enabledKeys)->not->toContain( + 'retry_restore', + 'restore_reexecute', + 'force_complete', + 'mark_succeeded', + 'delete_run', + 'purge_run', + ); + + foreach ([ + 'retry_restore', + 'restore_reexecute', + 'force_complete', + 'mark_succeeded', + 'delete_run', + 'purge_run', + ] as $action) { + expect($decision['disabled_reasons'])->not->toHaveKey($action) + ->and(spec365OperationRunActionKeys($decision['disabled_actions']))->not->toContain($action); + } +}); + +function spec365UnitReadyReviewTruth( + ManagedEnvironment $tenant, + User $user, + string $fingerprint, + int $snapshotId, +): EnvironmentReview { + $publishedRun = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinutes(5), + 'context' => [ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'review_fingerprint' => $fingerprint, + ], + ]); + + return EnvironmentReview::factory()->ready()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'evidence_snapshot_id' => $snapshotId, + 'initiated_by_user_id' => (int) $user->getKey(), + 'operation_run_id' => (int) $publishedRun->getKey(), + 'fingerprint' => $fingerprint, + ]); +} + +/** + * @return array + */ +function spec365OperationRunDecision(OperationRun $run, ?User $user): array +{ + return app(OperationRunActionEligibility::class)->forRun($run, $user); +} + +/** + * @param list> $actions + * @return list + */ +function spec365OperationRunActionKeys(array $actions): array +{ + return array_values(array_filter(array_map( + static fn (array $action): ?string => is_string($action['key'] ?? null) ? (string) $action['key'] : null, + $actions, + ))); +} diff --git a/apps/platform/tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php b/apps/platform/tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php new file mode 100644 index 00000000..487a2eaa --- /dev/null +++ b/apps/platform/tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php @@ -0,0 +1,218 @@ +forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'environment.review.compose', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + ]); + + EnvironmentReview::factory()->ready()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'operation_run_id' => (int) $run->getKey(), + ]); + + $decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_review') + ->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_review')) + ->and(data_get($decision, 'primary_action.requires_confirmation'))->toBeFalse(); +}); + +it('prefers the related evidence snapshot as the primary safe action in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + ]); + + EvidenceSnapshot::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => hash('sha256', 'spec365-evidence-'.$run->getKey()), + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['source' => 'spec365'], + 'generated_at' => now(), + ]); + + $decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_evidence') + ->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_evidence')); +}); + +it('prefers the related review pack as the primary safe action in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + ]); + + ReviewPack::factory()->ready()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'operation_run_id' => (int) $run->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + ]); + + $decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_report') + ->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_report')); +}); + +it('maps partial inventory sync runs to affected-family drilldown in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'inventory.sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + ]); + + $decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_affected_families') + ->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_affected_families')); +}); + +it('maps blocked backup executions with backup truth to backup details in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $backupSet = BackupSet::factory()->for($tenant)->create(['status' => 'completed']); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'backup.schedule.execute', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Blocked->value, + 'context' => [ + 'backup_set_id' => (int) $backupSet->getKey(), + ], + ]); + + $decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect(data_get($decision, 'primary_action.key'))->toBe('view_backup_details') + ->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_backup_details')); +}); + +it('prefers restore details over mutation actions for high-risk restore runs in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->for($tenant)->create(['status' => 'completed']); + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'restore.execute', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(10), + ]); + + $restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory() + ->for($tenant, 'tenant') + ->for($backupSet) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $run->getKey(), + 'status' => RestoreRunStatus::Completed->value, + 'is_dry_run' => false, + ])); + + $run->forceFill([ + 'context' => [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + ], + ])->save(); + + $decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect($decision['high_risk'])->toBeTrue() + ->and(data_get($decision, 'primary_action.key'))->toBe('view_restore_details') + ->and(data_get($decision, 'primary_action.color'))->toBe('warning') + ->and(data_get($decision, 'primary_action.requires_confirmation'))->toBeFalse() + ->and(spec365OperationRunPrimaryActionKeys($decision['secondary_actions']))->toContain('reconcile'); +}); + +it('keeps failed restore runs on restore details without unsafe high-risk actions in Spec365', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->for($tenant)->create(['status' => 'completed']); + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'user_id' => (int) $user->getKey(), + 'type' => 'restore.execute', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + ]); + + $restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory() + ->failedOutcome() + ->for($tenant, 'tenant') + ->for($backupSet) + ->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $run->getKey(), + 'status' => RestoreRunStatus::Failed->value, + ])); + + $run->forceFill([ + 'context' => [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + ], + ])->save(); + + $decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user); + + expect($decision['high_risk'])->toBeTrue() + ->and(data_get($decision, 'primary_action.key'))->toBe('view_restore_details') + ->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry')) + ->and(spec365OperationRunPrimaryActionKeys($decision['secondary_actions']))->not->toContain('reconcile'); +}); + +/** + * @param list> $actions + * @return list + */ +function spec365OperationRunPrimaryActionKeys(array $actions): array +{ + return array_values(array_filter(array_map( + static fn (array $action): ?string => is_string($action['key'] ?? null) ? (string) $action['key'] : null, + $actions, + ))); +} diff --git a/docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md b/docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md index 43f16f57..68162d20 100644 --- a/docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md +++ b/docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md @@ -13,36 +13,36 @@ # UI-003 Operations ## First Five Seconds -The page reads as an operations monitor. It communicates execution truth, but it still needs a sharper split between active work, terminal follow-up, and diagnostic history. +The page reads as an operations monitor with decision-first follow-up. It exposes run scope, lifecycle/outcome truth, and one safe next action before operators move into diagnostics. The history table now compresses secondary fields below wide desktop widths so the core decision columns remain visible. ## Productization Review -- Decision-first: medium; monitoring tends to be chronological. +- Decision-first: high; run rows and detail summaries surface a resolver-owned safe next action and concise attention context. - Evidence-first: OperationRun records are the source. - Context: workspace route is explicit. - Customer/auditor safety: not customer-facing by default. -- Diagnostics: appropriate as the primary mode here, but should not imply governance health. +- Diagnostics: secondary; raw/support context remains gated behind detail/diagnostic affordances. ## Information Inventory -The page exposes run state, status, recent work, and likely links to run detail. Execution outcome is visible; governance result and artifact truth remain separate surfaces. +The page exposes run state, status, scope, recent work, attention reason, and safe next action. Execution outcome is visible; governance result and artifact truth remain separate surfaces and related artifacts are opened through canonical OperationRun links. In the history table, operation context absorbs status/scope/timing on constrained widths while secondary columns remain available on wider screens. ## Dangerous Actions -Potential actions include retry, cancel, investigate, or open related artifacts. Target design should keep terminal actions confirmation-gated and secondary to run inspection. +Potential actions are intentionally constrained to inspect/open related artifacts or a confirmation-gated Reconcile action. Retry remains unavailable unless a repo-verified safe seam exists, and high-risk restore/promotion states must not expose retry, re-execute, force-complete, mark-succeeded, delete, or purge controls. ## Scores | IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf | | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 3 | 4 | 4 | 3 | 4 | 3 | 4 | 3 | 3 | 4 | 3 | 4 | +| 3 | 4 | 4 | 3 | 4 | 4 | 4 | 3 | 4 | 4 | 4 | 4 | ## Top Issues 1. Execution truth must stay visually separate from product health. -2. Detail drilldowns need consistent evidence/result links. -3. Responsive/table behavior was not captured. +2. Detail drilldowns must keep evidence/result links canonical and scope-safe. +3. Wider-screen target composition should still evolve toward a true attention queue plus proof rail, but the current table no longer requires horizontal scanning for the core decision on constrained desktop widths. ## Target Direction -P1 strategic target. Use one monitoring pattern for active, failed, partial, and completed runs, with evidence/result links delegated to shared OperationRun UX contracts. +P1 strategic target. Use one monitoring pattern for active, failed, partial, and completed runs, with safe next actions and evidence/result links delegated to shared OperationRun UX contracts. diff --git a/specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-action-eligibility-matrix.md b/specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-action-eligibility-matrix.md new file mode 100644 index 00000000..e7b98b94 --- /dev/null +++ b/specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-action-eligibility-matrix.md @@ -0,0 +1,71 @@ +# Spec 365 Action Eligibility Matrix + +This matrix is the product and test contract for `OperationRunActionEligibility`. It is derived from existing OperationRun truth and does not introduce new persisted status/outcome values. + +## Global Rules + +- At most one primary action is visible per run. +- If eligibility is uncertain, the action is unavailable. +- Direct action execution must enforce the same authorization/scope rules as UI visibility. +- Reconcile writes through `AdapterRunReconciler` and `OperationRunService`. +- Retry is unavailable unless a repo-verified safe non-high-risk retry/start seam exists. +- Restore, tenant mutation, destructive mutation, unknown operation, and high-risk operation are never retryable in this spec. +- Force Complete, Mark Succeeded, Delete, Purge, and Restore Re-execute are always forbidden. +- Related actions use canonical metadata and existing link/policy seams. +- Diagnostics are secondary and capability-gated. + +## Matrix + +| Family | Canonical example | Run state | Primary action | Reconcile | Retry | Related | Diagnostics | Disabled / attention reason | Required tests | +|---|---|---|---|---|---|---|---|---|---| +| Queue | any supported operation | fresh queued | View details | no | no | maybe | yes if capability | Operation is still within expected lifecycle window | unit, browser | +| Queue | any supported operation | stale queued | Reconcile when adapter/proof exists, otherwise View details | maybe | no by default | maybe | yes if capability | Waiting longer than expected; reconciliation may be safe only with adapter proof | unit, feature | +| Queue | any supported operation | stale running | Reconcile when adapter/proof exists, otherwise View details | maybe | no by default | maybe | yes if capability | Running longer than expected; fail closed without proof | unit, feature | +| Review compose | `environment.review.compose` | related review already available / reconciled | View review | no after reconciled | only if failed and safe seam verified | yes | yes if capability | Review result already exists | unit, feature, browser | +| Review compose | `environment.review.compose` | stale eligible with adapter proof | Reconcile | yes | no by default | maybe after reconcile | yes if capability | Existing review proof can reconcile this run | unit, feature | +| Review pack / report | `environment.review_pack.generate` | artifact already available / reconciled | View report | no after reconciled | only if safe seam verified | yes | yes if capability | Report artifact already exists | unit, feature, browser | +| Evidence | `tenant.evidence.snapshot.generate` | evidence snapshot already available / reconciled | View evidence | no after reconciled | only if safe seam verified | yes | yes if capability | Evidence snapshot already exists | unit, feature, browser | +| Sync | `inventory.sync` / `policy.sync` | partial | View affected families | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified | maybe | yes if capability | Some resource families completed; others blocked or failed | unit, feature, browser | +| Sync | `inventory.sync` / `policy.sync` | blocked | View missing permissions/details | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified | maybe | yes if capability | Provider access or precondition blocked capture | unit, feature | +| Backup | `backup.schedule.execute` | partial | View backup details | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified and non-destructive | yes if backup set exists | yes if capability | Backup completed with partial results | unit, feature | +| Backup | `backup.schedule.execute` | blocked | View missing permissions/details | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified and non-destructive | maybe | yes if capability | Backup blocked by access or precondition | unit, feature, browser | +| Restore | `restore.execute` | verification required | View restore details | maybe only if Spec364 verification proof is sufficient | no | yes | yes if capability | High-risk operation requires verification; retry unavailable | unit, feature, browser | +| Restore | `restore.execute` | partial | View restore details | maybe only if Spec364 proof is sufficient | no | yes | yes if capability | Restore completed only partially; retry unavailable | unit, feature | +| Restore | `restore.execute` | blocked | View restore details | no unless Spec364 proof allows safe blocked reconciliation | no | yes | yes if capability | Restore blocked; high-risk retry unavailable | unit, feature, browser | +| Restore | `restore.execute` | failed | View restore details | no unless Spec364 proof allows safe terminal reconciliation | no | maybe | yes if capability | Restore failed; retry/re-execute/force-success unavailable | unit, feature, browser | +| High-risk mutation | `promotion.execute` / tenant mutation | failed/blocked/unknown | View details | no unless explicit adapter proof exists | no | maybe | yes if capability | High-risk operation cannot be retried from this view | unit | +| Unknown | unmapped operation type | any terminal/active state | View details | no | no | no unless existing link resolves | yes if capability | Unsupported operation type | unit, feature | +| RBAC denied | any | otherwise eligible | none or disabled safe label | no direct execution | no direct execution | no direct execution | no if missing capability | User lacks required capability | feature, browser | +| Cross-scope denied | any | otherwise eligible | none | no direct execution | no direct execution | no direct execution | no | Operation is outside permitted workspace/environment | feature | + +## Forbidden Action Assertions + +Tests must assert these labels/actions do not exist for restore/high-risk runs: + +- Retry restore +- Re-execute restore +- Force complete +- Mark succeeded +- Ignore error and complete +- Manually mark successful +- Delete run +- Purge run + +## Retry Close-Out Template + +Implementation must update this section before completion: + +| Operation family | Safe retry seam found? | Implemented? | Disabled/deferred reason | +|---|---|---|---| +| Review compose | no generic retry seam verified; reconcile seam exists | no retry | Retry is deferred; stale runs use Reconcile only when adapter proof and RBAC allow it | +| Review pack/report | no generic retry seam verified | no retry | Retry is deferred; related artifact links are safe when canonical metadata resolves | +| Evidence snapshot | no generic retry seam verified | no retry | Retry is deferred; related evidence links are safe when canonical metadata resolves | +| Sync/capture | no generic retry seam verified | no retry | Retry is deferred; partial/blocked runs open affected-family/details surfaces | +| Backup capture | no generic retry seam verified | no retry | Retry is deferred; backup details are safe when backup truth resolves | +| Restore | no by spec | no | High-risk operations cannot be retried from this view | + +## Acknowledge Close-Out Template + +| Seam checked | Existing clean seam? | Implemented? | Deferral reason | +|---|---|---|---| +| OperationRun acknowledge/note/audit | no clean OperationRun-specific acknowledge/note seam verified | no | Acknowledge would create a local success-like state without existing domain truth; defer to a future explicit workflow spec | diff --git a/specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-regression-gate-matrix.md b/specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-regression-gate-matrix.md new file mode 100644 index 00000000..15fb7854 --- /dev/null +++ b/specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-regression-gate-matrix.md @@ -0,0 +1,125 @@ +# Spec 365 Regression Gate Matrix + +This matrix is the final Operations UI/operator action gate for the OperationRun/Reconciliation program. It links representative states from Specs 358-364 to the Spec 365 UI/action expectations and test families. + +## Matrix + +| Family | State | Required visible decision truth | Required absent/default-hidden truth | Primary action expectation | Test family | +|---|---|---|---|---|---| +| Queue | fresh queued | Queued/running lifecycle is still fresh; status/outcome/freshness/scope visible | Retry, Reconcile, raw JSON, worker blame when not stale | View details or no mutation action | Unit, Browser | +| Queue | stale queued | Longer-than-expected attention state; reason and scope visible | "Waiting for worker" as certain claim, raw queue payload | Reconcile only if adapter/proof exists, else View details | Unit, Feature, Browser | +| Queue | stale running | Stale running attention state; lifecycle reconciliation guidance | stale plus fresh contradictory copy | Reconcile only if adapter/proof exists, else View details | Unit, Feature | +| Reconciliation | review already available | Review already available; reconciled from adapter; related review proof | SQLSTATE, duplicate key, raw fingerprint/constraint | View review | Unit, Feature, Browser | +| Reconciliation | report/review-pack already available | Report/review-pack artifact available; related artifact proof | raw report payload, signed URL in context | View report | Unit, Feature, Browser | +| Reconciliation | evidence snapshot already available | Evidence snapshot available; related evidence proof | raw Graph payload, signed URL in context | View evidence | Unit, Feature, Browser | +| Sync | partial | Completed with partial results; affected families or blocked family count | provider trace, raw job payload | View affected families/details | Unit, Feature, Browser | +| Sync | blocked | Blocked reason and missing permission/precondition summary | access token, client secret, full provider trace | View missing permissions/details | Unit, Feature | +| Backup | partial | Backup completed with partial results; safe scope | raw backup payload by default | View backup details | Unit, Feature | +| Backup | blocked | Backup blocked; missing permission/precondition summary | raw provider exception by default | View missing permissions/details | Unit, Feature, Browser | +| Restore | verification required | Restore verification required; provider accepted but target not verified | Retry restore, Re-execute restore, Force complete, Mark succeeded | View restore details | Unit, Feature, Browser | +| Restore | partial | Restore partial; verified/unverified step summary | Force success copy, raw provider payload default | View restore details | Unit, Feature | +| Restore | blocked | Restore blocked; approval/provider/access reason | Retry restore, force-success, stack trace | View restore details | Unit, Feature, Browser | +| Restore | failed | Restore failed with high-risk guard | Retry restore, Re-execute restore, Force complete, Mark succeeded | View restore details | Unit, Feature, Browser | +| RBAC | action denied | Action unavailable or disabled reason for missing capability | Direct action success, hidden capability names as primary copy | No action execution | Feature, Browser | +| Scope | cross-workspace denied | No cross-scope hint for inaccessible run/action | Related cross-workspace object link | No action execution | Feature | +| Raw leakage | customer-readable default surface | Calm summary, safe reason, diagnostics secondary | SQLSTATE, Guzzle, stack trace, access token, client secret, serialized job, internal constraint names | N/A | Browser | + +## Implemented Test Files + +Direct Spec 365 tests: + +```text +apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php +apps/platform/tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php +apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php +apps/platform/tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php +``` + +High-risk guard, RBAC, audit, related-link, retry-unavailable, idempotency, and raw-leakage assertions are consolidated into the files above plus existing OperationRun presentation regressions listed below. + +Existing regression files exercised during implementation: + +```text +apps/platform/tests/Feature/Operations/Spec359OperationRunAdapterReconciliationTest.php +apps/platform/tests/Feature/EnvironmentReview/Spec359ReviewComposeReconciliationTest.php +apps/platform/tests/Feature/Operations/Spec364RestoreExecuteReconciliationTest.php +apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php +apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php +apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php +apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php +apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php +apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php +apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php +apps/platform/tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php +``` + +## Regression Commands + +Direct Spec 365: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec365 +``` + +OperationRun program regressions: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec358 +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec359 +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec360 +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec361 +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec362 +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec363 +cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec364 +``` + +Review/report/customer regressions: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/CustomerReviewWorkspaceSmokeTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec357ReportProfilesSmokeTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewExecutivePackTest.php +``` + +Operations/provider regressions: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections +``` + +Known external failures to check separately, not silently bundle: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReviewHeaderDisciplineTest.php +``` + +Quality: + +```bash +cd apps/platform && ./vendor/bin/pint --dirty +git diff --check +``` + +Optional direct browser gate: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php +``` + +## Close-Out Template + +Implementation must update this matrix or `tasks.md` with: + +- Spec365 direct test result: 20 PHP tests / 118 assertions and 3 browser tests / 42 assertions passed locally after implementation. +- Spec358-364 regression result: targeted Spec359, Spec360 browser, Spec364, OperationRun viewer/link, monitoring, and resource presentation regressions passed locally; final filter sweep is recorded in `tasks.md`. +- Browser smoke result: `php artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php` passed. +- Known external failures and whether they predate Spec365: none observed in the executed targeted lanes. +- Implemented/deferred retry families: retry deferred for all families; Reconcile implemented only through adapter proof and existing OperationRun service writes. +- Acknowledge implemented/deferred decision: deferred because no clean OperationRun acknowledge seam exists. +- Raw leakage guard result: browser smoke asserts absence of `SQLSTATE`, `Guzzle`, `stack trace`, `access token`, `client secret`, `serialized job`, and the review fingerprint unique-index name on default surfaces. diff --git a/specs/365-operations-ui-operator-actions-regression-gate/checklists/requirements.md b/specs/365-operations-ui-operator-actions-regression-gate/checklists/requirements.md new file mode 100644 index 00000000..19de064f --- /dev/null +++ b/specs/365-operations-ui-operator-actions-regression-gate/checklists/requirements.md @@ -0,0 +1,71 @@ +# Requirements Quality Checklist: Spec 365 + +**Feature**: Operations UI Operator Actions & Regression Gate +**Created**: 2026-06-07 +**Purpose**: Validate that Spec 365 prep artifacts are clear, bounded, testable, and aligned with repository truth before implementation. + +## Content Quality + +- [x] No implementation details leak into the product problem statement beyond repo-seam constraints needed for safety. +- [x] User value and operator/customer impact are explicit. +- [x] Scope is bounded to existing Operations surfaces and existing OperationRun/Reconciliation truth. +- [x] Out-of-scope items explicitly exclude new adapters, generic retry framework, restore retry, force success, delete/purge, and new top-level pages. +- [x] Acceptance criteria are measurable. +- [x] Localization and raw leakage expectations are stated. +- [x] Filament v5 / Livewire v4 constraints are stated. + +## Requirement Completeness + +- [x] Action eligibility resolver inputs and outputs are specified. +- [x] Reconcile action safety path is specified through existing registry/reconciler/service seams. +- [x] Retry is bounded to repo-verified safe non-high-risk seams and is unavailable otherwise. +- [x] High-risk restore/destructive guard is explicit. +- [x] Related domain object actions use canonical metadata and existing link/policy seams. +- [x] RBAC/capability and workspace/environment scope requirements are explicit. +- [x] Audit/action metadata required fields are explicit. +- [x] Outcome summary families and regression matrix states are explicit. +- [x] No raw technical leakage criteria are explicit. +- [x] No new top-level navigation/page sprawl criteria are explicit. +- [x] Mutation-scope disclosure is explicit for state-changing operator actions. + +## Constitution / Guardrail Alignment + +- [x] UI Surface Impact is classified. +- [x] UI/Productization coverage decision is recorded. +- [x] Cross-cutting shared pattern reuse is described. +- [x] OperationRun UX Impact is filled. +- [x] Provider/platform boundary impact is bounded. +- [x] Operator surface contract, decision-first role, and audience-aware disclosure are filled. +- [x] Proportionality review is complete for the new resolver abstraction. +- [x] Testing/lane/runtime impact is complete. +- [x] Filament output contract is included. + +## Ambiguity and Deferrals + +- [x] Generic retry ambiguity is resolved by fail-closed repo-verification rule. +- [x] Acknowledge ambiguity is resolved by optional/defer-if-no-clean-seam rule. +- [x] Capability ambiguity is resolved by "existing capability registry first, add minimal constants only if needed". +- [x] Report vs review-pack ambiguity is bounded to existing safe related metadata/link seams. +- [x] UI audit doc update ambiguity is assigned to implementation close-out. +- [x] Spec363 is included in the final Spec358-364 regression command set. +- [x] Denied action audit/log behavior is covered by implementation tasks. + +## Manual Prep Analysis Result + +- **Result**: PASS after prep adjustments. +- **Critical issues**: none. +- **Non-critical implementation watch items**: + - Verify retry seams before exposing retry. + - Verify whether acknowledge has a clean existing seam before implementing it. + - Keep new resolver derived and non-persisted. + - Ensure direct action tests cover denial, not only hidden UI. + - Update or explicitly close out UI audit coverage artifacts. + +## Readiness + +- [x] `spec.md` is ready for implementation planning. +- [x] `plan.md` defines implementation path and stop conditions. +- [x] `tasks.md` is dependency ordered and test-oriented. +- [x] Action eligibility matrix exists. +- [x] Regression gate matrix exists. +- [x] No application code was implemented during prep. diff --git a/specs/365-operations-ui-operator-actions-regression-gate/plan.md b/specs/365-operations-ui-operator-actions-regression-gate/plan.md new file mode 100644 index 00000000..7bd11f76 --- /dev/null +++ b/specs/365-operations-ui-operator-actions-regression-gate/plan.md @@ -0,0 +1,326 @@ +# Implementation Plan: Operations UI Operator Actions & Regression Gate + +**Branch**: `365-operations-ui-operator-actions-regression-gate` | **Date**: 2026-06-07 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/365-operations-ui-operator-actions-regression-gate/spec.md` + +**Note**: This plan is a preparation artifact only. It defines the implementation path and validation gate; it does not implement application code. + +## Summary + +Spec 365 completes the OperationRun/Reconciliation program by making existing run truth operator-actionable in the existing Operations hub and OperationRun detail. The implementation approach is to add one central derived action eligibility resolver, reuse existing reconciliation and related-link seams, integrate safe actions into existing Filament surfaces, and add a final regression matrix across Specs 358-364. + +The plan intentionally does not introduce a new adapter framework, a generic retry engine, a new persisted action table, or new top-level Operations pages. Retry is limited to operation families with a repo-verified safe retry/start seam; unsupported families fail closed with a disabled/deferred reason. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12.52.0 +**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, Laravel Sail 1.x +**Storage**: PostgreSQL; existing `operation_runs` table/context JSON only. No new table planned. +**Testing**: Pest 4 unit/feature/browser tests. Filament action tests must mount Livewire components/pages, not static resource classes. +**Validation Lanes**: fast-feedback, confidence, browser. +**Target Platform**: Laravel web application under `apps/platform`. +**Project Type**: Laravel + Filament application. +**Performance Goals**: Operations hub and detail remain DB-only render paths for run status; no Graph/provider calls during UI render. +**Constraints**: Fail closed for uncertain actions; no high-risk retry; no force-success; no raw diagnostics by default; no asset changes unless proven necessary. +**Scale/Scope**: Existing Operations hub/detail and related domain links. No new top-level IA. + +## Repo Truth Captured During Prep + +- Current branch at prep time: `365-operations-ui-operator-actions-regression-gate` +- Baseline HEAD: `3ce1cae7 feat: implement restore high risk operation reconciliation (#435)` +- Baseline status before artifacts: clean except the new Spec 365 directory. +- Spec 364 baseline context: restore high-risk reconciliation has completed task/checklist artifacts and is treated as the immediate completed predecessor. +- Current package baseline from Laravel Boost: + - PHP 8.4.15 + - Laravel 12.52.0 + - Filament 5.2.1 + - Livewire 4.1.4 + - Pest 4.3.1 + +Relevant implementation files discovered: + +```text +apps/platform/app/Models/OperationRun.php +apps/platform/app/Services/OperationRunService.php +apps/platform/app/Services/AdapterRunReconciler.php +apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php +apps/platform/app/Support/Operations/Reconciliation/* +apps/platform/app/Support/OperationRunType.php +apps/platform/app/Support/OperationRunStatus.php +apps/platform/app/Support/OperationRunOutcome.php +apps/platform/app/Support/OperationCatalog.php +apps/platform/app/Support/OperationRunCapabilityResolver.php +apps/platform/app/Support/Auth/Capabilities.php +apps/platform/app/Policies/OperationRunPolicy.php +apps/platform/app/Support/OpsUx/OperationUxPresenter.php +apps/platform/app/Support/OpsUx/OperationRunProgressContract.php +apps/platform/app/Support/OperationRunLinks.php +apps/platform/app/Support/Navigation/RelatedNavigationResolver.php +apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php +apps/platform/app/Filament/Pages/Monitoring/Operations.php +apps/platform/app/Filament/Resources/OperationRunResource.php +apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +apps/platform/app/Services/Audit/AuditRecorder.php +apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +``` + +Repo-specific decisions: + +- `OperationRunService` remains the write seam for OperationRun state/outcome/reconciliation transitions. +- `AdapterRunReconciler` and `OperationRunReconciliationRegistry` are the only approved reconciliation execution path. +- `OperationRunLinks` / `RelatedNavigationResolver` are the preferred related-object navigation path. +- `OperationRunPolicy` and `OperationRunCapabilityResolver` remain the authorization entry points; do not use raw capability strings. +- `TenantlessOperationRunViewer::resumeCaptureAction()` is a narrow existing resume-like seam and must be reconciled into the central eligibility model if touched. +- No repo-wide generic retry seam was found during prep; broad retry must remain unavailable/deferred unless implementation verifies or adds a bounded safe seam per operation family. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces. +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: + - `/admin/workspaces/{workspace}/operations` + - tenantless OperationRun detail page + - OperationRun detail header action groups + - related domain object action links +- **No-impact class, if applicable**: N/A. +- **Native vs custom classification summary**: mixed but Filament-native first. Reuse existing native table/detail actions and existing OperationRun detail sections; avoid new styling systems. +- **Shared-family relevance**: status messaging, header actions, related navigation, evidence/report/restore links, diagnostics disclosure. +- **State layers in scope**: page, table row, detail header, detail sections, action modal state. +- **Audience modes in scope**: operator-MSP and support-platform; customer-readable defaults must remain calm and non-technical. +- **Decision/diagnostic/raw hierarchy plan**: decision-first default, diagnostics second, support/raw third. +- **Raw/support gating plan**: collapsed and capability-gated. +- **One-primary-action / duplicate-truth control**: `OperationRunActionEligibility` output is the single source for primary action and disabled reasons consumed by list/detail/actions. +- **Handling modes by drift class or surface**: review-mandatory for high-risk action surface and raw leakage guard. +- **Repository-signal treatment**: report-only for existing Operations page audit docs unless implementation materially changes IA; review-mandatory for dangerous-action and customer-safe checks. +- **Special surface test profiles**: monitoring-state-page, shared-detail-family. +- **Required tests or manual smoke**: functional core + state-contract + browser smoke. +- **Exception path and spread control**: none planned. Any generic retry exception must be documented in this feature and covered by tests. +- **Active feature PR close-out entry**: Guardrail + Smoke Coverage. +- **UI/Productization coverage decision**: Existing strategic Operations page is materially changed; update existing coverage docs or record proportional no-update rationale during implementation close-out. +- **Coverage artifacts to update**: update `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md` / design matrix when implementation changes layout, action hierarchy, state hierarchy, or screenshots materially. A no-update rationale is allowed only when changes are limited to existing pattern-compatible action/copy wiring, and that rationale must be recorded in `tasks.md` close-out. +- **No-impact rationale**: N/A. +- **Navigation / Filament provider-panel handling**: no panel provider or navigation change planned. +- **Screenshot or page-report need**: screenshot/browser artifact recommended for the final smoke; no new page report required during prep. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes. +- **Systems touched**: OperationRun UX presenter/progress, related navigation, reconciliation registry/service, authorization policy/capability resolver, audit logging, localization. +- **Shared abstractions reused**: `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `RelatedNavigationResolver`, `AdapterRunReconciler`, `OperationRunService`, `OperationRunPolicy`. +- **New abstraction introduced? why?**: Yes, a narrow action eligibility resolver because no existing shared layer combines status/outcome/freshness/risk/adapter/RBAC/scope/related metadata into a single action contract. +- **Why the existing abstraction was sufficient or insufficient**: Existing presenters and services are sufficient for summaries, links, and writes, but insufficient for one-primary-action and action permission consistency. +- **Bounded deviation / spread control**: The resolver must not create a new operation taxonomy, adapter registry, or persisted action model. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes. +- **Central contract reused**: `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `OperationRunService`. +- **Delegated UX behaviors**: artifact links, run detail links, reconciliation result handling, lifecycle result display, tenant/workspace-safe URLs. +- **Surface-owned behavior kept local**: visible hierarchy and invocation of approved actions. +- **Queued DB-notification policy**: no new policy. +- **Terminal notification path**: existing central lifecycle mechanism. +- **Exception path**: Generic retry is not approved by this plan. If implemented, it must be through an existing or bounded safe start seam with explicit tests and audit. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes, bounded. +- **Provider-owned seams**: existing provider reason codes and provider failure summaries in canonical OperationRun context. +- **Platform-core seams**: OperationRun action eligibility, outcome summaries, high-risk classification, related action display. +- **Neutral platform terms / contracts preserved**: operation, outcome, attention, reconcile, retry, verification, partial, blocked, related evidence. +- **Retained provider-specific semantics and why**: Provider reason codes remain diagnostics only because they help operators/support understand blocked/partial states. +- **Bounded extraction or follow-up path**: none planned. + +## Constitution Check + +*GATE: Must pass before implementation. Re-check after design and before code merge.* + +- Inventory-first: PASS. This spec reads existing OperationRun and related artifact truth only. +- Read/write separation: PASS with constraints. Reconcile/retry are explicit operator actions with authorization, confirmation where appropriate, and audit. +- Graph contract path: PASS. No Graph calls in UI render or action eligibility. Retry seam, if any, must use existing service/jobs and Graph abstractions. +- Deterministic capabilities: PASS. Use `Capabilities` constants and `OperationRunCapabilityResolver`/policies. Add no raw strings. +- RBAC-UX: PASS. Preserve non-member/not-entitled 404 and member-missing-capability 403 semantics. +- Workspace isolation: PASS. All actions must enforce workspace/environment scope. +- Destructive-like actions: PASS. This spec forbids destructive and force-success actions. State-changing reconcile/retry actions require confirmation/audit as appropriate. +- Global search: PASS. `OperationRunResource` remains not globally searchable unless separately changed with View/Edit contract. +- Run observability: PASS. Reconcile uses existing run write seam; retry creates a run only through safe start seam. +- OperationRun start UX: PASS with constraint. Retry must reuse central start UX if implemented. +- Ops-UX 3-surface feedback: PASS. No new terminal DB notification behavior planned. +- Ops-UX lifecycle: PASS. `OperationRunService` owns state/outcome/reconciliation writes. +- Ops-UX summary counts: PASS. No new summary count keys planned; any touched keys must use `OperationSummaryKeys`. +- Data minimization: PASS. Raw context hidden/gated; no secrets in audit. +- Test governance: PASS. Unit, feature, and browser lane plans are explicit. +- Proportionality: PASS. New resolver is justified by safety, RBAC, high-risk guard, and multiple current concrete run families. +- No premature abstraction: PASS. One resolver replaces scattered action decision logic and is not a registry/framework. +- Persisted truth: PASS. No new independent persisted truth. +- Behavioral state: PASS. No new status/outcome family. +- UI semantics: PASS. Derived summaries and actions map from existing domain/run truth. +- Shared pattern first: PASS. Existing presenters/links/services are reused. +- Provider boundary: PASS. Provider details remain diagnostics only. +- V1 explicitness / few layers: PASS. One narrow derived layer. +- Spec discipline / bloat check: PASS. Proportionality review complete. +- Badge semantics: PASS. Implementation must reuse badge/shared status rendering if status-like badges change. +- Filament-native UI: PASS. Use native Filament actions/sections and existing shared primitives. +- UI/UX surface taxonomy: PASS. Surfaces classified. +- Decision-first operating model: PASS. Required default-visible fields and hierarchy defined. +- Audience-aware disclosure: PASS. Raw/support content hidden/gated. +- Filament UI Action Surface Contract: PASS with implementation tasks. +- UI/Productization coverage: PASS. Impact classified; coverage update/no-update rationale required at close-out. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for resolver/presenter; Feature for actions/RBAC/scope/audit/related links; Browser for Operations UI decision-first and raw leakage guard. +- **Affected validation lanes**: fast-feedback, confidence, browser. +- **Why this lane mix is the narrowest sufficient proof**: Action safety is mostly deterministic logic plus server-side enforcement; browser coverage is limited to the user-visible matrix and leakage guard. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec365` + - `cd apps/platform && ./vendor/bin/pint --dirty` + - `git diff --check` +- **Fixture / helper / factory / seed / context cost risks**: Need canonical OperationRun fixtures for each matrix state; keep as explicit Spec365 helpers only. +- **Expensive defaults or shared helper growth introduced?**: no; browser fixtures must not become global defaults. +- **Heavy-family additions, promotions, or visibility changes**: one explicit browser smoke file. +- **Surface-class relief / special coverage rule**: monitoring-state-page/shared-detail-family special coverage applies. +- **Closing validation and reviewer handoff**: Run Spec365 plus Spec358-364 regression filters and browser smoke. Review retry deferrals explicitly. +- **Budget / baseline / trend follow-up**: none expected. +- **Review-stop questions**: Does any action bypass resolver/policy? Does any retry path lack a safe start seam? Does high-risk restore expose unsafe copy/action? Are raw diagnostics default-visible? Does every state-changing action disclose TenantPilot-only, Microsoft-tenant, or simulation-only scope before execution? +- **Escalation path**: document-in-feature for retry/acknowledge deferrals; follow-up-spec for a generic retry framework. +- **Active feature PR close-out entry**: Guardrail + Smoke Coverage. +- **Why no dedicated follow-up spec is needed**: This completes the current OperationRun/Reconciliation program without introducing a larger governance inbox. + +## Project Structure + +### Documentation (this feature) + +```text +specs/365-operations-ui-operator-actions-regression-gate/ +├── spec.md +├── plan.md +├── tasks.md +├── checklists/ +│ └── requirements.md +└── artifacts/ + ├── spec365-action-eligibility-matrix.md + └── spec365-regression-gate-matrix.md +``` + +### Source Code (repository root) + +Expected implementation paths: + +```text +apps/platform/app/ +├── Filament/ +│ ├── Pages/Monitoring/Operations.php +│ ├── Pages/Operations/TenantlessOperationRunViewer.php +│ └── Resources/OperationRunResource.php +├── Policies/OperationRunPolicy.php +├── Services/OperationRunService.php +├── Services/AdapterRunReconciler.php +├── Support/ +│ ├── Auth/Capabilities.php +│ ├── OperationRunCapabilityResolver.php +│ ├── OperationRunLinks.php +│ ├── OpsUx/ +│ │ ├── OperationUxPresenter.php +│ │ └── OperationRunProgressContract.php +│ └── Operations/ +│ ├── OperationRunActionEligibility.php +│ └── Reconciliation/ +│ └── OperationRunReconciliationRegistry.php +└── Services/Audit/ + ├── AuditRecorder.php + └── WorkspaceAuditLogger.php + +apps/platform/lang/ +├── en/localization.php +└── de/localization.php + +apps/platform/tests/ +├── Unit/Support/Operations/ +├── Unit/Support/OpsUx/ +├── Feature/Operations/ +└── Browser/ +``` + +## Phase 0 - Research + +Completed during prep: + +- Read repository governance and architecture docs required by `AGENTS.md`. +- Verified installed Laravel/Filament/Livewire/Pest versions with Laravel Boost. +- Searched current Filament/Livewire/Pest docs through Laravel Boost for action confirmation/testing/global search constraints. +- Audited OperationRun model/service/reconciliation registry/link/policy/presenter/UI files. +- Checked completed Specs 358-364 for baseline context and scope continuity. + +Research conclusions: + +- Central action eligibility is justified. +- Reconcile can reuse existing registry/reconciler/service seams. +- Related links should reuse existing link/navigation resolvers. +- Generic retry is not repo-real yet and must be explicitly bounded. +- OperationRun acknowledge should be deferred unless a clean existing seam is verified during implementation. + +## Phase 1 - Design + +Design artifacts: + +- `artifacts/spec365-action-eligibility-matrix.md` +- `artifacts/spec365-regression-gate-matrix.md` + +Primary design decisions: + +1. Use one derived resolver for action decisions. +2. Keep writes service-owned. +3. Keep related navigation canonical and scope-safe. +4. Keep high-risk operations fail-closed. +5. Keep raw/support diagnostics hidden and gated. +6. Keep Operations as the central surface. + +## Phase 2 - Implementation Approach + +Implementation sequence: + +1. Add resolver and DTO/presenter tests first. +2. Add high-risk guard and forbidden-action tests. +3. Wire related links and detail/list primary action display. +4. Add safe reconcile Filament action with policy/audit/scope enforcement. +5. Verify retry seams; implement only safe non-high-risk retry or document deferral. +6. Add localization and summary presenter copy. +7. Add feature tests for actions/RBAC/scope/audit. +8. Add browser smoke for representative Operations UI states. +9. Run Spec365 and Spec358-364 regression gate, including Spec363. + +## Risk Register + +| Risk | Impact | Mitigation | +|---|---|---| +| Generic retry grows into unsafe re-execution framework | High | Implement only repo-verified seams; otherwise disabled/deferred reason | +| UI action visibility diverges from direct action authorization | High | Central resolver plus policy checks plus direct-action feature tests | +| Restore/high-risk shows unsafe next action | High | High-risk guard unit/feature/browser tests | +| Raw provider/SQL/queue leakage appears in default UI | High | Presenter sanitization and browser leakage guard | +| Related links bypass scope checks | High | Reuse existing link/policy resolvers and add cross-scope tests | +| New resolver becomes taxonomy framework | Medium | Keep derived, non-persisted, no new enum/status family | + +## Deployment / Ops Impact + +- Migrations: none planned. +- Environment variables: none planned. +- Queue/cron workers: no new workers planned; retry, if implemented, must use existing queues/jobs for that operation family. +- Storage/volumes: none. +- Assets: none planned. If implementation unexpectedly registers Filament assets, deploy must include `cd apps/platform && php artisan filament:assets`. +- Staging validation: required before production because this touches operator action affordances and high-risk restore safety. + +## Open Questions for Implementation + +1. Which existing capability constants should govern `reconcile`, `retry`, and `view diagnostics` for each operation family? +2. Is there a clean existing OperationRun acknowledge/note/audit seam? If not, acknowledge is deferred. +3. Which non-high-risk operation families have a safe idempotent retry/start seam today? +4. Should action metadata live only in existing audit logs, or also in bounded `context.operator_actions` for UI display? +5. Does implementation materially change the Operations page enough to require updating the UI audit page report/design matrix? + +## Implementation Stop Conditions + +- Stop and update spec/plan before adding a generic retry framework. +- Stop and update spec/plan before adding a new table/entity/status/outcome family. +- Stop before introducing any restore retry/re-execute or force-success action. +- Stop if action execution cannot be made server-side RBAC/scope-safe. +- Stop if state-changing action copy cannot make mutation scope explicit before execution. +- Stop if raw diagnostics cannot be gated/collapsed without broader UI redesign. diff --git a/specs/365-operations-ui-operator-actions-regression-gate/spec.md b/specs/365-operations-ui-operator-actions-regression-gate/spec.md new file mode 100644 index 00000000..48a3d9e5 --- /dev/null +++ b/specs/365-operations-ui-operator-actions-regression-gate/spec.md @@ -0,0 +1,753 @@ +# Feature Specification: Operations UI Operator Actions & Regression Gate + +**Feature Branch**: `365-operations-ui-operator-actions-regression-gate` +**Created**: 2026-06-07 +**Status**: Draft / Ready for implementation +**Input**: User description: "Spec 365 - Operations UI Operator Actions & Regression Gate" + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: OperationRun truth, reconciliation results, restore safety, and outcome semantics now exist, but operators still need a single decision-first surface that answers what happened, what needs attention, and what can be safely done next. +- **Today's failure**: Operators can see run history and technical details, but safe action eligibility is not centralized. This creates risk of scattered UI conditions, unsafe retry affordances for high-risk operations, raw diagnostic leakage, and incomplete regression coverage across Specs 358-364. +- **User-visible improvement**: The Operations hub and OperationRun detail become actionable and calm: one primary safe action, clear reason, scope, outcome, related domain links, and diagnostics only on demand. +- **Smallest enterprise-capable version**: Add a central derived action eligibility resolver, wire safe reconcile/open-related/detail presentation on existing Operations surfaces, integrate retry only where a repo-verified safe retry seam exists, block high-risk retry/force-success, and add a regression gate matrix. +- **Explicit non-goals**: No new adapter system, no generic OperationRun retry engine without an existing safe dispatch seam, no restore retry/re-execute, no force complete, no delete/purge, no new top-level Operations pages, no new persisted OperationRun status/outcome family, no raw JSON as the default detail experience. +- **Permanent complexity imported**: One narrow action eligibility resolver/presenter family, localized action/summary copy, action metadata/audit convention for operator actions, and focused unit/feature/browser regression tests. +- **Why now**: Specs 358-364 established the canonical run, reconciliation, outcome, and high-risk restore semantics. Without this final UI/action gate, the program remains technically correct but not operator-ready. +- **Why not local**: Local Blade/Filament `if` conditions would duplicate safety logic and create drift between list/detail/action tests. Central eligibility is required by RBAC, scope isolation, high-risk guardrails, and one-primary-action consistency. +- **Approval class**: Core Enterprise / Workflow Compression. +- **Red flags triggered**: New resolver abstraction; operator-facing action surface; high-risk operation guard. Defense: the abstraction is derived, non-persisted, bounded to existing OperationRun truth, and has multiple concrete current-release cases across review, evidence, backup/sync, and restore. +- **Score**: Nutzen: 3 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 3 | Wiederverwendung: 1 | **Gesamt: 12/12** +- **Decision**: approve as a bounded completion spec for the OperationRun/Reconciliation program. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - `/admin/workspaces/{workspace}/operations` + - tenantless OperationRun detail route resolved by `OperationRunLinks::tenantlessView()` + - existing related domain detail routes resolved by `OperationRunLinks` / `RelatedNavigationResolver` +- **Data Ownership**: Existing workspace-owned `operation_runs` and related domain records. No new table or independent persisted entity is introduced by this spec. +- **RBAC**: Workspace membership and environment entitlement remain required. Capability checks must use existing capability registry/policies first; any new capability constant must be added only after repo verification proves no existing capability fits. + +For this workspace-scope spec: + +- **Default filter behavior when tenant-context is active**: Existing workspace Operations routes remain workspace-scoped. Tenant-context routes may deep-link to the same run only through existing tenant/workspace-safe URL resolution and entitlement checks. +- **Explicit entitlement checks preventing cross-tenant leakage**: OperationRun view/action policy checks must preserve current 404 semantics for non-member or not-entitled scope and 403 semantics for members missing capability. + +## UI Surface Impact *(mandatory - UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [ ] No UI surface impact +- [x] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [x] New modal/drawer/wizard/action added +- [x] New table/form/state added +- [ ] Customer-facing surface changed +- [x] Dangerous action changed +- [x] Status/evidence/review presentation changed +- [x] Workspace/environment context presentation changed + +## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)* + +- **Route/page/surface**: + - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` + - `apps/platform/app/Filament/Resources/OperationRunResource.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - OperationRun detail header actions, related action group, summary/evidence/technical detail sections. +- **Current or new page archetype**: Existing Strategic Surface: Operations Hub / monitoring-state page. +- **Design depth**: Strategic Surface. +- **Repo-truth level**: repo-verified. +- **Existing pattern reused**: `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md`, `docs/ui-ux-enterprise-audit/target-experience-briefs/operations-hub.md`, `docs/ui/action-surface-contract.md`, existing Filament resource/page actions, existing OperationRun detail presenter sections. +- **New pattern required**: none. Add a bounded action eligibility resolver and presenter extension; no new UI framework. +- **Screenshot required**: yes during implementation for browser smoke. Store in `specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-operations-ui-screenshots/` if screenshots are captured. +- **Page audit required**: no new page audit required during prep; update the existing Operations page report only if implementation materially changes the page contract beyond action/state presentation. +- **Customer-safe review required**: yes. Browser smoke must prove no default raw SQL/Graph/queue/secret/stack trace leakage. +- **Dangerous-action review required**: yes. Restore/high-risk operations must not show retry/re-execute/force-success actions. +- **Coverage files updated or explicitly not needed**: + - [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` + - [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` + - [ ] `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` +- **Coverage artifact decision**: Implementation must either update the existing Operations page report/design matrix or record a proportional no-update rationale in `tasks.md` close-out when visual structure remains pattern-compatible. +- **No-impact rationale when applicable**: N/A. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: status messaging, header actions, row/detail actions, related navigation, evidence/report links, audit/action metadata, diagnostic disclosure. +- **Systems touched**: + - `OperationUxPresenter` + - `OperationRunProgressContract` + - `OperationRunLinks` + - `RelatedNavigationResolver` + - `RelatedActionLabelCatalog` + - `AdapterRunReconciler` + - `OperationRunReconciliationRegistry` + - `OperationRunService` + - `OperationRunPolicy` / capability resolver + - `AuditRecorder` / workspace audit services +- **Existing pattern(s) to extend**: Existing Operations hub, OperationRun detail, related navigation/action label catalog, OperationRun reconciliation registry, central OperationRun service writes. +- **Shared contract / presenter / builder / renderer to reuse**: Reuse existing presenters/link resolvers first. Add `OperationRunActionEligibility` only for action decision derivation where no central action model exists. +- **Why the existing shared path is sufficient or insufficient**: Existing services own reconciliation writes, detail links, and UX summaries, but no single repo-verified layer answers "which operator action is allowed and primary for this run". That gap is safety-relevant across multiple run families. +- **Allowed deviation and why**: Add one derived eligibility resolver and small action DTO/presenter if needed; do not create a new registry, state enum, or persisted action table unless implementation proves existing audit metadata is insufficient and the spec is amended. +- **Consistency impact**: List/detail/browser tests must use the same primary action, disabled reason, high-risk guard, and related-link truth. +- **Review focus**: Verify there is no parallel local action logic in Blade/Filament closures and no broad retry/re-execute system hidden behind UI actions. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `OperationRunService`, `AdapterRunReconciler`. +- **Delegated start/completion UX behaviors**: Existing progress/status/detail links remain central. Reconcile uses `AdapterRunReconciler` and `OperationRunService`. Retry may create a new OperationRun only through a repo-verified safe retry/start seam; otherwise retry is unavailable with a disabled reason. +- **Local surface-owned behavior that remains**: Operation-specific inputs are out of scope. The Operations surface owns display hierarchy and invoking authorized actions only. +- **Queued DB-notification policy**: No new queued/running DB notification policy. Retry, if implemented through an existing start seam, must follow that seam's notification policy. +- **Terminal notification path**: Existing central lifecycle mechanism. +- **Exception required?**: none for reconcile/open-related. Generic retry is explicitly not approved unless an existing safe dispatch seam is verified or added through a bounded implementation decision with tests. + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes, bounded to presentation of existing operation reason codes and provider failure summaries. +- **Boundary classification**: mixed. OperationRun/action eligibility is platform-core; provider reason codes remain provider-owned diagnostic data. +- **Seams affected**: Operator summaries, diagnostics, high-risk labels, related artifact labels, localization strings. +- **Neutral platform terms preserved or introduced**: operation, run, outcome, attention, reconcile, retry, verification, partial, blocked, related artifact, diagnostics. +- **Provider-specific semantics retained and why**: Existing Graph/provider reason codes may appear only in operator/support diagnostics, not customer-readable default copy. +- **Why this does not deepen provider coupling accidentally**: The resolver reads canonical OperationRun context and operation type; it does not call Graph, inspect raw payloads, or add provider-specific branching except for existing high-risk operation classification. +- **Follow-up path**: none unless implementation discovers provider-shaped UI copy that cannot be normalized without a separate provider guidance spec. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Operations hub list decision/action columns | yes | Native Filament table + existing shared presenters | status messaging, row/detail actions | page, table, row, URL-safe links | no | Existing strategic surface; no new page | +| OperationRun detail decision layout/header actions | yes | Native Filament resource/detail + existing custom detail sections | header actions, related links, diagnostics | detail, header, disclosure | no | Keep raw/support detail secondary/collapsed | +| Reconcile/Retry action modals | yes | Filament `Action` with confirmation/authorization | action safety, audit | detail/header action | no | Retry only repo-real non-high-risk seams | +| Related domain object action | yes | Existing link/action resolvers | navigation, evidence/report viewers | header/action group | no | Canonical metadata only | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| 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 | +|---|---|---|---|---|---|---|---| +| Operations hub list | Primary Decision Surface | Operator scans current and recent operations for attention states | operation label, scope, status/outcome, freshness, primary reason, one primary safe action | diagnostic references, technical context, raw context only if permitted | Primary because it is the central monitoring surface for run follow-up | Follows Operations hub target experience | Removes button sprawl and repeated raw-state inspection | +| OperationRun detail | Primary Decision Surface for a selected run | Operator decides whether to reconcile, retry safely, open related result, or inspect details | status/outcome, scope, reason, summary, evidence, high-risk warning, primary action | reconciliation evidence, result coverage, provider reason codes, raw context behind support diagnostics | Primary for individual run decision; not a general governance inbox | Keeps one run decidable without cross-page reconstruction | Makes diagnostics secondary and prevents unsafe restore actions | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| 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 | +|---|---|---|---|---|---|---|---| +| Operations hub list | operator-MSP, support-platform | Status, outcome, freshness, scope, primary reason, primary action | reason codes and affected families when compact | raw context not shown | action chosen by eligibility resolver | raw JSON, SQL/provider traces, queue payloads | Same resolver feeds primary action and disabled reason | +| OperationRun detail | operator-MSP, support-platform | What happened, why it matters, current state, safe next action, evidence summary | reason codes, coverage, verification, lifecycle reconciliation | raw context collapsed and capability-gated | one primary action in header/decision section | raw payloads, stack traces, secrets, signed URLs | Summary states blocker once; evidence sections add proof only | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Operations hub list | List / Table / Monitoring | Monitoring-state page | Open run or invoke one safe primary action | Row click opens OperationRun detail | required | More/detail header | N/A - no destructive actions in this spec | `/admin/workspaces/{workspace}/operations` | tenantless OperationRun view link | workspace/environment chips | Operation runs / Operation run | status, outcome, freshness, reason, scope, safe action | none | +| OperationRun detail | Detail / Workbench | Shared-detail-family | Reconcile, Retry when safe, or Open related object | Detail page itself | N/A | Header More/action group | N/A - no destructive actions in this spec | existing Operations hub | tenantless OperationRun view link | workspace/environment detail | Operation run | status, outcome, reason, evidence, high-risk guard | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Operations hub list | Tenant/MSP operator | Decide which run needs attention and what can be safely done next | Monitoring list | What needs action right now? | operation, scope, status, outcome, freshness, reason, primary action | reason codes, raw context, internal metadata | lifecycle, outcome, freshness, reconciliation, risk | TenantPilot only for reconcile metadata; Microsoft tenant only for repo-real retry start seams that explicitly do so | View related, Reconcile, Retry when safe, View details | none | +| OperationRun detail | Tenant/MSP operator and support operator | Decide final follow-up for one run | Decision detail | What happened, why does it matter, and what is the safe next step? | summary, scope, status/outcome, evidence, high-risk guard, primary action | provider codes, dispatch metadata, raw context gated/collapsed | lifecycle, outcome, data completeness, verification, risk | TenantPilot only for reconcile/action metadata; retry follows existing start seam | View related, Reconcile, Retry when safe, View diagnostics | Restore retry/re-execute and force success are forbidden | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no. Eligibility is derived from existing OperationRun, policy, capability, adapter registry, and related metadata. +- **New persisted entity/table/artifact?**: no. Audit/action metadata may be written into existing audit systems or existing OperationRun context only if no better existing seam exists. +- **New abstraction?**: yes, a bounded `OperationRunActionEligibility` resolver and possibly a small action DTO/presenter. +- **New enum/state/reason family?**: no new persisted status/outcome family. Derived action ids and disabled reason keys may be localized strings/constants only. +- **New cross-domain UI framework/taxonomy?**: no. This extends the existing Operations/OperationRun UX. +- **Current operator problem**: Action safety and primary-action choice are currently not a single testable decision. High-risk restore states must fail closed everywhere. +- **Existing structure is insufficient because**: `OperationUxPresenter` and link resolvers can summarize and navigate, while `AdapterRunReconciler` can reconcile, but no existing layer combines operation type, status/outcome, freshness, adapter support, related metadata, RBAC, and scope into one action contract. +- **Narrowest correct implementation**: One resolver that returns `primary_action`, `secondary_actions`, `disabled_actions`, `disabled_reasons`, and `attention_reason`; Filament pages consume that output. +- **Ownership cost**: Unit tests for action decisions, feature tests for server-side action enforcement, browser smoke for representative states, and localization maintenance. +- **Alternative intentionally rejected**: Scattered UI `if` blocks and page-local action conditions. They would be faster but unsafe because direct action execution, disabled reasons, and list/detail actions could diverge. +- **Release truth**: Current-release truth. It uses existing Specs 358-364 behavior and does not speculate about a full governance inbox. + +### Compatibility posture + +This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature, Browser. +- **Validation lane(s)**: fast-feedback for resolver/presenter tests, confidence for Filament/action/RBAC/audit tests, browser for Operations UI smoke. +- **Why this classification and these lanes are sufficient**: The highest risk is decision logic and server-side action enforcement, so unit and feature tests prove behavior. Browser smoke proves default-visible decision hierarchy and raw leakage guard on the real Filament surface. +- **New or expanded test families**: + - `Spec365OperationRunActionEligibilityTest` + - `Spec365OperationRunPrimaryActionTest` + - `Spec365HighRiskActionGuardTest` + - `Spec365OperationRunSummaryPresenterTest` + - `Spec365OperationRunActionsTest` + - `Spec365OperationRunActionRbacTest` + - `Spec365OperationRunActionAuditTest` + - `Spec365OperationRunRelatedLinksTest` + - `Spec365OperationRunRegressionGateTest` + - `Spec365OperationsUiOperatorActionsSmokeTest` +- **Fixture / helper cost impact**: Reuse existing OperationRun factories/helpers. Add only explicit Spec365 fixtures for canonical context states; no broad seeding defaults. +- **Heavy-family visibility / justification**: Browser smoke is explicit and limited to the Operations UI representative matrix. +- **Special surface test profile**: monitoring-state-page and shared-detail-family. +- **Standard-native relief or required special coverage**: Special coverage required for one primary action, high-risk guard, collapsed/gated diagnostics, raw leakage absence, and action denial. +- **Reviewer handoff**: Reviewers must verify that action visibility and direct action execution use the same resolver/policy rules, and that unsupported retry paths fail closed. +- **Budget / baseline / trend impact**: No expected material budget drift. Browser smoke may add one bounded feature-specific file. +- **Escalation needed**: document-in-feature if generic retry or OperationRun acknowledge is deferred because no repo-safe seam exists; follow-up-spec only if a generic retry framework is desired later. +- **Active feature PR close-out entry**: Guardrail + Smoke Coverage. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec365` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec364` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec363` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec362` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec361` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec360` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec359` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec358` + - `cd apps/platform && ./vendor/bin/pint --dirty` + - `git diff --check` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Decide the safe next action for an OperationRun (Priority: P1) + +As an operator, I can scan the Operations hub or open a run detail and immediately understand status, outcome, freshness, scope, primary reason, and the one safe next action. + +**Why this priority**: This is the core productization step that makes Specs 358-364 usable and customer-safe. + +**Independent Test**: Seed representative OperationRuns and verify the resolver, table/detail presentation, one primary action, and raw leakage guard. + +**Acceptance Scenarios**: + +1. **Given** a reconciled review-compose run with canonical related metadata, **When** the operator views it, **Then** the UI shows "Review already available" and "View review" as the primary action without "Waiting for worker" or duplicate-key leakage. +2. **Given** a partial sync run, **When** the operator views it, **Then** the UI shows completed-with-partial-results copy and a details-oriented primary action rather than retry as the default. +3. **Given** a fresh queued run, **When** the operator views it, **Then** no retry or reconcile action is offered. + +### User Story 2 - Safely reconcile eligible stale or failed runs (Priority: P1) + +As an authorized operator, I can trigger reconciliation only when an existing adapter, canonical proof, RBAC, and scope checks make the action safe. + +**Why this priority**: Reconciliation exists but must be explicitly actionable without bypassing `OperationRunService`. + +**Independent Test**: Directly call the Filament action/service path for eligible and unsupported runs and verify success/denial/audit metadata. + +**Acceptance Scenarios**: + +1. **Given** an eligible stale review-compose run with adapter proof and a user with the required capability, **When** the operator selects Reconcile, **Then** `AdapterRunReconciler` and `OperationRunService` write canonical reconciliation and action audit metadata. +2. **Given** an unsupported operation type, **When** the user opens or directly calls Reconcile, **Then** the action is absent or unavailable and direct execution is denied. +3. **Given** a cross-workspace OperationRun, **When** a user attempts Reconcile, **Then** server-side authorization denies access using existing workspace isolation semantics. + +### User Story 3 - Prevent unsafe retry and force-success actions (Priority: P1) + +As an operator, I never see retry, re-execute, force complete, or mark succeeded actions for high-risk restore/destructive operations. + +**Why this priority**: Restore/high-risk safety from Spec 364 must remain visible and enforced in the UI. + +**Independent Test**: Validate eligibility and browser state for restore verification-required, blocked, partial, and failed runs. + +**Acceptance Scenarios**: + +1. **Given** a failed `restore.execute` run, **When** the operator views it, **Then** no retry/re-execute/force-success action is visible and the disabled reason states that high-risk operations cannot be retried from this view. +2. **Given** a restore verification-required run, **When** the operator views it, **Then** the primary action is "View restore details" and verification proof is visible before technical diagnostics. +3. **Given** any operation, **When** the UI renders actions, **Then** no Force Complete or Mark Succeeded action exists. + +### User Story 4 - Retry only repo-verifiable safe non-high-risk operations (Priority: P2) + +As an operator, I can retry a safe non-high-risk operation only when the repository already has, or this spec implements, a bounded dispatch seam that creates a new OperationRun with canonical dispatch/retry metadata. + +**Why this priority**: Retry is useful, but a generic re-execution layer is outside the safe minimum. + +**Independent Test**: Use one repo-verified retry/resume/start seam and prove dispatch context, source/new run relationship, idempotency, and audit. If no seam exists for a family, verify disabled/deferred state. + +**Acceptance Scenarios**: + +1. **Given** a failed non-high-risk operation with a repo-verified retry seam and an authorized user, **When** the operator selects Retry, **Then** a new OperationRun is started with `context.dispatch`, source/new retry metadata, and audit/action metadata. +2. **Given** a failed non-high-risk operation without a repo-verified retry seam, **When** the operator views it, **Then** Retry is unavailable with a clear disabled reason. +3. **Given** a completed/succeeded run, **When** the operator views it, **Then** Retry is not offered. + +### User Story 5 - Open related domain evidence safely (Priority: P2) + +As an operator, I can open the related review, evidence snapshot, review pack/report, backup, sync, or restore detail when canonical related metadata and authorization allow it. + +**Why this priority**: Operators need proof and domain context without raw context inspection. + +**Independent Test**: Build canonical `context.reconciliation.related` cases and verify same-scope link resolution and cross-scope denial. + +**Acceptance Scenarios**: + +1. **Given** a reconciled evidence snapshot run with canonical related metadata, **When** the operator views it, **Then** the primary or secondary action opens the evidence detail using existing link policies. +2. **Given** related metadata that points outside the workspace/environment scope, **When** the operator views it, **Then** the related action is hidden or disabled and direct access is denied. + +### User Story 6 - Audit and localize operator actions (Priority: P3) + +As an auditor or support operator, I can trace reconcile/retry action attempts and outcomes without exposing secrets, and labels/summaries are available in EN/DE. + +**Why this priority**: Actionability must be audit-visible and customer-safe. + +**Independent Test**: Execute actions and assert audit/action metadata includes actor, action, run id, workspace, environment, previous/new status/outcome, reason code, timestamp, and related new run id when retry starts a run. + +**Acceptance Scenarios**: + +1. **Given** an authorized reconcile action, **When** it succeeds, **Then** audit/action metadata contains the required fields and no secrets. +2. **Given** a denied action attempt, **When** the user lacks capability or scope, **Then** the attempt is denied and either audit-visible or safely logged without leaking secrets. +3. **Given** EN and DE locale files, **When** the UI renders action labels and summary states, **Then** labels and disabled reasons are localized. + +## Functional Requirements *(mandatory)* + +### FR-001 - OperationRun Action Eligibility Resolver + +The system MUST provide a central, unit-testable eligibility layer for OperationRun operator actions. + +Inputs MUST include: + +- operation type +- status +- outcome +- freshness state +- reconciliation state +- dispatch context +- results context +- restore context +- risk classification +- user capabilities +- workspace/environment scope +- related metadata + +Outputs MUST include: + +- `primary_action` +- `secondary_actions` +- `disabled_actions` +- `disabled_reasons` +- `attention_reason` + +Acceptance: + +- No action visibility is decided through scattered Blade-only or page-local `if` logic. +- High-risk restore is not retryable. +- Completed/succeeded runs do not receive Retry. +- Unsupported runs do not receive Reconcile. +- Eligible stale runs may offer Reconcile when adapter proof exists. +- Missing capability and cross-scope state fail closed. +- Disabled reasons are visible in tooltip, modal description, or decision detail. + +### FR-002 - Safe Reconcile Action + +Reconcile MUST appear only when an existing adapter supports the operation, canonical proof is sufficient, RBAC and scope pass, and the action can route through existing reconciliation seams. + +Acceptance: + +- Reconcile uses `OperationRunReconciliationRegistry` / `AdapterRunReconciler`. +- Reconcile writes through `OperationRunService`. +- Reconcile is RBAC protected, scope-safe, and idempotent. +- Review-compose, review-pack/report artifact, evidence snapshot, sync/capture/backup, and restore reconciliation are limited to the proof already defined by Specs 359, 361, 362, and 364. +- Unsupported and cross-scope runs cannot be reconciled. +- Action audit/metadata is written without secrets. + +### FR-003 - Safe Retry Action for non-high-risk Runs + +Retry MUST be offered only for non-high-risk operations with a repo-verified safe retry/start seam. + +Candidate families MAY include review compose, evidence snapshot generation, review-pack/report generation, inventory/sync/capture, and backup capture only if implementation proves a safe, idempotent dispatch seam exists. + +Retry MUST NOT be implemented for: + +- `restore.execute` +- tenant mutation +- destructive mutation +- high-risk operation +- unknown operation +- completed/succeeded run +- unsupported operation without a safe retry seam + +Acceptance: + +- Retry eligibility is resolver-controlled. +- Retry starts a new OperationRun or uses an existing retry/resume seam with canonical `context.dispatch`. +- Source/new retry metadata is visible. +- Retry is audit-visible. +- High-risk restore shows an unavailable reason instead of a retry action. +- Unsupported retry paths fail closed and may be documented as deferred in `tasks.md`. + +### FR-004 - No Force Complete Action + +This spec MUST NOT introduce Force Complete, Mark Succeeded, Ignore Error and Complete, Manually Mark Restore Successful, Delete, Purge, or equivalent success-forcing actions. + +Acceptance: + +- No force-success action exists in action code, labels, localization, or tests. +- Restore/high-risk tests assert absence of force-success and retry/re-execute actions. + +### FR-005 - Acknowledge / Mark Reviewed Action + +Acknowledge is optional and MUST be implemented only if a clean existing audit/note seam exists. + +Acceptance if implemented: + +- Does not change `status` or `outcome`. +- Writes audit-visible note/action metadata. +- Is RBAC protected. +- UI distinguishes reviewed from resolved/succeeded. + +If no clean seam exists, implementation MUST document deferral in `tasks.md` and not implement a local substitute. + +### FR-006 - Open Related Domain Object Action + +The system MUST offer safe related-object actions when canonical metadata supports them. + +Sources: + +- `context.reconciliation.related` +- other canonical related metadata already exposed by OperationRun helpers +- existing `OperationRunLinks` / `RelatedNavigationResolver` paths + +Acceptance: + +- Reconciled review opens Review. +- Evidence snapshot opens Evidence. +- Review-pack/report artifact opens the existing safe report/review-pack seam when canonical metadata exists. +- Restore opens restore details when the existing route/policy resolves same-scope proof. +- Cross-scope related objects are hidden/disabled and direct access is denied. +- No signed URL or raw payload is read from context. + +### FR-007 - OperationRun Details Decision Layout + +OperationRun detail MUST be decision-first: + +- Header: operation label, status/outcome, scope, primary reason, primary action. +- Summary: what happened, why it matters, current state, next safe action. +- Evidence: reconciliation proof, result coverage, restore verification, provider reason codes where operator-safe. +- Details: dispatch metadata, duration, technical diagnostics. +- Raw context: internal/support capability only, collapsed by default. + +Acceptance: + +- Raw JSON is not the default experience. +- High-risk warnings are visible. +- Technical details are progressively disclosed. +- Customer-readable paths remain calm and free of raw implementation detail. + +### FR-008 - Outcome-Specific Summary Cards + +The UI/presenter MUST cover the main OperationRun outcome families: + +- Queued fresh +- Queued longer than expected +- Running +- Running longer than expected +- Reconciled from adapter +- Report/review-pack already available +- Evidence snapshot already available +- Sync completed with partial results +- Sync blocked +- Backup completed with partial results +- Backup blocked +- Restore verification required +- Restore completed with partial results +- Restore blocked +- Failed by lifecycle reconciliation + +Acceptance: + +- Each family has EN/DE copy. +- Summary cards use presenter/contract output, not Blade special cases. +- Browser smoke covers representative states. +- Customer-facing defaults do not show raw technical errors. + +### FR-009 - High-Risk Guard in UI + +High-risk operations MUST be visually and functionally protected. + +Acceptance: + +- Restore, tenant mutation, destructive mutation, unknown/high-risk operations have no Retry/Re-execute/Force Complete primary action. +- Verification-required state emphasizes verification proof and "View restore details". +- Disabled reasons are visible. +- Browser and feature tests assert action absence/disabled state. + +### FR-010 - RBAC / Capability Enforcement + +All actions MUST be capability-first and server-side enforced. + +Acceptance: + +- Use existing policies, `OperationRunCapabilityResolver`, and `Capabilities` constants first. +- Do not use raw capability strings or role-string checks in feature code. +- If new capability constants are required, add the smallest number and test positive/negative behavior. +- User without capability cannot see or execute the action. +- Direct action execution without capability fails. +- Cross-workspace and cross-environment execution fails. + +### FR-011 - Auditability for Operator Actions + +Each operator action and relevant denied attempt MUST be audit-visible or safely logged. + +Required fields: + +- action +- operation_run_id +- workspace_id +- managed_environment_id when present +- actor_id +- previous status/outcome +- resulting status/outcome +- reason_code +- timestamp +- related new operation_run_id when retry starts one + +Acceptance: + +- Reconcile action is audit-visible. +- Retry action is audit-visible when implemented. +- Acknowledge action is audit-visible if implemented. +- Failed/denied action attempts are audit-visible or safely logged. +- No secrets, access tokens, raw payloads, or signed URLs are stored. + +### FR-012 - Retry Metadata Contract + +When retry starts a run, the source and new run relationship MUST be visible using canonical context/action metadata. + +Example: + +```json +{ + "retry": { + "source_operation_run_id": 123, + "retry_operation_run_id": 124, + "requested_by_user_id": 5, + "requested_at": "2026-06-06T16:00:00+02:00", + "reason_code": "operator_retry_requested" + } +} +``` + +Acceptance: + +- Old and new runs link to each other or one canonical direction is documented. +- New run has `context.dispatch`. +- UI can show "Retry started". +- No uncontrolled retry loops. +- High-risk runs do not receive retry metadata. + +### FR-013 - Regression Gate Matrix + +This spec MUST define and cover a final OperationRun state matrix. + +Minimum matrix: + +| Family | State | +|---|---| +| Queue | fresh queued | +| Queue | stale queued | +| Queue | stale running | +| Reconciliation | review already available | +| Reconciliation | report/review-pack already available | +| Reconciliation | evidence snapshot already available | +| Sync | partial | +| Sync | blocked | +| Backup | partial | +| Backup | blocked | +| Restore | verification required | +| Restore | partial | +| Restore | blocked | +| Restore | failed | +| RBAC | action denied | +| Scope | cross-workspace denied | + +Acceptance: + +- Matrix exists at `artifacts/spec365-regression-gate-matrix.md`. +- Browser smoke covers representative states. +- Feature tests cover action eligibility/enforcement. +- Spec 358-364 regressions remain green. + +### FR-014 - No Raw Technical Leakage + +Customer-readable and shared default surfaces MUST NOT show raw technical details. + +Forbidden by default: + +- `SQLSTATE` +- `Guzzle` +- stack trace +- Graph raw payload +- access token +- client secret +- queue payload +- serialized job +- internal constraint names such as `environment_reviews_fingerprint_mutable_unique` + +Acceptance: + +- Browser tests assert absence of forbidden strings. +- Operator-safe reason codes may appear in diagnostics. +- Raw context JSON is collapsed and capability-gated. + +### FR-015 - No New Top-Level Sprawl + +This spec MUST use existing Operations and related domain surfaces. + +Acceptance: + +- No new top-level page for stale, retry, reconcile, partial sync, restore verification, or similar states. +- Operations remains the central run surface. +- Related domain pages are linked. +- Navigation remains calm. + +### FR-016 - Mutation Scope Disclosure + +Every state-changing operator action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only. + +Acceptance: + +- Reconcile copy/confirmation states that reconciliation writes TenantPilot OperationRun/action metadata only, unless an adapter/service explicitly performs a broader mutation that this spec permits. +- Retry copy/confirmation states the scope of the underlying safe retry/start seam before execution. +- High-risk or unknown mutation scope fails closed and does not expose the action. +- Feature or browser coverage proves the mutation-scope text is visible near the action, helper text, preview, or confirmation. + +## Canonical Cutover Rules + +- New UI/action code MUST read canonical OperationRun truth structures. +- New tests MUST be canonical-only. +- New action writes MUST use canonical audit/action metadata. +- Do not add new alias/fallback paths in Blade or presenters. +- Existing compatibility helpers may remain untouched, but Spec 365 code must not depend on legacy context paths. + +## Action Eligibility Contract + +The resolver SHOULD use a repo-conform name such as `OperationRunActionEligibility`. + +It SHOULD expose derived actions such as: + +- `view_related` +- `reconcile` +- `retry` +- `view_details` +- `view_diagnostics` +- `acknowledge` only if a clean existing seam exists + +It MUST always treat the following as unavailable: + +- `force_complete` +- `mark_succeeded` +- `delete` +- `purge` +- `retry_high_risk` +- `reexecute_restore` + +## Localization Requirements + +Add EN/DE localization keys for: + +- action labels +- action descriptions +- success/error/unavailable messages +- disabled reasons +- high-risk guard messages +- outcome summary titles/descriptions +- audit/action metadata labels when surfaced + +Example key families: + +- `operations.actions.reconcile.*` +- `operations.actions.retry.*` +- `operations.actions.view_related.*` +- `operations.actions.disabled.*` +- `operations.summary.restore_verification_required.*` +- `operations.summary.partial.*` + +## Nonfunctional Requirements + +- **NFR-001 - Decision-first UX**: Every OperationRun detail answers what happened, why it matters, safe next action, and evidence. +- **NFR-002 - Fail closed**: If eligibility is uncertain, do not offer the action. +- **NFR-003 - No high-risk retry**: Restore/tenant mutation/destructive mutation are not retryable in this spec. +- **NFR-004 - No destructive actions**: No delete/purge/force-success actions. +- **NFR-005 - Idempotency**: Reconcile and retry actions must be safe against double execution. Buttons must be disabled/debounced while executing. +- **NFR-006 - Auditability**: Actions and relevant attempts must be traceable. +- **NFR-007 - Capability-first RBAC**: UI hiding is never the only control. +- **NFR-008 - Workspace/environment isolation**: No cross-scope action. +- **NFR-009 - Livewire v4 compatibility**: No Livewire v3 APIs such as `emit`, `emitTo`, or `dispatchBrowserEvent`. +- **NFR-010 - No asset changes**: No Filament/Tailwind/build asset changes unless repo verification proves they are required. +- **NFR-011 - Explicit mutation scope**: State-changing actions must show TenantPilot-only, Microsoft-tenant, or simulation-only scope before execution. + +## Entities / Derived Contracts *(include if feature involves data)* + +No new persisted entity is required. + +Derived contracts: + +- **OperationRunActionEligibilityResult**: primary action, secondary actions, disabled actions, disabled reasons, attention reason, diagnostics visibility. +- **OperationRunOperatorActionMetadata**: audit/action metadata written through existing audit or OperationRun context only when needed. +- **OperationRunDecisionSummary**: presenter output for default-visible summary/evidence/high-risk state. + +Existing canonical context keys in scope: + +- `context.dispatch` +- `context.reconciliation` +- `context.results` +- `context.coverage` +- `context.restore` +- `context.operator_actions` only if existing audit seam is insufficient and implementation records a bounded decision. + +## Assumptions + +- Filament v5.2.1 with Livewire v4.1.4 is the target UI stack. +- Laravel panel providers remain registered in `apps/platform/bootstrap/providers.php`; this spec does not add a panel provider. +- `OperationRunResource` remains not globally searchable unless a future implementation explicitly adds a View/Edit page compliant global-search contract. Current prep assumes global search stays disabled for OperationRun resource. +- Reconcile uses existing adapter registry and `OperationRunService`. +- Generic retry is not assumed. It is available only per operation family when implementation verifies or creates a safe, idempotent, audited start seam. +- OperationRun acknowledge is deferred unless implementation verifies a clean existing audit/note seam. + +## Out of Scope + +- New adapter framework or adapter families. +- New report/evidence reconciliation. +- New sync/backup/restore semantics. +- Restore retry/re-execute. +- Generic retry framework. +- Force complete, mark succeeded, delete, purge, cancel. +- Full decision-based governance inbox. +- New notification routing architecture. +- New customer review workspace. +- New top-level Operations pages. +- New assets or frontend build strategy. + +## Success Metrics *(mandatory)* + +- **SM-001**: For every covered matrix state, resolver returns at most one primary action. +- **SM-002**: High-risk restore/destructive operations expose zero retry/re-execute/force-success actions in unit, feature, and browser tests. +- **SM-003**: Eligible reconcile actions execute only through the existing reconciliation registry and `OperationRunService`. +- **SM-004**: Direct action execution without capability or scope fails server-side. +- **SM-005**: Browser smoke proves no raw technical leakage in default Operations UI. +- **SM-006**: Spec 358-364 regression tests remain green in the planned validation lane. + +## Acceptance Criteria + +- **AC-001**: Action eligibility resolver exists. +- **AC-002**: Each relevant run state has at most one primary action. +- **AC-003**: Reconcile is only available when adapter, proof, RBAC, and scope pass. +- **AC-004**: Retry is only available for safe non-high-risk operations with a repo-verified seam. +- **AC-005**: Restore/high-risk operations are not retryable. +- **AC-006**: No Force Complete or Mark Succeeded action exists. +- **AC-007**: Related domain links use canonical metadata and existing link/policy seams. +- **AC-008**: RBAC is enforced server-side. +- **AC-009**: Scope isolation is enforced. +- **AC-010**: Operator actions are audit-visible. +- **AC-011**: Operation detail is decision-first. +- **AC-012**: Raw technical leakage is absent by default. +- **AC-013**: Regression gate matrix exists and is test-covered. +- **AC-014**: Specs 358-364 remain stable. +- **AC-015**: State-changing operator actions disclose mutation scope before execution. + +## Filament v5 Output Contract + +1. **Livewire v4.0+ compliance**: Required. This project uses Livewire v4.1.4. No Livewire v3 APIs. +2. **Provider registration location**: No panel provider changes. If any future provider change becomes necessary, Laravel 12 panel providers belong in `apps/platform/bootstrap/providers.php`. +3. **Globally searchable resources**: `OperationRunResource` currently disables global search. If changed later, it must have an Edit or View page and record title contract. +4. **Destructive actions**: This spec adds no destructive actions. Reconcile/retry are state-changing and must execute through `Action::make(...)->action(...)`, with confirmation where risk or current Filament action rules require it, explicit mutation-scope copy, plus server-side authorization and audit. +5. **Asset strategy**: No new assets. Deploy process only needs `cd apps/platform && php artisan filament:assets` if implementation registers Filament assets, which this spec does not plan. +6. **Testing plan**: Unit tests for resolver/primary action/high-risk guard/presenter; feature tests for actions/RBAC/scope/audit/related links; browser smoke for Operations list/detail representative states and raw leakage guard. diff --git a/specs/365-operations-ui-operator-actions-regression-gate/tasks.md b/specs/365-operations-ui-operator-actions-regression-gate/tasks.md new file mode 100644 index 00000000..6ba8b749 --- /dev/null +++ b/specs/365-operations-ui-operator-actions-regression-gate/tasks.md @@ -0,0 +1,243 @@ +# Tasks: Operations UI Operator Actions & Regression Gate + +**Input**: Design documents from `/specs/365-operations-ui-operator-actions-regression-gate/` +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [artifacts/spec365-action-eligibility-matrix.md](./artifacts/spec365-action-eligibility-matrix.md), [artifacts/spec365-regression-gate-matrix.md](./artifacts/spec365-regression-gate-matrix.md) + +**Tests**: Required. Runtime changes must use Pest 4 unit/feature/browser coverage. + +## Repository State Captured During Prep + +- **Branch**: `365-operations-ui-operator-actions-regression-gate` +- **HEAD**: `3ce1cae7 feat: implement restore high risk operation reconciliation (#435)` +- **git status at prep start**: clean on `platform-dev` before branch creation; after Spec Kit branch creation only `specs/365-operations-ui-operator-actions-regression-gate/` was untracked. +- **Spec 364 baseline status**: treated as completed immediate predecessor; implementation must keep Spec364 restore/high-risk tests green. +- **Relevant Operations UI / action files**: + - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` + - `apps/platform/app/Filament/Resources/OperationRunResource.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` + - `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php` + - `apps/platform/app/Support/OperationRunLinks.php` + - `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` + - `apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php` + - `apps/platform/app/Policies/OperationRunPolicy.php` + - `apps/platform/app/Support/OperationRunCapabilityResolver.php` + - `apps/platform/app/Support/Auth/Capabilities.php` + - `apps/platform/app/Services/AdapterRunReconciler.php` + - `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php` + - `apps/platform/app/Services/OperationRunService.php` + +## Implementation Decisions to Record During Close-Out + +- **Implemented actions**: Reconcile for eligible stale adapter-backed OperationRuns; safe related navigation for review, evidence snapshot, review pack/report, inventory affected-family/details, backup details, and restore details; support diagnostics remains secondary/capability-gated. +- **Deferred actions**: generic Retry for all families because no repo-verified generic safe retry/start seam was found; OperationRun Acknowledge because no clean existing acknowledge/note seam exists. +- **Unsupported/forbidden actions**: Force Complete, Mark Succeeded, Retry Restore, Re-execute Restore, Delete, Purge. +- **Coverage artifact decision**: updated `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md` because the existing Operations strategic surface gained visible safe-next-action hierarchy. +- **Spec 358-364 regression result**: targeted Spec359/Spec360 browser/Spec364 plus OperationRun viewer/link/monitoring/resource presentation regressions passed locally; final filter sweep recorded under Phase 13. +- **Mutation scope disclosure result**: Reconcile confirmation discloses TenantPilot-only OperationRun/action metadata and explicitly states no Microsoft tenant retry/change. Retry was not implemented. + +## 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 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 surface test profile (`monitoring-state-page`, `shared-detail-family`) is explicit. +- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel after prerequisites. +- **[Story]**: US1, US2, US3, US4, US5, US6. + +## Phase 1: Setup and Audit (Shared) + +**Purpose**: Confirm repo seams, docs, capabilities, and existing UI before implementation. + +- [x] T001 [P] Re-read `specs/365-operations-ui-operator-actions-regression-gate/spec.md`, `plan.md`, both matrix artifacts, and `.specify/memory/constitution.md` before code changes. +- [x] T002 [P] Audit current OperationRun UI/action code in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`. +- [x] T003 [P] Audit current shared OperationRun UX/link seams in `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `RelatedNavigationResolver`, and `RelatedActionLabelCatalog`. +- [x] T004 [P] Audit action authorization seams in `OperationRunPolicy`, `OperationRunCapabilityResolver`, and `Capabilities` to decide whether existing capabilities cover reconcile/retry/diagnostics. +- [x] T005 [P] Audit existing audit/action metadata seams in `AuditRecorder`, `WorkspaceAuditLogger`, and OperationRun context writes to decide whether `context.operator_actions` is needed. +- [x] T006 [P] Audit safe retry/resume/start seams, including `TenantlessOperationRunViewer::resumeCaptureAction()` and any operation-family start services; record unsupported families in the close-out section above. + +## Phase 2: Foundational Resolver and Contracts (Blocking) + +**Purpose**: Add the single action decision path before UI wiring. + +- [x] T007 [US1] Add a narrow resolver such as `apps/platform/app/Support/Operations/OperationRunActionEligibility.php` that derives primary/secondary/disabled actions from canonical OperationRun truth, user, workspace, and environment scope. +- [x] T008 [US1] Add a small derived result object or array contract for `primary_action`, `secondary_actions`, `disabled_actions`, `disabled_reasons`, and `attention_reason`; keep it non-persisted and avoid new status/outcome enums. +- [x] T009 [US1] Ensure resolver reads canonical `context.dispatch`, `context.reconciliation`, `context.results`, `context.coverage`, and `context.restore` only; do not add new legacy fallback paths. +- [x] T010 [US3] Encode high-risk classification for restore/tenant mutation/destructive/unknown operations so retry/re-execute/force-success always fail closed. +- [x] T011 [US2] Encode reconcile eligibility through `OperationRunReconciliationRegistry` support and current run state/freshness/proof rules. +- [x] T012 [US4] Encode retry eligibility as unavailable by default unless a repo-verified safe non-high-risk retry/start seam exists for the operation family. +- [x] T013 [US5] Encode related action eligibility using canonical related metadata and existing link/navigation resolvers. +- [x] T014 [US6] Encode diagnostics visibility through existing support/operator capability checks. + +## Phase 3: Unit Tests First (Resolver, Presenter, Guard) + +**Purpose**: Lock action decisions before Filament UI changes. + +- [x] T015 [P] [US1] Add `apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php` covering fresh queued, stale queued, stale running, unsupported, missing capability, and cross-workspace inputs. +- [x] T016 [P] [US1] Add `apps/platform/tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php` covering one primary action for review, evidence, review-pack/report, sync partial, backup blocked, restore verification-required, and restore failed. +- [x] T017 [P] [US3] Cover high-risk action guard assertions in the Spec365 unit tests, proving restore/high-risk has no retry, re-execute, force complete, mark succeeded, delete, or purge action. +- [x] T018 [P] [US1] Update OperationRun detail/presentation/browser coverage for outcome-specific decision summaries and raw-leakage sanitization. +- [x] T019 [P] [US4] Add unit coverage proving safe retry returns unavailable/deferred for operation families without a repo-verified retry seam. + +## Phase 4: Existing Operations UI Integration + +**Purpose**: Make the existing surfaces decision-first without creating new pages. + +- [x] T020 [US1] Update `Operations.php` table/list presentation to surface status/outcome, freshness, scope, primary reason, and one resolver-provided primary action or action label. +- [x] T021 [US1] Update `OperationRunResource.php` detail sections to place decision summary, evidence, and next action before technical diagnostics. +- [x] T022 [US1] Update `TenantlessOperationRunViewer.php` header/action groups to consume resolver output for primary and secondary actions. +- [x] T023 [US1] Ensure technical/raw context sections are collapsed and capability-gated by default. +- [x] T024 [US1] Remove or demote any redundant "View" action when row click/detail link already provides the primary inspect model. +- [x] T025 [US3] Ensure high-risk restore detail uses "View restore details" or equivalent safe navigation and never presents retry/re-execute/force-success copy. + +## Phase 5: Safe Reconcile Action + +**Purpose**: Integrate reconciliation only through existing canonical seams. + +- [x] T026 [US2] Add a Filament reconcile action on the appropriate OperationRun detail/header surface using `Action::make(...)->action(...)` and confirmation/description copy where appropriate. +- [x] T078 [US2] Ensure Reconcile action helper text, modal description, preview, or confirmation communicates mutation scope before execution, normally TenantPilot-only OperationRun/action metadata. +- [x] T027 [US2] Enforce server-side authorization and scope in `OperationRunPolicy` or a central action policy/helper; preserve non-member 404 and missing-capability 403 semantics. +- [x] T028 [US2] Execute reconcile through `AdapterRunReconciler` and `OperationRunService`; do not mutate OperationRun state directly in the UI action. +- [x] T029 [US2] Make reconcile idempotent for already reconciled or no-op adapter outcomes. +- [x] T030 [US2] Write audit/action metadata for reconcile with action, run id, workspace, environment, actor, previous/new status/outcome, reason code, timestamp, and no secrets. +- [x] T031 [US2] Add disabled/unavailable reason copy for unsupported, missing capability, cross-scope, insufficient proof, and already terminal/succeeded states. + +## Phase 6: Safe Retry Action or Explicit Deferral + +**Purpose**: Offer retry only where safe and repo-real. + +- [x] T032 [US4] For each candidate non-high-risk operation family, verify whether a safe idempotent retry/start seam exists; document results in the close-out section. +- [x] T033 [US4] No safe generic seam exists; retry action not implemented. +- [x] T079 [US4] No Retry implemented; mutation-scope disclosure not applicable. +- [x] T034 [US4] If no safe seam exists for a family, keep retry unavailable and show a localized disabled/deferred reason. +- [x] T035 [US4] No Retry implemented; no new run-creation flood path added. +- [x] T036 [US4] No Retry implemented; no retry audit metadata required. +- [x] T037 [US4] Ensure completed/succeeded, unknown, restore, tenant mutation, destructive, and high-risk runs never become retryable. + +## Phase 7: Related Domain Actions + +**Purpose**: Let operators open proof without raw context inspection. + +- [x] T038 [US5] Wire related actions through existing `OperationRunLinks` / `RelatedNavigationResolver` where canonical metadata and policy checks pass. +- [x] T039 [US5] Support canonical related actions for review, evidence snapshot, review-pack/report artifact, backup set, sync/details if existing route exists, and restore details. +- [x] T040 [US5] Hide or disable related actions when metadata is absent, capability is missing, or the target is cross-workspace/cross-environment. +- [x] T041 [US5] Ensure no action reads signed URLs, raw payloads, or legacy fallback context from OperationRun context. + +## Phase 8: Localization and Copy + +**Purpose**: Keep labels and summaries customer-safe in EN/DE. + +- [x] T042 [P] [US6] Add EN localization keys in `apps/platform/lang/en/localization.php` for reconcile, retry, related actions, disabled reasons, high-risk guard, and summary states. +- [x] T043 [P] [US6] Add DE localization keys in `apps/platform/lang/de/localization.php` for the same key families. +- [x] T044 [US6] Ensure primary action labels use Verb + Object and avoid implementation-first terms. +- [x] T045 [US6] Ensure customer-readable copy does not expose SQL, Guzzle, stack trace, access token, client secret, queue payload, serialized job, or internal constraint names. + +## Phase 9: Feature Tests + +**Purpose**: Prove direct action behavior, RBAC, scope, audit, and related links. + +- [x] T046 [P] [US2] Add `apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php` for reconcile success, unsupported reconcile denial, and idempotency. +- [x] T047 [P] [US4] Add retry-unavailable tests in focused Spec365 unit/feature coverage; no Retry implemented. +- [x] T048 [P] [US2,US4] Add `apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php` for missing capability and direct action denial. +- [x] T049 [P] [US2,US5] Add cross-workspace and cross-environment denial tests. +- [x] T050 [P] [US6] Add `apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php` for reconcile metadata and no-secret assertions. +- [x] T080 [P] [US6] Extend denied-action coverage to assert failed/denied Reconcile attempts are audit-visible or safely logged without secrets; Retry not implemented. +- [x] T051 [P] [US5] Cover same-scope related links and cross-scope denial in Spec365 unit/feature tests plus existing OperationRun link contract tests. +- [x] T052 [P] [US1] Cover regression matrix states that do not require browser coverage in Spec365 unit tests and existing OperationRun presentation regressions. + +## Phase 10: Browser Smoke + +**Purpose**: Prove the actual Operations UI is decision-first and safe. + +- [x] T053 [US1] Add `apps/platform/tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php` for Operations list decision-first fields and no raw JSON by default. +- [x] T054 [US2] Cover review reconcile state, confirmation modal, and absence of SQL/constraint leakage. +- [x] T055 [US5] Cover review-pack/report and evidence snapshot available states with safe related actions. +- [x] T056 [US1] Cover sync partial and backup blocked summary states. +- [x] T057 [US3] Cover restore verification-required/high-risk state with safe restore details action and absence of Retry Restore, Force Complete, and Mark Succeeded. +- [x] T058 [US6] Cover RBAC-denied user where reconcile/retry actions are unavailable in feature coverage; Retry not implemented. +- [x] T059 [US6] Cover raw leakage guard for `SQLSTATE`, `Guzzle`, `stack trace`, `access token`, `client secret`, `environment_reviews_fingerprint_mutable_unique`, and `serialized job`. +- [x] T060 [US1] Screenshots were not saved because the automated browser assertions were sufficient and no visual defect remained after smoke. + +## Phase 11: Optional Acknowledge Decision + +**Purpose**: Avoid a local "reviewed" substitute unless the repo already supports it cleanly. + +- [x] T061 [US6] Verify whether a clean existing OperationRun acknowledge/note/audit seam exists. +- [x] T062 [US6] No clean seam exists; Acknowledge not implemented. +- [x] T063 [US6] Document Acknowledge as deferred in the close-out section and do not implement local context-only success-like state. + +## Phase 12: Coverage Artifacts and Documentation Close-Out + +**Purpose**: Keep Spec Kit and UI coverage aligned. + +- [x] T064 [P] Update `artifacts/spec365-action-eligibility-matrix.md` if implementation changes eligible actions or disabled reasons. +- [x] T065 [P] Update `artifacts/spec365-regression-gate-matrix.md` with actual test file names/statuses after implementation. +- [x] T066 Update `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md` and related design coverage files if implementation changes layout, action hierarchy, state hierarchy, or screenshots materially; record a no-update rationale only for pattern-compatible action/copy wiring. +- [x] T067 Update this `tasks.md` close-out section with implemented/deferred/unsupported actions and validation outcomes. + +## Phase 13: Validation + +**Purpose**: Run the final Spec365 and regression gate. + +- [x] T068 Run `cd apps/platform && php artisan test --compact --filter=Spec365` or targeted direct Spec365 lanes. +- [x] T069 Run `cd apps/platform && php artisan test --compact --filter=Spec364`. +- [x] T070 Run `cd apps/platform && php artisan test --compact --filter=Spec363`. +- [x] T071 Run `cd apps/platform && php artisan test --compact --filter=Spec362`. +- [x] T072 Run `cd apps/platform && php artisan test --compact --filter=Spec361`. +- [x] T073 Run `cd apps/platform && php artisan test --compact --filter=Spec360`. +- [x] T074 Run `cd apps/platform && php artisan test --compact --filter=Spec359`. +- [x] T075 Run `cd apps/platform && php artisan test --compact --filter=Spec358`. +- [x] T076 Run `cd apps/platform && php vendor/bin/pint --dirty`. +- [x] T077 Run `git diff --check`. +- [x] T081 Run a static scan over changed application files for Livewire v3 APIs: `emit`, `emitTo`, and `dispatchBrowserEvent`. +- [x] T082 Review the final diff for Filament/Tailwind/build asset changes; if any are required, update `spec.md` and `plan.md` before merge. +- [x] T083 If browser lane was not included in the Spec365 filter, run `cd apps/platform && php artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php`. + +## Validation Close-Out + +- `php artisan test --compact --filter=Spec365`: 23 passed, 160 assertions. +- `php artisan test --compact --filter=Spec364`: 10 passed, 59 assertions. +- `php artisan test --compact --filter=Spec363`: no tests found. +- `php artisan test --compact --filter=Spec362`: 27 passed, 238 assertions. +- `php artisan test --compact --filter=Spec361`: 16 passed, 123 assertions. +- `php artisan test --compact --filter=Spec360`: 9 passed, 79 assertions. +- `php artisan test --compact --filter=Spec359`: 25 passed, 150 assertions. +- `php artisan test --compact --filter=Spec358`: no tests found. +- `php vendor/bin/pest tests/Feature/Operations/Spec359OperationRunAdapterReconciliationTest.php tests/Feature/EnvironmentReview/Spec359ReviewComposeReconciliationTest.php tests/Feature/Operations/Spec364RestoreExecuteReconciliationTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php`: 42 passed, 194 assertions. +- `php vendor/bin/pest tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`: 37 passed, 271 assertions. +- `php artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php`: 3 passed, 42 assertions. +- `php artisan test --compact tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php`: 2 passed, 21 assertions. +- `php vendor/bin/pint --dirty`: passed after formatting dirty PHP files. +- Livewire v3 API scan over changed app files for `emit`, `emitTo`, and `dispatchBrowserEvent`: no matches. +- `git diff --check`: passed. +- Final diff review: no Filament panel/provider registration changes, no Tailwind/build asset changes, no new migrations, no env var changes, no queue/cron/storage changes. + +## Dependencies and Ordering + +- Phase 1 must complete before runtime implementation. +- Phase 2 must complete before Phases 4-7. +- Phase 3 should be written before or alongside Phase 2. +- Reconcile and retry feature tests depend on resolver and authorization decisions. +- Browser smoke depends on visible UI wiring and localization. +- Validation runs last. + +## Parallel Execution Examples + +- T002-T006 can run in parallel during audit. +- T015-T019 can run in parallel after resolver contract is sketched. +- T042-T043 can run in parallel with feature test implementation. +- T046-T052 can be split by action family after shared factories/fixtures exist. + +## Notes + +- Do not add a new generic retry framework in this spec. +- Do not add any restore retry/re-execute path. +- Do not add Force Complete, Mark Succeeded, Delete, Purge, or equivalent copy. +- Do not add new top-level Operations navigation. +- Do not expose raw technical diagnostics by default. +- Do not add new Filament/Tailwind assets unless implementation proves they are required and the spec/plan are updated first.