feat: implement operations UI operator actions regression gate
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m44s

This commit is contained in:
Ahmed Darrazi 2026-06-08 03:19:34 +02:00
parent 3ce1cae71e
commit 2a856d2693
25 changed files with 3864 additions and 68 deletions

View File

@ -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,

View File

@ -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<string, mixed> $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<string, mixed>
*/
public function operationActionDecision(): array
{
return app(OperationRunActionEligibility::class)->forRun($this->run, $this->resolveViewerActor());
}
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')

View File

@ -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<string, mixed>|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<int, array{text: string, source: string, normalized: string}> $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<string, mixed>
*/
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<string, mixed>
*/

View File

@ -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();
}
}

View File

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Services\Operations;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use App\Services\AdapterRunReconciler;
use App\Services\Audit\AuditRecorder;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunActionEligibility;
use Illuminate\Support\Facades\Gate;
final class OperationRunOperatorActionService
{
public function __construct(
private readonly AdapterRunReconciler $reconciler,
private readonly OperationRunActionEligibility $eligibility,
private readonly AuditRecorder $auditRecorder,
) {}
/**
* @return array<string, mixed>
*/
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<string, mixed> $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(),
);
}
}

View File

@ -0,0 +1,456 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\Reconciliation\OperationRunReconciliationAdapter;
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
use App\Support\Operations\Reconciliation\ReconciliationResult;
use Illuminate\Support\Facades\Gate;
use Throwable;
final class OperationRunActionEligibility
{
public function __construct(
private readonly OperationRunReconciliationRegistry $reconciliationRegistry,
private readonly OperationRunCapabilityResolver $capabilityResolver,
private readonly CapabilityResolver $tenantCapabilities,
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
) {}
/**
* @return array{
* primary_action: array<string, mixed>|null,
* secondary_actions: list<array<string, mixed>>,
* disabled_actions: list<array<string, mixed>>,
* disabled_reasons: array<string, string>,
* 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<array<string, mixed>> $secondaryActions
* @param array<string, string> $disabledReasons
* @return array{
* primary_action: array<string, mixed>|null,
* secondary_actions: list<array<string, mixed>>,
* disabled_actions: list<array<string, mixed>>,
* disabled_reasons: array<string, string>,
* 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<string, mixed>|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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>|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');
}
}

View File

@ -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.',

View File

@ -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.',

View File

@ -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
<div class="space-y-6">
@ -15,21 +21,21 @@
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="max-w-3xl space-y-2">
<p class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Operations Hub</p>
<h2 class="text-2xl font-semibold text-gray-950 dark:text-white">Execution follow-up workbench</h2>
<h2 class="text-2xl font-semibold text-gray-950 dark:text-white">Execution follow-up</h2>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
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.
</p>
</div>
<div class="flex flex-wrap gap-2 text-sm">
<div class="flex max-w-full flex-col gap-1 text-sm sm:max-w-md lg:items-end lg:text-right">
@if ($landingHierarchy['scope_label'] !== __('localization.shell.all_environments'))
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
<x-filament::badge color="gray" size="sm">
{{ $landingHierarchy['scope_label'] }}
</span>
</x-filament::badge>
@endif
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400" data-testid="operations-hub-scope-body">
{{ $landingHierarchy['scope_body'] }}
</span>
</p>
</div>
</div>
@ -56,22 +62,17 @@
</div>
@if ($selectedOperation !== null)
<span @class([
'inline-flex w-fit items-center rounded-lg px-2.5 py-1 text-xs font-medium',
'bg-warning-50 text-warning-700 dark:bg-warning-500/10 dark:text-warning-300' => ($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,
])>
<x-filament::badge :color="$attentionBadgeColor" size="sm">
{{ $selectedOperation['attention_label'] }}
</span>
</x-filament::badge>
@endif
</div>
@if (! ($workbench['has_attention'] ?? false))
<div class="mt-5 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950/40" data-testid="operations-hub-no-attention-state">
<h3 class="text-base font-semibold text-gray-950 dark:text-white">No operations need attention</h3>
<h3 class="text-base font-semibold text-gray-950 dark:text-white">No operations need follow-up</h3>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
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.
</p>
</div>
@endif
@ -87,12 +88,12 @@
<div class="mt-5 space-y-5">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
<x-filament::badge color="gray" size="sm">
{{ $selectedOperation['identifier'] }}
</span>
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Environment: {{ $selectedOperation['environment'] }}
</span>
</x-filament::badge>
</div>
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">
@ -230,9 +231,9 @@ class="mt-2 w-full"
<section class="space-y-4" data-testid="operations-hub-secondary-history">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-gray-950 dark:text-white">Operations history</h2>
<h2 class="text-lg font-semibold text-gray-950 dark:text-white">Recent runs</h2>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
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.
</p>
</div>
@ -296,7 +297,7 @@ class="mt-2 w-full"
</x-filament::tabs>
<p class="text-sm text-gray-600 dark:text-gray-400">
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.
</p>
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)

View File

@ -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();

View File

@ -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')

View File

@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\RestoreRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->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',
],
]);
}

View File

@ -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')

View File

@ -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');
});

View File

@ -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')

View File

@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\AuditLog;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentMembership;
use App\Models\OperationRun;
use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
use App\Services\Operations\OperationRunOperatorActionService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunActionEligibility;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
it('reconciles a stale review-compose run through the confirmed Filament operator action and audits the mutation in Spec365', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
$fingerprint = app(EnvironmentReviewFingerprint::class)->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,
],
]);
}

View File

@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunActionEligibility;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps fresh active operation runs on detail-only guidance and disables unsafe follow-up actions in Spec365', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$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()->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<string, mixed>
*/
function spec365OperationRunDecision(OperationRun $run, ?User $user): array
{
return app(OperationRunActionEligibility::class)->forRun($run, $user);
}
/**
* @param list<array<string, mixed>> $actions
* @return list<string>
*/
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,
)));
}

View File

@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\ReviewPack;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunActionEligibility;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prefers the related environment review as the primary safe action in Spec365', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
$run = OperationRun::factory()->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<array<string, mixed>> $actions
* @return list<string>
*/
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,
)));
}

View File

@ -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.

View File

@ -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 |

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.