Compare commits

..

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
c43b3a88c9 merge: sync dev into 216-homepage-structure
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 47s
2026-04-19 14:55:13 +02:00
Ahmed Darrazi
097f8e708c feat: implement homepage structure spec 216
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 42s
2026-04-19 14:42:51 +02:00
65 changed files with 745 additions and 3951 deletions

View File

@ -216,8 +216,6 @@ ## Active Technologies
- PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression) - PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression)
- Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests (215-website-core-pages) - Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests (215-website-core-pages)
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages) - Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` (216-provider-dispatch-gate)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure) - Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure)
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure) - Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
@ -254,7 +252,6 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
- 216-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests - 216-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests - 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests
- 214-governance-outcome-compression: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `OperatorExplanationBuilder`, `BaselineSnapshotPresenter`, `BadgeCatalog`, `BadgeRenderer`, existing governance Filament resources/pages, and current Enterprise Detail builders - 214-governance-outcome-compression: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `OperatorExplanationBuilder`, `BaselineSnapshotPresenter`, `BadgeCatalog`, `BadgeRenderer`, existing governance Filament resources/pages, and current Enterprise Detail builders

View File

@ -13,9 +13,7 @@ trait ResolvesPanelTenantContext
{ {
protected static function resolveTenantContextForCurrentPanel(): ?Tenant protected static function resolveTenantContextForCurrentPanel(): ?Tenant
{ {
$request = request(); if (Filament::getCurrentPanel()?->getId() === 'admin') {
if (static::currentPanelId($request) === 'admin') {
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
return $tenant instanceof Tenant ? $tenant : null; return $tenant instanceof Tenant ? $tenant : null;
@ -51,41 +49,4 @@ protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
{ {
return static::resolveTenantContextForCurrentPanelOrFail(); return static::resolveTenantContextForCurrentPanelOrFail();
} }
private static function currentPanelId(mixed $request): ?string
{
$panelId = Filament::getCurrentPanel()?->getId();
if (is_string($panelId) && $panelId !== '') {
return $panelId;
}
$routeName = is_object($request) && method_exists($request, 'route')
? $request->route()?->getName()
: null;
if (is_string($routeName) && $routeName !== '') {
if (str_contains($routeName, '.tenant.')) {
return 'tenant';
}
if (str_contains($routeName, '.admin.')) {
return 'admin';
}
}
$path = is_object($request) && method_exists($request, 'path')
? '/'.ltrim((string) $request->path(), '/')
: null;
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
return 'tenant';
}
if (is_string($path) && str_starts_with($path, '/admin/')) {
return 'admin';
}
return null;
}
} }

View File

@ -521,7 +521,7 @@ public function basisRunSummary(): array
'badgeColor' => null, 'badgeColor' => null,
'runUrl' => null, 'runUrl' => null,
'historyUrl' => null, 'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
]; ];
} }
@ -537,7 +537,7 @@ public function basisRunSummary(): array
'badgeColor' => $badge->color, 'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null, 'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null, 'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
]; ];
} }

View File

@ -122,17 +122,17 @@ public function table(Table $table): Table
TextColumn::make('outcome') TextColumn::make('outcome')
->label('Outcome') ->label('Outcome')
->badge() ->badge()
->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeLabel'])) ->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryLabel)
->color(\Closure::fromCallable([$this, 'reviewOutcomeBadgeColor'])) ->color(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryBadge->color)
->icon(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIcon'])) ->icon(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->icon)
->iconColor(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIconColor'])) ->iconColor(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->iconColor)
->description(\Closure::fromCallable([$this, 'reviewOutcomeDescription'])) ->description(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryReason)
->wrap(), ->wrap(),
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('next_step') TextColumn::make('next_step')
->label('Next step') ->label('Next step')
->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeNextStep'])) ->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->nextActionText)
->wrap(), ->wrap(),
]) ])
->filters([ ->filters([
@ -330,46 +330,13 @@ private function reviewTruth(TenantReview $record, bool $fresh = false): Artifac
: $presenter->forTenantReview($record); : $presenter->forTenantReview($record);
} }
private function reviewOutcomeLabel(TenantReview $record): string
{
return $this->reviewOutcome($record)->primaryLabel;
}
private function reviewOutcomeBadgeColor(TenantReview $record): string
{
return $this->reviewOutcome($record)->primaryBadge->color;
}
private function reviewOutcomeBadgeIcon(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryBadge->icon;
}
private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryReason;
}
private function reviewOutcomeNextStep(TenantReview $record): string
{
return $this->reviewOutcome($record)->nextActionText;
}
private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? $this->reviewTruth($record, true)
: $this->reviewTruth($record);
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh) return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh)
?? $presenter->compressedOutcomeFromEnvelope( ?? $presenter->compressedOutcomeFromEnvelope(
$truth, $this->reviewTruth($record, $fresh),
SurfaceCompressionContext::reviewRegister(), SurfaceCompressionContext::reviewRegister(),
); );
} }

View File

@ -46,7 +46,6 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
@ -2874,22 +2873,65 @@ public function startVerification(): void
); );
} }
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $result->run->getKey()),
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$notification->send(); Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$notification->send(); $reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return; return;
} }
@ -2897,12 +2939,24 @@ public function startVerification(): void
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
$notification->send(); OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
return; return;
} }
$notification->send(); OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
} }
public function refreshVerificationStatus(): void public function refreshVerificationStatus(): void
@ -3002,73 +3056,85 @@ public function startBootstrap(array $operationTypes): void
actor: $user, actor: $user,
expectedVersion: $this->expectedDraftVersion(), expectedVersion: $this->expectedDraftVersion(),
mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void { mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void {
$nextOperationType = $this->nextBootstrapOperationType($draft, $types, (int) $connection->getKey()); $lockedConnection = ProviderConnection::query()
->whereKey($connection->getKey())
->lockForUpdate()
->firstOrFail();
if ($nextOperationType === null) { $activeRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->active()
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
->orderByDesc('id')
->first();
if ($activeRun instanceof OperationRun) {
$result = [ $result = [
'status' => 'already_completed', 'status' => 'scope_busy',
'operation_type' => null, 'run' => $activeRun,
'remaining_types' => [],
]; ];
return; return;
} }
$capability = $this->resolveBootstrapCapability($nextOperationType); $runsService = app(OperationRunService::class);
$bootstrapRuns = [];
$bootstrapCreated = [];
if ($capability === null) { foreach ($types as $operationType) {
throw new RuntimeException("Unsupported bootstrap operation type: {$nextOperationType}"); $definition = $registry->get($operationType);
}
$startResult = app(ProviderOperationStartGate::class)->start( $context = [
tenant: $tenant,
connection: $connection,
operationType: $nextOperationType,
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection, $nextOperationType): void {
$this->dispatchBootstrapJob(
operationType: $nextOperationType,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
run: $run,
);
},
initiator: $user,
extraContext: [
'wizard' => [ 'wizard' => [
'flow' => 'managed_tenant_onboarding', 'flow' => 'managed_tenant_onboarding',
'step' => 'bootstrap', 'step' => 'bootstrap',
], ],
'required_capability' => $capability, 'provider' => $lockedConnection->provider,
'module' => $definition['module'],
'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
], ],
];
$run = $runsService->ensureRunWithIdentity(
tenant: $tenant,
type: $operationType,
identityInputs: [
'provider_connection_id' => (int) $lockedConnection->getKey(),
],
context: $context,
initiator: $user,
); );
if ($run->wasRecentlyCreated) {
$this->dispatchBootstrapJob(
operationType: $operationType,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $lockedConnection->getKey(),
run: $run,
);
}
$bootstrapRuns[$operationType] = (int) $run->getKey();
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
}
$state = $draft->state ?? []; $state = $draft->state ?? [];
$existing = $state['bootstrap_operation_runs'] ?? []; $existing = $state['bootstrap_operation_runs'] ?? [];
$existing = is_array($existing) ? $existing : []; $existing = is_array($existing) ? $existing : [];
if ($startResult->status !== 'scope_busy') { $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns);
$existing[$nextOperationType] = (int) $startResult->run->getKey();
}
$state['bootstrap_operation_runs'] = $existing;
$state['bootstrap_operation_types'] = $types; $state['bootstrap_operation_types'] = $types;
$draft->state = $state; $draft->state = $state;
$draft->current_step = 'bootstrap'; $draft->current_step = 'bootstrap';
$remainingTypes = array_values(array_filter(
$types,
fn (string $candidate): bool => $candidate !== $nextOperationType
&& ! $this->bootstrapOperationSucceeded($draft, $candidate, (int) $connection->getKey()),
));
$result = [ $result = [
'status' => $startResult->status, 'status' => 'started',
'start_result' => $startResult, 'runs' => $bootstrapRuns,
'operation_type' => $nextOperationType, 'created' => $bootstrapCreated,
'run' => $startResult->run,
'remaining_types' => $remainingTypes,
]; ];
}, },
)); ));
@ -3086,36 +3152,26 @@ public function startBootstrap(array $operationTypes): void
throw new RuntimeException('Bootstrap start did not return a run result.'); throw new RuntimeException('Bootstrap start did not return a run result.');
} }
if ($result['status'] === 'already_completed') { if ($result['status'] === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() Notification::make()
->title('Bootstrap already completed') ->title('Another operation is already running')
->body('All selected bootstrap actions have already finished successfully for this provider connection.') ->body('Please wait for the active operation to finish.')
->info() ->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())),
])
->send(); ->send();
return; return;
} }
$operationType = (string) ($result['operation_type'] ?? ''); $bootstrapRuns = $result['runs'];
$startResult = $result['start_result'] ?? null;
$run = $result['run'] ?? null;
if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || ! $run instanceof OperationRun || $operationType === '') {
throw new RuntimeException('Bootstrap start did not return a canonical run result.');
}
$remainingTypes = is_array($result['remaining_types'] ?? null)
? array_values(array_filter($result['remaining_types'], static fn (mixed $value): bool => is_string($value) && $value !== ''))
: [];
if ($this->onboardingSession instanceof TenantOnboardingSession) { if ($this->onboardingSession instanceof TenantOnboardingSession) {
$auditStatus = match ($result['status']) {
'started' => 'success',
'deduped' => 'deduped',
'scope_busy' => 'blocked',
default => 'success',
};
app(WorkspaceAuditLogger::class)->log( app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace, workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value, action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
@ -3125,40 +3181,36 @@ public function startBootstrap(array $operationTypes): void
'tenant_db_id' => (int) $tenant->getKey(), 'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(), 'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_types' => $types, 'operation_types' => $types,
'started_operation_type' => $operationType, 'operation_run_ids' => $bootstrapRuns,
'operation_run_id' => (int) $run->getKey(),
'result' => (string) $result['status'],
], ],
], ],
actor: $user, actor: $user,
status: $auditStatus, status: 'success',
resourceType: 'managed_tenant_onboarding_session', resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(), resourceId: (string) $this->onboardingSession->getKey(),
); );
} }
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $startResult,
blockedTitle: 'Bootstrap action blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $run->getKey()),
scopeBusyTitle: 'Bootstrap action busy',
scopeBusyBody: $remainingTypes !== []
? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.'
: 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.',
);
if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
foreach ($types as $operationType) {
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
$toast = $wasCreated
? OperationUxPresenter::queuedToast($operationType)
: OperationUxPresenter::alreadyQueuedToast($operationType);
if ($runUrl !== null) {
$toast->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
]);
} }
$notification->send(); $toast->send();
if ($remainingTypes !== [] && in_array($result['status'], ['started', 'deduped'], true)) {
Notification::make()
->title('Continue bootstrap after this run finishes')
->body(sprintf('%d additional bootstrap action(s) remain selected for this provider connection.', count($remainingTypes)))
->info()
->send();
} }
} }
@ -3175,65 +3227,17 @@ private function dispatchBootstrapJob(
userId: $userId, userId: $userId,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
operationRun: $run, operationRun: $run,
)->afterCommit(), ),
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch( 'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
tenantId: $tenantId, tenantId: $tenantId,
userId: $userId, userId: $userId,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
operationRun: $run, operationRun: $run,
)->afterCommit(), ),
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"), default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
}; };
} }
/**
* @param array<int, string> $types
*/
private function nextBootstrapOperationType(TenantOnboardingSession $draft, array $types, int $providerConnectionId): ?string
{
foreach ($types as $type) {
if (! $this->bootstrapOperationSucceeded($draft, $type, $providerConnectionId)) {
return $type;
}
}
return null;
}
private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, string $type, int $providerConnectionId): bool
{
$state = is_array($draft->state) ? $draft->state : [];
$runMap = $state['bootstrap_operation_runs'] ?? [];
if (! is_array($runMap)) {
return false;
}
$runId = $runMap[$type] ?? null;
if (! is_numeric($runId)) {
return false;
}
$run = OperationRun::query()->whereKey((int) $runId)->first();
if (! $run instanceof OperationRun) {
return false;
}
$context = is_array($run->context ?? null) ? $run->context : [];
$runProviderConnectionId = is_numeric($context['provider_connection_id'] ?? null)
? (int) $context['provider_connection_id']
: null;
if ($runProviderConnectionId !== $providerConnectionId) {
return false;
}
return $run->status === OperationRunStatus::Completed->value
&& $run->outcome === OperationRunOutcome::Succeeded->value;
}
private function resolveBootstrapCapability(string $operationType): ?string private function resolveBootstrapCapability(string $operationType): ?string
{ {
return match ($operationType) { return match ($operationType) {

View File

@ -182,11 +182,7 @@ public static function table(Table $table): Table
->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color) ->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon) ->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor) ->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor)
->description(static fn (BaselineSnapshot $record): ?string => self::truthHeadline($record)) ->description(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryReason)
->wrap(),
TextColumn::make('coverage_summary')
->label('Coverage')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(), ->wrap(),
TextColumn::make('next_step') TextColumn::make('next_step')
->label('Next step') ->label('Next step')
@ -381,12 +377,6 @@ private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh =
: $presenter->forBaselineSnapshot($snapshot); : $presenter->forBaselineSnapshot($snapshot);
} }
private static function truthHeadline(BaselineSnapshot $record): ?string
{
return self::truthEnvelope($record)->operatorExplanation?->headline
?? self::compressedOutcome($record)->primaryReason;
}
private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);

View File

@ -3,14 +3,16 @@
namespace App\Filament\Resources\EntraGroupResource\Pages; namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Directory\EntraGroupSyncService; use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -53,7 +55,7 @@ protected function getHeaderActions(): array
->label('Sync Groups') ->label('Sync Groups')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('primary')
->action(function (EntraGroupSyncService $syncService): void { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
$tenant = EntraGroupResource::panelTenantContext(); $tenant = EntraGroupResource::panelTenantContext();
@ -61,18 +63,52 @@ protected function getHeaderActions(): array
return; return;
} }
$result = $syncService->startManualSync($tenant, $user); $selectionKey = EntraGroupSelection::allGroupsV1();
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result, // --- Phase 3: Canonical Operation Run Start ---
blockedTitle: 'Directory groups sync blocked', /** @var OperationRunService $opService */
runUrl: OperationRunLinks::view($result->run, $tenant), $opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
); );
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
} OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
$notification->send(); return;
}
// ----------------------------------------------
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: null,
operationRun: $opRun
));
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}) })
) )
->requireCapability(Capabilities::TENANT_SYNC) ->requireCapability(Capabilities::TENANT_SYNC)

View File

@ -691,12 +691,9 @@ private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = fa
private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? static::truthEnvelope($record, true)
: static::truthEnvelope($record);
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh) return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh)
?? $truth->toArray(static::compressedOutcome($record, $fresh)); ?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
} }
private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome

View File

@ -316,13 +316,7 @@ public static function getEloquentQuery(): Builder
public static function resolveScopedRecordOrFail(int|string $key): Model public static function resolveScopedRecordOrFail(int|string $key): Model
{ {
$tenant = static::resolveTenantContextForCurrentPanelOrFail(); return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()->with('lastSeenRun'),
$tenant,
);
} }
public static function getPages(): array public static function getPages(): array

View File

@ -2,30 +2,14 @@
namespace App\Filament\Resources\InventoryItemResource\Pages; namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ViewInventoryItem extends ViewRecord class ViewInventoryItem extends ViewRecord
{ {
use ResolvesPanelTenantContext;
protected static string $resource = InventoryItemResource::class; protected static string $resource = InventoryItemResource::class;
public function mount(int|string $record): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof Tenant) {
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
}
parent::mount($record);
}
protected function resolveRecord(int|string $key): Model protected function resolveRecord(int|string $key): Model
{ {
return InventoryItemResource::resolveScopedRecordOrFail($key); return InventoryItemResource::resolveScopedRecordOrFail($key);

View File

@ -21,7 +21,6 @@
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -1358,23 +1357,20 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
initiator: $user, initiator: $user,
); );
$runUrl = OperationRunLinks::view($result->run, $tenant); if ($result->status === 'scope_busy') {
$extraActions = $result->status === 'started' Notification::make()
? [] ->title('Scope busy')
: [ ->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections') Actions\Action::make('manage_connections')
->label('Manage Provider Connections') ->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)), ->url(static::getUrl('index', tenant: $tenant)),
]; ])
$notification = app(ProviderOperationStartResultPresenter::class)->notification( ->send();
result: $result,
blockedTitle: 'Connection check blocked',
runUrl: $runUrl,
extraActions: $extraActions,
);
if ($result->status === 'scope_busy') {
$notification->send();
return; return;
} }
@ -1382,20 +1378,50 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$notification->send(); OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$notification->send(); $reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Connection check blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$notification->send(); OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
} }
/** /**
@ -1426,14 +1452,17 @@ private static function handleProviderOperationAction(
initiator: $user, initiator: $user,
); );
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: $blockedTitle,
runUrl: OperationRunLinks::view($result->run, $tenant),
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
$notification->send(); Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return; return;
} }
@ -1441,20 +1470,44 @@ private static function handleProviderOperationAction(
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$notification->send(); OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$notification->send(); $reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title($blockedTitle)
->body(implode("\n", $bodyLines))
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$notification->send(); OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder

View File

@ -14,7 +14,6 @@
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -27,8 +26,6 @@
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver; use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
@ -38,7 +35,6 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
@ -1921,53 +1917,6 @@ public static function createRestoreRun(array $data): RestoreRun
->executionSafetySnapshot($tenant, $user, $data) ->executionSafetySnapshot($tenant, $user, $data)
->toArray(); ->toArray();
[$result, $restoreRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if (! in_array($result->status, ['started', 'deduped'], true)) {
throw new \Filament\Support\Exceptions\Halt;
}
if (! $restoreRun instanceof RestoreRun) {
throw new \RuntimeException('Restore execution was accepted without creating a restore run.');
}
return $restoreRun;
}
/**
* @param array<int>|null $selectedItemIds
* @param array<string, mixed> $preview
* @param array<string, mixed> $metadata
* @param array<string, mixed> $groupMapping
* @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun}
*/
private static function startQueuedRestoreExecution(
Tenant $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
array $preview,
array $metadata,
array $groupMapping,
?string $actorEmail,
?string $actorName,
): array {
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(), backupSetId: (int) $backupSet->getKey(),
@ -1975,27 +1924,34 @@ private static function startQueuedRestoreExecution(
groupMapping: $groupMapping, groupMapping: $groupMapping,
); );
$initiator = auth()->user(); $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
$initiator = $initiator instanceof User ? $initiator : null;
$queuedRestoreRun = null; if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
$dispatcher = function (OperationRun $run) use ( $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
$tenant, ->body('Reusing the active restore run.');
$backupSet,
$selectedItemIds, if ($existingOpRun) {
$preview, $toast->actions([
$metadata, Actions\Action::make('view_run')
$groupMapping, ->label('Open operation')
$actorEmail, ->url(OperationRunLinks::view($existingOpRun, $tenant)),
$actorName, ]);
$idempotencyKey, }
&$queuedRestoreRun,
): void { $toast->send();
$queuedRestoreRun = RestoreRun::create([
return $existing;
}
try {
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'operation_run_id' => $run->getKey(),
'requested_by' => $actorEmail, 'requested_by' => $actorEmail,
'is_dry_run' => false, 'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value, 'status' => RestoreRunStatus::Queued->value,
@ -2005,114 +1961,83 @@ private static function startQueuedRestoreExecution(
'metadata' => $metadata, 'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); ]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
$context = is_array($run->context) ? $run->context : []; if ($existing) {
$context['restore_run_id'] = (int) $queuedRestoreRun->getKey(); $existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$run->forceFill(['context' => $context])->save(); $existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return $existing;
}
throw $exception;
}
app(AuditLogger::class)->log( app(AuditLogger::class)->log(
tenant: $tenant, tenant: $tenant,
action: 'restore.queued', action: 'restore.queued',
context: [ context: [
'metadata' => [ 'metadata' => [
'restore_run_id' => $queuedRestoreRun->id, 'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
], ],
], ],
actorEmail: $actorEmail, actorEmail: $actorEmail,
actorName: $actorName, actorName: $actorName,
resourceType: 'restore_run', resourceType: 'restore_run',
resourceId: (string) $queuedRestoreRun->id, resourceId: (string) $restoreRun->id,
status: 'success', status: 'success',
); );
$providerConnectionId = is_numeric($context['provider_connection_id'] ?? null) /** @var OperationRunService $runs */
? (int) $context['provider_connection_id'] $runs = app(OperationRunService::class);
: null; $initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
ExecuteRestoreRunJob::dispatch( $opRun = $runs->ensureRun(
restoreRunId: (int) $queuedRestoreRun->getKey(),
actorEmail: $actorEmail,
actorName: $actorName,
operationRun: $run,
providerConnectionId: $providerConnectionId,
)->afterCommit();
};
if (static::requiresProviderExecution($backupSet, $selectedItemIds)) {
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'restore.execute',
dispatcher: $dispatcher,
initiator: $initiator,
extraContext: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
);
} else {
$run = app(OperationRunService::class)->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'restore.execute', type: 'restore.execute',
identityInputs: [ inputs: [
'idempotency_key' => $idempotencyKey, 'restore_run_id' => (int) $restoreRun->getKey(),
],
context: [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false, 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound', 'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE, 'required_capability' => Capabilities::TENANT_MANAGE,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
], ],
initiator: $initiator, initiator: $initiator,
); );
if ($run->wasRecentlyCreated) { if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$dispatcher($run); $restoreRun->update(['operation_run_id' => $opRun->getKey()]);
$result = ProviderOperationStartResult::started($run, true);
} else {
$result = ProviderOperationStartResult::deduped($run);
}
} }
if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') { ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
$restoreRunId = data_get($result->run->context ?? [], 'restore_run_id');
if (is_numeric($restoreRunId)) { OperationUxPresenter::queuedToast('restore.execute')
$queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first(); ->actions([
} Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
$queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun( return $restoreRun->refresh();
(int) $tenant->getKey(),
$idempotencyKey,
);
}
return [$result, $queuedRestoreRun?->refresh()];
}
/**
* @param array<int>|null $selectedItemIds
*/
private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool
{
$query = $backupSet->items()->select(['id', 'policy_type']);
if (is_array($selectedItemIds) && $selectedItemIds !== []) {
$query->whereIn('id', $selectedItemIds);
}
return $query->get()->contains(function (BackupItem $item): bool {
$restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only';
return $restoreMode !== 'preview-only';
});
} }
/** /**
@ -2527,35 +2452,123 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
'rerun_of_restore_run_id' => $record->id, 'rerun_of_restore_run_id' => $record->id,
]; ];
$metadata['rerun_of_restore_run_id'] = $record->id; $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
[$result, $newRun] = static::startQueuedRestoreExecution( backupSetId: (int) $backupSet->getKey(),
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds, selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping, groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
); );
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
} }
app(ProviderOperationStartResultPresenter::class) $toast->send();
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if ($result->status !== 'started' || ! $newRun instanceof RestoreRun) {
return; return;
} }
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return;
}
throw $exception;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $newRun->id,
'backup_set_id' => $backupSet->id,
'rerun_of_restore_run_id' => $record->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'restore_run.rerun', action: 'restore_run.rerun',
@ -2572,6 +2585,15 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
actorName: $actorName, actorName: $actorName,
); );
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return; return;
} }

View File

@ -399,12 +399,9 @@ private static function truthEnvelope(ReviewPack $record, bool $fresh = false):
private static function truthState(ReviewPack $record, bool $fresh = false): array private static function truthState(ReviewPack $record, bool $fresh = false): array
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? static::truthEnvelope($record, true)
: static::truthEnvelope($record);
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh) return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh)
?? $truth->toArray(static::compressedOutcome($record, $fresh)); ?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
} }
private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome

View File

@ -42,7 +42,6 @@
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
@ -514,16 +513,20 @@ private static function handleVerifyConfigurationAction(
); );
$runUrl = OperationRunLinks::tenantlessView($result->run); $runUrl = OperationRunLinks::tenantlessView($result->run);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $runUrl,
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$notification->send(); Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
@ -531,20 +534,68 @@ private static function handleVerifyConfigurationAction(
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$notification->send(); OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$notification->send(); $actions = [
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Actions\Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$notification->send(); OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
} }
private static function userCanManageAnyTenant(User $user): bool private static function userCanManageAnyTenant(User $user): bool
@ -3268,14 +3319,29 @@ public static function syncRoleDefinitionsAction(): Actions\Action
abort(403); abort(403);
} }
$result = $syncService->startManualSync($record, $user); $opRun = $syncService->startManualSync($record, $user);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Role definitions sync blocked',
runUrl: OperationRunLinks::tenantlessView($result->run),
);
$notification->send(); $runUrl = OperationRunLinks::tenantlessView($opRun);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
OperationUxPresenter::queuedToast('directory_role_definitions.sync')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
}); });
} }
} }

View File

@ -17,7 +17,6 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -72,16 +71,20 @@ public function startVerification(StartVerification $verification): void
); );
$runUrl = OperationRunLinks::tenantlessView($result->run); $runUrl = OperationRunLinks::tenantlessView($result->run);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $runUrl,
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$notification->send(); Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
@ -89,20 +92,72 @@ public function startVerification(StartVerification $verification): void
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$notification->send(); OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$notification->send(); $reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$notification->send(); OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
} }
/** /**

View File

@ -121,10 +121,6 @@ public function handle(Request $request, Closure $next): Response
reason: 'single_membership', reason: 'single_membership',
); );
if ($this->requestHasExplicitTenantContext($request)) {
return $next($request);
}
return $this->redirectViaTenantBranching($workspace, $user); return $this->redirectViaTenantBranching($workspace, $user);
} }
} }
@ -148,10 +144,6 @@ public function handle(Request $request, Closure $next): Response
reason: 'last_used', reason: 'last_used',
); );
if ($this->requestHasExplicitTenantContext($request)) {
return $next($request);
}
return $this->redirectViaTenantBranching($lastWorkspace, $user); return $this->redirectViaTenantBranching($lastWorkspace, $user);
} }
@ -211,17 +203,6 @@ private function isChooserFirstPath(string $path): bool
return in_array($path, ['/admin', '/admin/choose-tenant'], true); return in_array($path, ['/admin', '/admin/choose-tenant'], true);
} }
private function requestHasExplicitTenantContext(Request $request): bool
{
if (filled($request->query('tenant')) || filled($request->query('tenant_id'))) {
return true;
}
$route = $request->route();
return $route?->hasParameter('tenant') && filled($route->parameter('tenant'));
}
private function redirectToChooser(): Response private function redirectToChooser(): Response
{ {
return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']); return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']);

View File

@ -31,7 +31,6 @@ public function __construct(
public string $selectionKey, public string $selectionKey,
public ?string $slotKey = null, public ?string $slotKey = null,
public ?int $runId = null, public ?int $runId = null,
public ?int $providerConnectionId = null,
?OperationRun $operationRun = null ?OperationRun $operationRun = null
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
@ -75,7 +74,7 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
$result = $syncService->sync($tenant, $this->selectionKey, $this->providerConnectionId()); $result = $syncService->sync($tenant, $this->selectionKey);
$terminalStatus = 'succeeded'; $terminalStatus = 'succeeded';
@ -134,16 +133,4 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
} }
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
} }

View File

@ -36,7 +36,6 @@ public function __construct(
public ?string $actorEmail = null, public ?string $actorEmail = null,
public ?string $actorName = null, public ?string $actorName = null,
?OperationRun $operationRun = null, ?OperationRun $operationRun = null,
public ?int $providerConnectionId = null,
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
@ -161,15 +160,12 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
); );
try { try {
$providerConnectionId = $this->providerConnectionId();
$restoreService->executeForRun( $restoreService->executeForRun(
restoreRun: $restoreRun, restoreRun: $restoreRun,
tenant: $tenant, tenant: $tenant,
backupSet: $backupSet, backupSet: $backupSet,
actorEmail: $this->actorEmail, actorEmail: $this->actorEmail,
actorName: $this->actorName, actorName: $this->actorName,
providerConnectionId: $providerConnectionId,
); );
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
@ -211,16 +207,4 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
throw $throwable; throw $throwable;
} }
} }
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
} }

View File

@ -31,7 +31,6 @@ class SyncRoleDefinitionsJob implements ShouldQueue
*/ */
public function __construct( public function __construct(
public int $tenantId, public int $tenantId,
public ?int $providerConnectionId = null,
?OperationRun $operationRun = null, ?OperationRun $operationRun = null,
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
@ -70,7 +69,7 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
$result = $syncService->sync($tenant, $this->providerConnectionId()); $result = $syncService->sync($tenant);
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -125,16 +124,4 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
} }
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
} }

View File

@ -4,16 +4,13 @@
namespace App\Livewire; namespace App\Livewire;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Enums\RelationshipType; use App\Support\Enums\RelationshipType;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@ -238,7 +235,7 @@ private function resolveInventoryItem(): InventoryItem
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId); $inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
$tenant = $this->resolveCurrentTenant(); $tenant = $this->resolveCurrentTenant();
if (! $this->canViewInventoryItem($inventoryItem, $tenant)) { if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
throw new NotFoundHttpException; throw new NotFoundHttpException;
} }
@ -249,10 +246,6 @@ private function resolveCurrentTenant(): Tenant
{ {
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
$tenant = app(WorkspaceContext::class)->rememberedTenant(request());
}
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
throw new NotFoundHttpException; throw new NotFoundHttpException;
} }
@ -260,21 +253,6 @@ private function resolveCurrentTenant(): Tenant
return $tenant; return $tenant;
} }
private function canViewInventoryItem(InventoryItem $inventoryItem, Tenant $tenant): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$capabilityResolver = app(CapabilityResolver::class);
return (int) $inventoryItem->tenant_id === (int) $tenant->getKey()
&& $capabilityResolver->isMember($user, $tenant)
&& $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
private function normalizeRelationshipType(mixed $value): ?string private function normalizeRelationshipType(mixed $value): ?string
{ {
if (! is_string($value) || $value === '' || $value === 'all') { if (! is_string($value) || $value === '' || $value === 'all') {

View File

@ -41,14 +41,6 @@ private function assignmentReferences(): array
$tenant = rescue(fn () => Tenant::current(), null); $tenant = rescue(fn () => Tenant::current(), null);
if (! $tenant instanceof Tenant) {
$this->version->loadMissing('tenant');
$tenant = $this->version->tenant instanceof Tenant
? $this->version->tenant
: null;
}
$groupIds = []; $groupIds = [];
$sourceNames = []; $sourceNames = [];

View File

@ -225,7 +225,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage summary', title: 'Coverage summary',
view: 'filament.infolists.entries.baseline-snapshot-summary-table', view: 'filament.infolists.entries.baseline-snapshot-summary-table',
viewData: ['rows' => $rendered->summaryRows], viewData: ['rows' => $rendered->summaryRows],
description: $rendered->fidelitySummary,
emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'), emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
), ),
$factory->viewSection( $factory->viewSection(
@ -263,7 +262,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage', title: 'Coverage',
items: [ items: [
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge), $factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Fidelity mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount), $factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount), $factory->keyFact('Captured items', $capturedItemCount),
], ],

View File

@ -10,10 +10,8 @@
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class EntraGroupSyncService class EntraGroupSyncService
@ -22,38 +20,38 @@ public function __construct(
private readonly GraphClientInterface $graph, private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
private readonly ProviderOperationStartGate $providerStarts,
) {} ) {}
public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult public function startManualSync(Tenant $tenant, User $user): OperationRun
{ {
$selectionKey = EntraGroupSelection::allGroupsV1(); $selectionKey = EntraGroupSelection::allGroupsV1();
return $this->providerStarts->start( /** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
connection: null, type: 'entra_group_sync',
operationType: 'entra_group_sync', identityInputs: ['selection_key' => $selectionKey],
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void { context: [
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null) 'selection_key' => $selectionKey,
? (int) $run->context['provider_connection_id'] 'trigger' => 'manual',
: null; ],
initiator: $user,
);
EntraGroupSyncJob::dispatch( if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey, selectionKey: $selectionKey,
slotKey: null, slotKey: null,
runId: null, runId: null,
providerConnectionId: $providerConnectionId, operationRun: $opRun,
operationRun: $run, ));
)->afterCommit();
}, return $opRun;
initiator: $user,
extraContext: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
'required_capability' => Capabilities::TENANT_SYNC,
],
);
} }
/** /**
@ -69,7 +67,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
* error_summary:?string * error_summary:?string
* } * }
*/ */
public function sync(Tenant $tenant, string $selectionKey, ?int $providerConnectionId = null): array public function sync(Tenant $tenant, string $selectionKey): array
{ {
$nowUtc = CarbonImmutable::now('UTC'); $nowUtc = CarbonImmutable::now('UTC');
@ -107,9 +105,7 @@ public function sync(Tenant $tenant, string $selectionKey, ?int $providerConnect
$errorSummary = null; $errorSummary = null;
$errorCount = 0; $errorCount = 0;
$options = $providerConnectionId !== null $options = $this->graphOptionsResolver->resolveForTenant($tenant);
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
: $this->graphOptionsResolver->resolveForTenant($tenant);
$useQuery = $query; $useQuery = $query;
$nextPath = $path; $nextPath = $path;

View File

@ -10,10 +10,8 @@
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class RoleDefinitionsSyncService class RoleDefinitionsSyncService
@ -22,35 +20,36 @@ public function __construct(
private readonly GraphClientInterface $graph, private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
private readonly ProviderOperationStartGate $providerStarts,
) {} ) {}
public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult public function startManualSync(Tenant $tenant, User $user): OperationRun
{ {
$selectionKey = 'role_definitions_v1'; $selectionKey = 'role_definitions_v1';
return $this->providerStarts->start( /** @var OperationRunService $opService */
tenant: $tenant, $opService = app(OperationRunService::class);
connection: null,
operationType: 'directory_role_definitions.sync',
dispatcher: function (OperationRun $run) use ($tenant): void {
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
? (int) $run->context['provider_connection_id']
: null;
SyncRoleDefinitionsJob::dispatch( $opRun = $opService->ensureRunWithIdentity(
tenantId: (int) $tenant->getKey(), tenant: $tenant,
providerConnectionId: $providerConnectionId, type: 'directory_role_definitions.sync',
operationRun: $run, identityInputs: ['selection_key' => $selectionKey],
)->afterCommit(); context: [
},
initiator: $user,
extraContext: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,
'trigger' => 'manual', 'trigger' => 'manual',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
initiator: $user,
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new SyncRoleDefinitionsJob(
tenantId: (int) $tenant->getKey(),
operationRun: $opRun,
));
return $opRun;
} }
/** /**
@ -66,7 +65,7 @@ public function startManualSync(Tenant $tenant, User $user): ProviderOperationSt
* error_summary:?string * error_summary:?string
* } * }
*/ */
public function sync(Tenant $tenant, ?int $providerConnectionId = null): array public function sync(Tenant $tenant): array
{ {
$nowUtc = CarbonImmutable::now('UTC'); $nowUtc = CarbonImmutable::now('UTC');
@ -104,9 +103,7 @@ public function sync(Tenant $tenant, ?int $providerConnectionId = null): array
$errorSummary = null; $errorSummary = null;
$errorCount = 0; $errorCount = 0;
$options = $providerConnectionId !== null $options = $this->graphOptionsResolver->resolveForTenant($tenant);
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
: $this->graphOptionsResolver->resolveForTenant($tenant);
$useQuery = $query; $useQuery = $query;
$nextPath = $path; $nextPath = $path;

View File

@ -236,7 +236,6 @@ public function executeForRun(
BackupSet $backupSet, BackupSet $backupSet,
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
?int $providerConnectionId = null,
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
@ -267,7 +266,6 @@ public function executeForRun(
actorName: $actorName, actorName: $actorName,
groupMapping: $restoreRun->group_mapping ?? [], groupMapping: $restoreRun->group_mapping ?? [],
existingRun: $restoreRun, existingRun: $restoreRun,
providerConnectionId: $providerConnectionId,
); );
} }
@ -288,7 +286,6 @@ public function execute(
?string $actorName = null, ?string $actorName = null,
array $groupMapping = [], array $groupMapping = [],
?RestoreRun $existingRun = null, ?RestoreRun $existingRun = null,
?int $providerConnectionId = null,
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
@ -300,7 +297,7 @@ public function execute(
$baseGraphOptions = []; $baseGraphOptions = [];
if (! $dryRun) { if (! $dryRun) {
$connection = $this->resolveProviderConnection($tenant, $providerConnectionId); $connection = $this->resolveProviderConnection($tenant);
$tenantIdentifier = (string) $connection->entra_tenant_id; $tenantIdentifier = (string) $connection->entra_tenant_id;
$baseGraphOptions = $this->providerGateway()->graphOptions($connection); $baseGraphOptions = $this->providerGateway()->graphOptions($connection);
} }
@ -2913,23 +2910,9 @@ private function buildScopeTagsForVersion(
]; ];
} }
private function resolveProviderConnection(Tenant $tenant, ?int $providerConnectionId = null): ProviderConnection private function resolveProviderConnection(Tenant $tenant): ProviderConnection
{ {
if ($providerConnectionId !== null) {
$connection = ProviderConnection::query()->find($providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is not configured.',
));
}
$resolution = $this->providerConnections()->validateConnection($tenant, 'microsoft', $connection);
} else {
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft'); $resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
}
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) { if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
return $resolution->connection; return $resolution->connection;

View File

@ -72,7 +72,7 @@ public static function unresolvedFoundation(string $label, string $foundationTyp
public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self
{ {
$maskedId = static::mask($targetId); $maskedId = static::mask($targetId);
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], panel: 'tenant', tenant: $tenant) : null; $url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], tenant: $tenant) : null;
return new self( return new self(
targetLabel: $label, targetLabel: $label,

View File

@ -2,9 +2,7 @@
namespace App\Services\Providers; namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Providers\ProviderReasonCodes;
final class MicrosoftGraphOptionsResolver final class MicrosoftGraphOptionsResolver
{ {
@ -30,37 +28,4 @@ public function resolveForTenant(Tenant $tenant, array $overrides = []): array
return $this->gateway->graphOptions($resolution->connection, $overrides); return $this->gateway->graphOptions($resolution->connection, $overrides);
} }
/**
* @return array<string, mixed>
*/
public function resolveForConnection(Tenant $tenant, int|ProviderConnection $connection, array $overrides = []): array
{
$providerConnection = $connection instanceof ProviderConnection
? $connection
: ProviderConnection::query()->find($connection);
if (! $providerConnection instanceof ProviderConnection) {
throw ProviderConfigurationRequiredException::forTenant(
tenant: $tenant,
provider: 'microsoft',
resolution: ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'The selected provider connection could not be found.',
),
);
}
$resolution = $this->connections->validateConnection($tenant, 'microsoft', $providerConnection);
if (! $resolution->resolved || $resolution->connection === null) {
throw ProviderConfigurationRequiredException::forTenant(
tenant: $tenant,
provider: 'microsoft',
resolution: $resolution,
);
}
return $this->gateway->graphOptions($resolution->connection, $overrides);
}
} }

View File

@ -2,13 +2,12 @@
namespace App\Services\Providers; namespace App\Services\Providers;
use App\Support\Auth\Capabilities;
use InvalidArgumentException; use InvalidArgumentException;
final class ProviderOperationRegistry final class ProviderOperationRegistry
{ {
/** /**
* @return array<string, array{provider: string, module: string, label: string, required_capability: string}> * @return array<string, array{provider: string, module: string, label: string}>
*/ */
public function all(): array public function all(): array
{ {
@ -17,37 +16,16 @@ public function all(): array
'provider' => 'microsoft', 'provider' => 'microsoft',
'module' => 'health_check', 'module' => 'health_check',
'label' => 'Provider connection check', 'label' => 'Provider connection check',
'required_capability' => Capabilities::PROVIDER_RUN,
], ],
'inventory_sync' => [ 'inventory_sync' => [
'provider' => 'microsoft', 'provider' => 'microsoft',
'module' => 'inventory', 'module' => 'inventory',
'label' => 'Inventory sync', 'label' => 'Inventory sync',
'required_capability' => Capabilities::PROVIDER_RUN,
], ],
'compliance.snapshot' => [ 'compliance.snapshot' => [
'provider' => 'microsoft', 'provider' => 'microsoft',
'module' => 'compliance', 'module' => 'compliance',
'label' => 'Compliance snapshot', 'label' => 'Compliance snapshot',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'restore.execute' => [
'provider' => 'microsoft',
'module' => 'restore',
'label' => 'Restore execution',
'required_capability' => Capabilities::TENANT_MANAGE,
],
'entra_group_sync' => [
'provider' => 'microsoft',
'module' => 'directory_groups',
'label' => 'Directory groups sync',
'required_capability' => Capabilities::TENANT_SYNC,
],
'directory_role_definitions.sync' => [
'provider' => 'microsoft',
'module' => 'directory_role_definitions',
'label' => 'Role definitions sync',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
]; ];
} }
@ -58,7 +36,7 @@ public function isAllowed(string $operationType): bool
} }
/** /**
* @return array{provider: string, module: string, label: string, required_capability: string} * @return array{provider: string, module: string, label: string}
*/ */
public function get(string $operationType): array public function get(string $operationType): array
{ {

View File

@ -244,15 +244,6 @@ private function resolveRequiredCapability(string $operationType, array $extraCo
return trim((string) $extraContext['required_capability']); return trim((string) $extraContext['required_capability']);
} }
if ($this->registry->isAllowed($operationType)) {
$definition = $this->registry->get($operationType);
$requiredCapability = $definition['required_capability'] ?? null;
if (is_string($requiredCapability) && trim($requiredCapability) !== '') {
return trim($requiredCapability);
}
}
if ($this->registry->isAllowed($operationType)) { if ($this->registry->isAllowed($operationType)) {
return Capabilities::PROVIDER_RUN; return Capabilities::PROVIDER_RUN;
} }

View File

@ -86,10 +86,6 @@ public function handle(Request $request, Closure $next): Response
! $resolvedContext->hasTenant() ! $resolvedContext->hasTenant()
&& $this->adminPathRequiresTenantSelection($path) && $this->adminPathRequiresTenantSelection($path)
) { ) {
if ($this->requestHasExplicitTenantHint($request)) {
abort(404);
}
return redirect()->route('filament.admin.pages.choose-tenant'); return redirect()->route('filament.admin.pages.choose-tenant');
} }
@ -236,21 +232,12 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer; return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer;
} }
private function requestHasExplicitTenantHint(Request $request): bool
{
return filled($request->query('tenant')) || filled($request->query('tenant_id'));
}
private function adminPathRequiresTenantSelection(string $path): bool private function adminPathRequiresTenantSelection(string $path): bool
{ {
if (! str_starts_with($path, '/admin/')) { if (! str_starts_with($path, '/admin/')) {
return false; return false;
} }
if (str_starts_with($path, '/admin/finding-exceptions/queue')) { return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
} }
} }

View File

@ -119,7 +119,7 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
$pageCategory = $this->pageCategory($request); $pageCategory = $this->pageCategory($request);
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory); $routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request); $sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
$workspace = $this->resolveWorkspaceForPageCategory($routeTenantCandidate, $pageCategory, $request); $workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
$workspaceSource = match (true) { $workspaceSource = match (true) {
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace', $workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
@ -185,19 +185,6 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
$recoveryReason ??= $queryHintTenant['reason']; $recoveryReason ??= $queryHintTenant['reason'];
if ($this->requiresStrictQueryTenantHintResolution($request)) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'invalid_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
recoveryAction: 'abort_not_found',
recoveryReason: $recoveryReason ?? 'missing_tenant',
);
}
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace); $tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
if ($tenant instanceof Tenant) { if ($tenant instanceof Tenant) {
@ -269,7 +256,7 @@ private function resolveValidatedFilamentTenant(
} }
$pageCategory ??= $this->pageCategory($request); $pageCategory ??= $this->pageCategory($request);
$workspace ??= $this->resolveWorkspaceForPageCategory($tenant, $pageCategory, $request); $workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) { if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
return $tenant; return $tenant;
@ -301,18 +288,6 @@ private function resolveValidatedRouteTenant(
return ['tenant' => $tenant, 'reason' => null]; return ['tenant' => $tenant, 'reason' => null];
} }
private function resolveWorkspaceForPageCategory(
?Tenant $tenant,
TenantPageCategory $pageCategory,
?Request $request = null,
): ?Workspace {
return match ($pageCategory) {
TenantPageCategory::TenantScopedEvidence => $this->workspaceContext->currentWorkspace($request),
TenantPageCategory::TenantBound => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
default => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
};
}
private function resolveValidatedQueryHintTenant( private function resolveValidatedQueryHintTenant(
?Request $request, ?Request $request,
Workspace $workspace, Workspace $workspace,
@ -374,30 +349,6 @@ private function resolveQueryTenantHint(?Request $request = null): ?Tenant
return null; return null;
} }
private function hasExplicitQueryTenantHint(?Request $request = null): bool
{
return filled($request?->query('tenant')) || filled($request?->query('tenant_id'));
}
private function requiresStrictQueryTenantHintResolution(?Request $request = null): bool
{
if (! $this->hasExplicitQueryTenantHint($request)) {
return false;
}
$path = '/'.ltrim((string) $request?->path(), '/');
if (! str_starts_with($path, '/admin/')) {
return false;
}
if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
}
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
{ {
if ($tenantIdentifier instanceof Tenant) { if ($tenantIdentifier instanceof Tenant) {

View File

@ -53,34 +53,6 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
->duration(self::QUEUED_TOAST_DURATION_MS); ->duration(self::QUEUED_TOAST_DURATION_MS);
} }
/**
* Canonical provider-backed dedupe feedback using the shared start vocabulary.
*/
public static function alreadyRunningToast(string $operationType): FilamentNotification
{
$operationLabel = OperationCatalog::label($operationType);
return FilamentNotification::make()
->title("{$operationLabel} already running")
->body('A matching operation is already queued or running. Open the operation for progress and next steps.')
->info()
->duration(self::QUEUED_TOAST_DURATION_MS);
}
/**
* Canonical provider-backed protected-scope conflict feedback.
*/
public static function scopeBusyToast(
string $title = 'Scope busy',
string $body = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
): FilamentNotification {
return FilamentNotification::make()
->title($title)
->body($body)
->warning()
->duration(self::QUEUED_TOAST_DURATION_MS);
}
/** /**
* Terminal DB notification payload. * Terminal DB notification payload.
* *

View File

@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\OperationRunLinks;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonPresenter;
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification;
final class ProviderOperationStartResultPresenter
{
public function __construct(
private readonly ReasonPresenter $reasonPresenter,
) {}
/**
* @param array<int, Action> $extraActions
*/
public function notification(
ProviderOperationStartResult $result,
string $blockedTitle,
string $runUrl,
array $extraActions = [],
string $scopeBusyTitle = 'Scope busy',
string $scopeBusyBody = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
): FilamentNotification {
$notification = match ($result->status) {
'started' => OperationUxPresenter::queuedToast((string) $result->run->type),
'deduped' => OperationUxPresenter::alreadyRunningToast((string) $result->run->type),
'scope_busy' => OperationUxPresenter::scopeBusyToast($scopeBusyTitle, $scopeBusyBody),
'blocked' => FilamentNotification::make()
->title($blockedTitle)
->body(implode("\n", $this->blockedBodyLines($result)))
->warning(),
default => OperationUxPresenter::queuedToast((string) $result->run->type),
};
return $notification->actions($this->actionsFor($result, $runUrl, $extraActions));
}
/**
* @param array<int, Action> $extraActions
* @return array<int, Action>
*/
private function actionsFor(ProviderOperationStartResult $result, string $runUrl, array $extraActions): array
{
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
if ($result->status === 'blocked') {
$nextStep = $this->firstNextStep($result);
if ($nextStep instanceof NextStepOption && $nextStep->kind === 'link' && is_string($nextStep->destination) && trim($nextStep->destination) !== '') {
$actions[] = Action::make('next_step_0')
->label($nextStep->label)
->url($nextStep->destination);
}
}
return [...$actions, ...$extraActions];
}
/**
* @return array<int, string>
*/
private function blockedBodyLines(ProviderOperationStartResult $result): array
{
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
return $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
}
private function firstNextStep(ProviderOperationStartResult $result): ?NextStepOption
{
$nextSteps = is_array($result->run->context['next_steps'] ?? null)
? $result->run->context['next_steps']
: [];
$storedNextStep = NextStepOption::collect($nextSteps)[0] ?? null;
if ($storedNextStep instanceof NextStepOption) {
return $storedNextStep;
}
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
return $reasonEnvelope?->firstNextStep();
}
}

View File

@ -123,11 +123,11 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant); $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
$basisRunUrl = OperationRunLinks::view($run, $tenant); $basisRunUrl = OperationRunLinks::view($run, $tenant);
$inventoryItemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant); $inventoryItemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
$searchPage = visit(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant)); $searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
$searchPage $searchPage
->waitForText('Inventory Items') ->waitForText('Inventory Items')

View File

@ -1,77 +0,0 @@
<?php
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Jobs\EntraGroupSyncJob;
use App\Jobs\SyncRoleDefinitionsJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('starts directory group sync with explicit provider connection context', function (): void {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroups::class)
->callAction('sync_groups');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'entra_group_sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($run): bool {
return $job->providerConnectionId === ($run?->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($run);
});
});
it('blocks role definitions sync before queue when no provider connection is available', function (): void {
Bus::fake();
$tenant = Tenant::factory()->create([
'app_client_id' => 'client-123',
'app_client_secret' => 'secret',
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$result = app(RoleDefinitionsSyncService::class)->startManualSync($tenant, $user);
expect($result->status)->toBe('blocked');
expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
Bus::assertNotDispatched(SyncRoleDefinitionsJob::class);
});

View File

@ -38,13 +38,11 @@
expect($opRun)->not->toBeNull(); expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued'); expect($opRun?->status)->toBe('queued');
expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all'); expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all');
expect($opRun?->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool { Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey() return $job->tenantId === (int) $tenant->getKey()
&& $job->selectionKey === 'groups-v1:all' && $job->selectionKey === 'groups-v1:all'
&& $job->runId === null && $job->runId === null
&& $job->providerConnectionId === ($opRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun instanceof OperationRun && $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); && (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
}); });

View File

@ -1,8 +1,8 @@
<?php <?php
use App\Jobs\EntraGroupSyncJob; use App\Jobs\EntraGroupSyncJob;
use App\Models\OperationRun;
use App\Services\Directory\EntraGroupSyncService; use App\Services\Directory\EntraGroupSyncService;
use App\Services\Providers\ProviderOperationStartResult;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
it('starts a manual group sync by creating a run and dispatching a job', function () { it('starts a manual group sync by creating a run and dispatching a job', function () {
@ -12,19 +12,14 @@
$service = app(EntraGroupSyncService::class); $service = app(EntraGroupSyncService::class);
$result = $service->startManualSync($tenant, $user); $run = $service->startManualSync($tenant, $user);
$run = $result->run;
expect($result)->toBeInstanceOf(ProviderOperationStartResult::class) expect($run)->toBeInstanceOf(OperationRun::class)
->and($result->status)->toBe('started');
expect($run)
->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->tenant_id)->toBe($tenant->getKey())
->and($run->user_id)->toBe($user->getKey()) ->and($run->user_id)->toBe($user->getKey())
->and($run->type)->toBe('entra_group_sync') ->and($run->type)->toBe('entra_group_sync')
->and($run->status)->toBe('queued') ->and($run->status)->toBe('queued')
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all') ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all');
->and($run->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class); Queue::assertPushed(EntraGroupSyncJob::class);
}); });

View File

@ -66,8 +66,8 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
$basisRun = seedInventoryCoverageBasis($tenant); $basisRun = seedInventoryCoverageBasis($tenant);
$itemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant); $itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
$coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant); $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
$this->actingAs($user) $this->actingAs($user)
->get($itemsUrl) ->get($itemsUrl)
@ -102,7 +102,7 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->get(InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant)) ->get(InventoryCoverage::getUrl(tenant: $tenant))
->assertOk() ->assertOk()
->assertSee('No current coverage basis') ->assertSee('No current coverage basis')
->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.') ->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.')

View File

@ -1,157 +0,0 @@
<?php
declare(strict_types=1);
use Tests\Support\OpsUx\SourceFileScanner;
function providerDispatchGateSlice(string $source, string $startAnchor, ?string $endAnchor = null): string
{
$start = strpos($source, $startAnchor);
expect($start)->not->toBeFalse();
$start = is_int($start) ? $start : 0;
if ($endAnchor === null) {
return substr($source, $start);
}
$end = strpos($source, $endAnchor, $start + strlen($startAnchor));
expect($end)->not->toBeFalse();
$end = is_int($end) ? $end : strlen($source);
return substr($source, $start, $end - $start);
}
it('keeps first-slice route-bounded provider starts on canonical gate-owned entry points', function (): void {
$root = SourceFileScanner::projectRoot();
$checks = [
[
'file' => $root.'/app/Services/Verification/StartVerification.php',
'start' => 'public function providerConnectionCheckForTenant(',
'end' => 'public function providerConnectionCheckUsingConnection(',
'required' => [
'return $this->providers->start(',
"operationType: 'provider.connection.check'",
"'required_capability' => Capabilities::PROVIDER_RUN",
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Verification/StartVerification.php',
'start' => 'public function providerConnectionCheckUsingConnection(',
'end' => 'private function dispatchConnectionHealthCheck(',
'required' => [
'$result = $this->providers->start(',
"operationType: 'provider.connection.check'",
'ProviderVerificationStatus::Pending',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Directory/EntraGroupSyncService.php',
'start' => 'public function startManualSync(',
'end' => 'public function sync(',
'required' => [
'return $this->providerStarts->start(',
"operationType: 'entra_group_sync'",
'EntraGroupSyncJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Directory/RoleDefinitionsSyncService.php',
'start' => 'public function startManualSync(',
'end' => 'public function sync(',
'required' => [
'return $this->providerStarts->start(',
"operationType: 'directory_role_definitions.sync'",
'SyncRoleDefinitionsJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/RestoreRunResource.php',
'start' => 'private static function startQueuedRestoreExecution(',
'end' => 'private static function detailPreviewState(',
'required' => [
'app(ProviderOperationStartGate::class)->start(',
"operationType: 'restore.execute'",
'ExecuteRestoreRunJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php',
'start' => "Action::make('sync_groups')",
'end' => '->requireCapability(Capabilities::TENANT_SYNC)',
'required' => [
'$syncService->startManualSync($tenant, $user)',
'ProviderOperationStartResultPresenter::class',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/TenantResource.php',
'start' => 'public static function syncRoleDefinitionsAction(): Actions\\Action',
'end' => null,
'required' => [
'$result = $syncService->startManualSync($record, $user);',
'ProviderOperationStartResultPresenter::class',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
'start' => 'private static function handleCheckConnectionAction(',
'end' => 'private static function handleProviderOperationAction(',
'required' => [
'$verification->providerConnectionCheck(',
'ProviderOperationStartResultPresenter::class',
'OperationRunLinks::view($result->run, $tenant)',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
'start' => 'private static function handleProviderOperationAction(',
'end' => 'public static function getEloquentQuery(): Builder',
'required' => [
'$result = $gate->start(',
'ProviderOperationStartResultPresenter::class',
'OperationRunLinks::view($result->run, $tenant)',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
'start' => 'public function startBootstrap(array $operationTypes): void',
'end' => 'private function dispatchBootstrapJob(',
'required' => [
'app(ProviderOperationStartGate::class)->start(',
'ProviderOperationStartResultPresenter::class',
"'bootstrap_operation_types'",
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
];
foreach ($checks as $check) {
$source = SourceFileScanner::read($check['file']);
$slice = providerDispatchGateSlice($source, $check['start'], $check['end']);
foreach ($check['required'] as $needle) {
expect($slice)->toContain($needle);
}
foreach ($check['forbidden'] as $needle) {
expect($slice)->not->toContain($needle);
}
}
})->group('surface-guard');

View File

@ -136,7 +136,6 @@
->and($families->has('policy-resource-admin-search-parity'))->toBeTrue() ->and($families->has('policy-resource-admin-search-parity'))->toBeTrue()
->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue() ->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue()
->and($families->has('workspace-settings-slice-management'))->toBeTrue() ->and($families->has('workspace-settings-slice-management'))->toBeTrue()
->and($families->has('provider-dispatch-gate-coverage'))->toBeTrue()
->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue() ->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue()
->and($families->has('browser-smoke'))->toBeTrue(); ->and($families->has('browser-smoke'))->toBeTrue();
@ -160,7 +159,7 @@
expect($familyBudgets)->not->toBeEmpty() expect($familyBudgets)->not->toBeEmpty()
->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds']) ->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds'])
->and(collect($familyBudgets)->pluck('familyId')->all()) ->and(collect($familyBudgets)->pluck('familyId')->all())
->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'provider-dispatch-gate-coverage', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management'); ->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management');
}); });
it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void { it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void {

View File

@ -5,24 +5,11 @@
use App\Models\InventoryLink; use App\Models\InventoryLink;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Str; use Illuminate\Support\Str;
function inventoryItemAdminSession(Tenant $tenant): array
{
return [
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
];
}
it('shows zero-state when no dependencies and shows missing badge when applicable', function () { it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -32,7 +19,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
// Zero state // Zero state
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->withSession($session)->get($url)->assertOk()->assertSee('No dependencies found'); $this->get($url)->assertOk()->assertSee('No dependencies found');
// Create a missing edge and assert badge appears // Create a missing edge and assert badge appears
InventoryLink::factory()->create([ InventoryLink::factory()->create([
@ -48,7 +35,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
], ],
]); ]);
$this->withSession($session)->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertSee('Missing') ->assertSee('Missing')
->assertSee('Last known: Ghost Target'); ->assertSee('Last known: Ghost Target');
@ -57,8 +44,6 @@ function inventoryItemAdminSession(Tenant $tenant): array
it('renders native dependency controls in place instead of a GET apply workflow', function () { it('renders native dependency controls in place instead of a GET apply workflow', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -97,7 +82,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$this->withSession($session)->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertSee('Direction') ->assertSee('Direction')
->assertSee('Inbound') ->assertSee('Inbound')
@ -110,8 +95,6 @@ function inventoryItemAdminSession(Tenant $tenant): array
it('ignores legacy relationship query state while preserving visible target safety', function () { it('ignores legacy relationship query state while preserving visible target safety', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -143,7 +126,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin') $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin')
.'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by'; .'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by';
$this->withSession($session)->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertSee('Scoped Target') ->assertSee('Scoped Target')
->assertSee('Assigned Target'); ->assertSee('Assigned Target');
@ -152,8 +135,6 @@ function inventoryItemAdminSession(Tenant $tenant): array
it('does not show edges from other tenants (tenant isolation)', function () { it('does not show edges from other tenants (tenant isolation)', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -175,7 +156,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->withSession($session)->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertDontSee('Other Tenant Edge'); ->assertDontSee('Other Tenant Edge');
}); });
@ -183,8 +164,6 @@ function inventoryItemAdminSession(Tenant $tenant): array
it('shows masked identifier when last known name is missing', function () { it('shows masked identifier when last known name is missing', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -206,7 +185,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->withSession($session)->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertSee('Group (external): 123456…'); ->assertSee('Group (external): 123456…');
}); });
@ -214,8 +193,6 @@ function inventoryItemAdminSession(Tenant $tenant): array
it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () { it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -277,7 +254,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->withSession($session)->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertSee('Scope Tag: Finance (6…)') ->assertSee('Scope Tag: Finance (6…)')
->assertSee('Assignment Filter: VIP Devices (62fb77…)') ->assertSee('Assignment Filter: VIP Devices (62fb77…)')
@ -287,8 +264,6 @@ function inventoryItemAdminSession(Tenant $tenant): array
it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () { it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
$graph = \Mockery::mock(GraphClientInterface::class); $graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldNotReceive('listPolicies'); $graph->shouldNotReceive('listPolicies');
@ -326,7 +301,7 @@ function inventoryItemAdminSession(Tenant $tenant): array
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->withSession($session)->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertSee('Scope Tag: Finance'); ->assertSee('Scope Tag: Finance');
}); });

View File

@ -992,7 +992,7 @@
expect($session->completed_at)->not->toBeNull(); expect($session->completed_at)->not->toBeNull();
}); });
it('starts one selected bootstrap action at a time and persists the remaining selections', function (): void { it('starts selected bootstrap actions as separate operation runs and dispatches their jobs', function (): void {
Bus::fake(); Bus::fake();
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
@ -1047,105 +1047,17 @@
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->count())->toBe(1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs)->toBeArray();
expect($runs['inventory_sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
});
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$verificationRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-bootstrap-next-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->firstOrFail();
$inventoryRun->forceFill([
'status' => 'completed',
'outcome' => 'succeeded',
])->save();
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1); Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot') ->whereIn('type', ['inventory_sync', 'compliance.snapshot'])
->count())->toBe(1); ->count())->toBe(2);
$session->refresh(); $session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? []; $runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs)->toBeArray();
expect($runs['inventory_sync'] ?? null)->toBeInt(); expect($runs['inventory_sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeInt(); expect($runs['compliance.snapshot'] ?? null)->toBeInt();
}); });

View File

@ -104,6 +104,6 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(route('admin.evidence.overview')) ->get(route('admin.evidence.overview'))
->assertOk() ->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA, panel: 'tenant')) ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA))
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied, panel: 'tenant')); ->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied));
}); });

View File

@ -397,8 +397,8 @@
->assertSuccessful() ->assertSuccessful()
->assertSee('Complete onboarding') ->assertSee('Complete onboarding')
->assertDontSee('Activate tenant') ->assertDontSee('Activate tenant')
->assertDontSeeText('Restore tenant') ->assertDontSee('Restore')
->assertDontSeeText('Archive tenant') ->assertDontSee('Archive')
->assertSee('After completion'); ->assertSee('After completion');
}); });

View File

@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Notifications\OperationRunCompleted;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function makeProviderBlockedRun(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => 999999,
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
'blocked_by' => 'provider_preflight',
'target_scope' => [
'entra_tenant_id' => $tenant->tenant_id,
],
],
]);
return [$user, $tenant, $run];
}
it('reuses translated provider-backed blocker language on the canonical run detail page', function (): void {
[$user, , $run] = makeProviderBlockedRun();
$this->actingAs($user);
$component = Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
$banner = $component->instance()->blockedExecutionBanner();
expect($banner)->not->toBeNull();
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
expect($banner['body'] ?? null)->toContain('Provider connection required');
expect($banner['body'] ?? null)->toContain('usable provider connection');
});
it('keeps terminal notification reason translation aligned with the canonical provider-backed run detail', function (): void {
[$user, , $run] = makeProviderBlockedRun();
$this->actingAs($user);
$banner = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->instance()
->blockedExecutionBanner();
$payload = (new OperationRunCompleted($run))->toDatabase($user);
expect($payload['title'] ?? null)->toBe('Inventory sync blocked by prerequisite');
expect($payload['body'] ?? null)->toContain('Provider connection required');
expect($payload['body'] ?? null)->toContain('usable provider connection');
expect($payload['reason_translation']['operator_label'] ?? null)->toContain('Provider connection required');
expect($payload['reason_translation']['short_explanation'] ?? null)->toContain('usable provider connection');
expect($payload['diagnostic_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
expect($banner['body'] ?? '')->toContain('Provider connection required');
});
it('keeps the same blocked provider-backed vocabulary for system-initiated runs on canonical detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'initiator_name' => 'Scheduled automation',
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => 999999,
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
'blocked_by' => 'provider_preflight',
'target_scope' => [
'entra_tenant_id' => $tenant->tenant_id,
],
],
]);
$banner = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->instance()
->blockedExecutionBanner();
expect($banner)->not->toBeNull();
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
expect($banner['body'] ?? null)->toContain('Provider connection required');
expect($banner['body'] ?? null)->toContain('usable provider connection');
});

View File

@ -1,90 +0,0 @@
<?php
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\OperationRunLinks;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('returns scope-busy semantics when a different provider operation is already active for the same connection', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'consent_status' => 'granted',
]);
$component = Livewire::test(ListProviderConnections::class);
$component->callTableAction('inventory_sync', $connection);
$component->callTableAction('compliance_snapshot', $connection);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->first();
expect($inventoryRun)->not->toBeNull();
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
Queue::assertPushed(ProviderComplianceSnapshotJob::class, 0);
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($inventoryRun, $tenant));
});
it('blocks provider connection checks with shared guidance and does not enqueue work', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'consent_status' => 'granted',
'is_default' => true,
]);
Livewire::test(ListProviderConnections::class)
->callTableAction('check_connection', $connection);
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->outcome)->toBe('blocked');
expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing);
Queue::assertNothingPushed();
Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class);
});

View File

@ -1,163 +0,0 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RestoreRunStatus;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
function seedRestoreStartContext(bool $withProviderConnection = true): array
{
$tenant = Tenant::create([
'tenant_id' => fake()->uuid(),
'name' => 'Restore Tenant',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
if ($withProviderConnection) {
ensureDefaultProviderConnection($tenant, 'microsoft');
}
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => fake()->uuid(),
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'metadata' => [
'displayName' => 'Backup Policy',
],
]);
$user = User::factory()->create([
'email' => 'restore@example.com',
'name' => 'Restore Operator',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
return [$tenant, $backupSet, $backupItem, $user];
}
it('starts restore execution with explicit provider connection context', function (): void {
Bus::fake();
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext();
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Restore Tenant',
])
->call('create')
->assertHasNoFormErrors();
$restoreRun = RestoreRun::query()->latest('id')->first();
$operationRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'restore.execute')
->latest('id')
->first();
expect($restoreRun)->not->toBeNull();
expect($restoreRun?->status)->toBe(RestoreRunStatus::Queued->value);
expect($operationRun)->not->toBeNull();
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($restoreRun, $operationRun): bool {
return $job->restoreRunId === (int) $restoreRun?->getKey()
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($operationRun);
});
});
it('blocks restore reruns before queue when no provider connection is available', function (): void {
Bus::fake();
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext(withProviderConnection: false);
$this->actingAs($user);
$run = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'failed',
'is_dry_run' => false,
'requested_items' => [$backupItem->id],
'group_mapping' => [],
]);
Livewire::test(ListRestoreRuns::class)
->callTableAction('rerun', $run);
expect(RestoreRun::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
$operationRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'restore.execute')
->latest('id')
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun?->outcome)->toBe('blocked');
expect($operationRun?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
Bus::assertNotDispatched(ExecuteRestoreRunJob::class);
});

View File

@ -189,12 +189,10 @@
expect($operationRun)->not->toBeNull(); expect($operationRun)->not->toBeNull();
expect($operationRun?->status)->toBe('queued'); expect($operationRun?->status)->toBe('queued');
expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey()); expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey());
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0)); expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0));
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool { Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool {
return $job->restoreRunId === (int) $run->getKey() return $job->restoreRunId === (int) $run->getKey()
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun instanceof OperationRun && $job->operationRun instanceof OperationRun
&& $job->operationRun->getKey() === $operationRun?->getKey(); && $job->operationRun->getKey() === $operationRun?->getKey();
}); });

View File

@ -3,7 +3,6 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Providers\ProviderOperationStartResult;
use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Directory\RoleDefinitionsSyncService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -21,31 +20,19 @@
'status' => 'active', 'status' => 'active',
]); ]);
[$user, $tenant] = createUserWithTenant( [$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
tenant: $tenant,
role: 'owner',
fixtureProfile: 'credential-enabled',
);
$service = app(RoleDefinitionsSyncService::class); $service = app(RoleDefinitionsSyncService::class);
$result = $service->startManualSync($tenant, $user); $run = $service->startManualSync($tenant, $user);
expect($result)->toBeInstanceOf(ProviderOperationStartResult::class);
expect($result->status)->toBe('started');
$run = $result->run;
expect($run->type)->toBe('directory_role_definitions.sync'); expect($run->type)->toBe('directory_role_definitions.sync');
expect($run->context['provider_connection_id'] ?? null)->toBeInt();
$url = OperationRunLinks::tenantlessView($run); $url = OperationRunLinks::tenantlessView($run);
expect($url)->toContain('/admin/operations/'); expect($url)->toContain('/admin/operations/');
Bus::assertDispatched( Bus::assertDispatched(
App\Jobs\SyncRoleDefinitionsJob::class, App\Jobs\SyncRoleDefinitionsJob::class,
fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run)
&& $job->providerConnectionId === ($run->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($run)
); );
}); });

View File

@ -1,83 +0,0 @@
<?php
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('starts tenant verification with explicit connection context and dedupes repeat starts', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('is_default', true)
->firstOrFail();
$component = Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]);
$component->callAction('verify');
$component->callAction('verify');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->context)->toMatchArray([
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1);
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
});
it('blocks tenant verification before queue when no provider connection is available', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->callAction('verify');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->outcome)->toBe('blocked');
expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
Queue::assertNothingPushed();
});

View File

@ -1,167 +0,0 @@
<?php
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('returns scope-busy semantics for onboarding verification when another run is active for the same connection scope', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user);
$tenantGuid = '10101010-1010-1010-1010-101010101010';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $tenantGuid,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Provider start test',
]);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(0);
Bus::assertNotDispatched(ProviderConnectionHealthCheckJob::class);
});
it('serializes onboarding bootstrap so only one selected provider-backed action starts at a time', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user);
$tenantGuid = '20202020-2020-2020-2020-202020202020';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $tenantGuid,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Provider start test',
]);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$verificationRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-bootstrap-provider-start-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->firstOrFail();
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
$inventoryRun->forceFill([
'status' => 'completed',
'outcome' => 'succeeded',
])->save();
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(1);
Bus::assertDispatchedTimes(ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(ProviderComplianceSnapshotJob::class, 1);
});

View File

@ -71,7 +71,7 @@
->assertDontSee('/admin/t/'.$tenantInOther->external_id, false); ->assertDontSee('/admin/t/'.$tenantInOther->external_id, false);
}); });
it('uses the routed tenant workspace on tenant routes when workspace context is missing', function (): void { it('returns 404 on tenant routes when workspace context is missing', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
@ -93,7 +93,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(TenantDashboard::getUrl(tenant: $tenant)) ->get(TenantDashboard::getUrl(tenant: $tenant))
->assertSuccessful(); ->assertNotFound();
}); });
it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void { it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void {

View File

@ -535,34 +535,6 @@ public static function families(): array
'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'], 'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'],
'validationStatus' => 'guarded', 'validationStatus' => 'guarded',
], ],
[
'familyId' => 'provider-dispatch-gate-coverage',
'classificationId' => 'surface-guard',
'purpose' => 'Keep the first-slice provider-backed start hosts on canonical ProviderOperationStartGate-owned entry points instead of route-bounded direct-dispatch bypasses.',
'currentLaneId' => 'heavy-governance',
'targetLaneId' => 'heavy-governance',
'selectors' => [
[
'selectorType' => 'group',
'selectorValue' => 'surface-guard',
'selectorRole' => 'include',
'sourceOfTruth' => 'pest-group',
'rationale' => 'The bypass guard spans multiple route-bounded start surfaces and belongs with heavy governance checks.',
],
[
'selectorType' => 'file',
'selectorValue' => 'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php',
'selectorRole' => 'inventory-only',
'sourceOfTruth' => 'manifest',
'rationale' => 'Canonical guard for first-slice provider-backed dispatch-gate coverage.',
],
],
'hotspotFiles' => [
'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php',
],
'costSignals' => ['route-bounded surface scan', 'start-host governance breadth', 'gate adoption regression detection'],
'validationStatus' => 'guarded',
],
[ [
'familyId' => 'policy-resource-admin-search-parity', 'familyId' => 'policy-resource-admin-search-parity',
'classificationId' => 'discovery-heavy', 'classificationId' => 'discovery-heavy',
@ -1203,16 +1175,6 @@ public static function budgetTargets(): array
'lifecycleState' => 'documented', 'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable heavy-governance runs', 'reviewCadence' => 'tighten after two stable heavy-governance runs',
], ],
[
'budgetId' => 'family-provider-dispatch-gate-coverage',
'targetType' => 'family',
'targetId' => 'provider-dispatch-gate-coverage',
'thresholdSeconds' => 20,
'baselineSource' => 'measured-current-suite',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable heavy-governance runs',
],
[ [
'budgetId' => 'family-policy-resource-admin-search-parity', 'budgetId' => 'family-policy-resource-admin-search-parity',
'targetType' => 'family', 'targetType' => 'family',

View File

@ -5,7 +5,6 @@
use App\Models\ProviderCredential; use App\Models\ProviderCredential;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -158,123 +157,3 @@
expect($result->run->context['verification_report'] ?? null)->toBeArray(); expect($result->run->context['verification_report'] ?? null)->toBeArray();
expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue(); expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
}); });
it('starts restore execution with explicit provider connection binding and operation capability metadata', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => 'restore-entra-tenant-id',
'consent_status' => 'granted',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'restore.execute',
dispatcher: function (OperationRun $run) use (&$dispatched): void {
$dispatched++;
expect($run->type)->toBe('restore.execute');
},
);
expect($dispatched)->toBe(1);
expect($result->status)->toBe('started');
expect($result->dispatched)->toBeTrue();
$run = $result->run->fresh();
expect($run)->not->toBeNull();
expect($run->context)->toMatchArray([
'provider_connection_id' => (int) $connection->getKey(),
'required_capability' => Capabilities::TENANT_MANAGE,
'target_scope' => [
'entra_tenant_id' => 'restore-entra-tenant-id',
],
]);
});
it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => 'directory-entra-tenant-id',
'consent_status' => 'granted',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'entra_group_sync',
dispatcher: function (OperationRun $run) use (&$dispatched): void {
$dispatched++;
expect($run->type)->toBe('entra_group_sync');
},
);
expect($dispatched)->toBe(1);
expect($result->status)->toBe('started');
expect($result->dispatched)->toBeTrue();
$run = $result->run->fresh();
expect($run)->not->toBeNull();
expect($run->context)->toMatchArray([
'provider_connection_id' => (int) $connection->getKey(),
'required_capability' => Capabilities::TENANT_SYNC,
'target_scope' => [
'entra_tenant_id' => 'directory-entra-tenant-id',
],
]);
});
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'consent_status' => 'granted',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$blocking = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'compliance.snapshot',
dispatcher: function () use (&$dispatched): void {
$dispatched++;
},
);
expect($dispatched)->toBe(0);
expect($result->status)->toBe('scope_busy');
expect($result->run->getKey())->toBe($blocking->getKey());
});

View File

@ -1,149 +0,0 @@
<?php
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds queued notifications for accepted provider-backed starts', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'queued',
'context' => [
'provider_connection_id' => 123,
],
]);
$presenter = app(ProviderOperationStartResultPresenter::class);
$notification = $presenter->notification(
result: ProviderOperationStartResult::started($run, true),
blockedTitle: 'Verification blocked',
runUrl: OperationRunLinks::tenantlessView($run),
extraActions: [
Action::make('manage_connections')
->label('Manage Provider Connections')
->url('/provider-connections'),
],
);
$actions = collect($notification->getActions());
expect($notification->getTitle())->toBe('Provider connection check queued')
->and($notification->getBody())->toBe('Queued for execution. Open the operation for progress and next steps.')
->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([
'view_run',
'manage_connections',
])
->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([
'Open operation',
'Manage Provider Connections',
]);
});
it('builds already-running notifications for deduped provider-backed starts', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'context' => [
'provider_connection_id' => 123,
],
]);
$presenter = app(ProviderOperationStartResultPresenter::class);
$notification = $presenter->notification(
result: ProviderOperationStartResult::deduped($run),
blockedTitle: 'Verification blocked',
runUrl: OperationRunLinks::tenantlessView($run),
);
expect($notification->getTitle())->toBe('Provider connection check already running')
->and($notification->getBody())->toBe('A matching operation is already queued or running. Open the operation for progress and next steps.');
});
it('builds scope-busy notifications for conflicting provider-backed starts', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'context' => [
'provider_connection_id' => 123,
],
]);
$presenter = app(ProviderOperationStartResultPresenter::class);
$notification = $presenter->notification(
result: ProviderOperationStartResult::scopeBusy($run),
blockedTitle: 'Inventory sync blocked',
runUrl: OperationRunLinks::tenantlessView($run),
);
expect($notification->getTitle())->toBe('Scope busy')
->and($notification->getBody())->toBe('Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.');
});
it('builds blocked notifications from translated reason detail and first next step', function (): void {
$tenant = Tenant::factory()->create();
$reasonEnvelope = new ReasonResolutionEnvelope(
internalCode: 'provider_consent_missing',
operatorLabel: 'Admin consent required',
shortExplanation: 'Grant admin consent for this provider connection before retrying.',
actionability: 'prerequisite_missing',
nextSteps: [
NextStepOption::link('Grant admin consent', '/provider-connections/1/consent'),
NextStepOption::link('Open provider settings', '/provider-connections/1'),
],
);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => 'provider_consent_missing',
'reason_translation' => $reasonEnvelope->toArray(),
],
]);
$presenter = app(ProviderOperationStartResultPresenter::class);
$notification = $presenter->notification(
result: ProviderOperationStartResult::blocked($run),
blockedTitle: 'Verification blocked',
runUrl: OperationRunLinks::tenantlessView($run),
extraActions: [
Action::make('manage_connections')
->label('Manage Provider Connections')
->url('/provider-connections'),
],
);
$actions = collect($notification->getActions());
expect($notification->getTitle())->toBe('Verification blocked')
->and($notification->getBody())->toBe("Admin consent required\nGrant admin consent for this provider connection before retrying.\nNext step: Grant admin consent.")
->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([
'view_run',
'next_step_0',
'manage_connections',
])
->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([
'Open operation',
'Grant admin consent',
'Manage Provider Connections',
]);
});

View File

@ -1432,37 +1432,6 @@ ### Run Log Inspect Affordance Alignment
- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement. - **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement.
- **Priority**: medium - **Priority**: medium
### Selected-Record Monitoring Host Alignment
- **Type**: workflow compression
- **Source**: enterprise UX review 2026-04-19 — Finding Exceptions Queue and Audit Log selected-record monitoring surfaces
- **Problem**: Specs 193 and 198 correctly established the semantics for `queue_workbench` and `selected_record_monitoring`, but they intentionally stopped at action hierarchy and page-state transport. The remaining gap is the active review host shape. `FindingExceptionsQueue` and `AuditLog` both preserve selection via query parameter and `inspect_action`, yet the current host experience still sits awkwardly between a list page, an inline expanded detail block, and a modal-style inspect affordance. That is technically valid, but it does not read as an enterprise-grade workbench. Operators get shareable URLs and refresh-safe state, but not a clearly expressed review mode with one deliberate place for context, next step, and close/return behavior.
- **Why it matters**: Enterprise operators working through queues or history need one of two unmistakable behaviors: either remain in a stable workbench where list context and active record review coexist intentionally, or leave the list for a canonical detail route with explicit return continuity. The current halfway pattern preserves state better than a slide-over, but it still weakens scanability, makes the active review lane feel bolted on, and leaves too much room for future local variations across monitoring surfaces.
- **Proposed direction**:
- Define two allowed enterprise host models for `selected_record_monitoring` surfaces:
- **Split-pane workbench**: the list, filters, and queue context remain continuously visible while the selected record occupies a dedicated persistent review pane
- **Canonical detail route**: the list remains list-first, and inspect opens a standalone detail page with explicit back/return continuity and optional preserved filter state
- Allow **quick-peek overlays** only as optional preview affordances, never as the sole canonical inspect or deep-link contract
- Add host-selection criteria so surfaces choose deliberately between split-pane and canonical detail route instead of drifting into full-page inline "focused lane above the table" patterns
- Pilot the rule on `FindingExceptionsQueue` and `AuditLog`, keeping current query-param addressability while upgrading the actual review host ergonomics
- Codify close/back/new-tab/reload semantics and invalid-selection fallback per host model so URL durability and review ergonomics are aligned rather than accidental
- **Smallest enterprise-capable version**: Limit the first slice to the two already-real `selected_record_monitoring` surfaces in Monitoring: `FindingExceptionsQueue` and `AuditLog`. The spec should choose and implement one clear host model per surface, document the decision rule, and stop there. No generic pane framework, no broad monitoring IA rewrite, and no rollout to unrelated list/detail pages.
- **Explicit non-goals**: Not a full Monitoring redesign, not a new modal framework, not a replacement for Spec 198 page-state semantics, not a generic shared-detail engine, not a broad action-surface retrofit outside `selected_record_monitoring`, and not a rewrite of finding or audit domain truth.
- **Permanent complexity imported**: One small host-pattern contract for `selected_record_monitoring`, explicit decision criteria for split-pane vs canonical detail route, focused regression coverage for two surfaces, and a small amount of new vocabulary around host model choice. No new persisted truth, no new provider/runtime architecture, and no new generalized UI platform are justified.
- **Why now**: The product already has at least two real consumers of the same selected-record monitoring pattern, and one of them is visible enough that the UX gap is now obvious. Leaving the gap open means future monitoring surfaces will keep re-solving the same question locally, and the currently correct page-state work will continue to feel less enterprise than it should.
- **Why not local**: A one-off polish pass on `FindingExceptionsQueue` would not answer what `AuditLog` should do, nor would it define when a selected-record monitoring surface should stay list-first versus move to canonical detail. The missing artifact is not just layout polish; it is the host decision rule for a small but real surface family.
- **Approval class**: Workflow Compression
- **Red flags triggered**: One red flag: this introduces a cross-surface host-model rule. The scope must stay bounded to the already-real `selected_record_monitoring` family and must not grow into a general monitoring-shell framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
- **Acceptance points**:
- Each `selected_record_monitoring` surface declares one deliberate host model instead of expressing active review as an ad hoc inline expansion
- Deep links, refresh, and invalid-selection fallback remain stable after the host upgrade
- Operators can keep queue/history context while reviewing a record, or return to it predictably when the chosen host model uses a dedicated detail route
- Close, back, related drilldowns, and "open in full detail" semantics become consistent enough that selected-record monitoring feels like a product pattern instead of a local layout choice
- **Dependencies**: Spec 193 (`monitoring-action-hierarchy`), Spec 198 (`monitoring-page-state`), and the existing Monitoring page-state guards already in the repo
- **Related specs / candidates**: Spec 197 (`shared-detail-contract`), Action Surface Contract v1.1, Admin Visual Language Canon, Record Page Header Discipline & Contextual Navigation (for return semantics only; not a direct dependency)
- **Priority**: medium
### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention ### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention
- **Type**: foundation - **Type**: foundation
- **Source**: admin UI consistency analysis 2026-03-17 - **Source**: admin UI consistency analysis 2026-03-17

View File

@ -6,10 +6,10 @@
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"scripts": { "scripts": {
"dev": "corepack pnpm dev:platform && (corepack pnpm --filter @tenantatlas/platform dev &) && corepack pnpm dev:website", "dev": "bash ./scripts/dev-workspace",
"dev:platform": "./scripts/platform-sail up -d", "dev:platform": "bash ./scripts/dev-platform",
"dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev", "dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev",
"build:platform": "corepack pnpm --filter @tenantatlas/platform build", "build:platform": "./scripts/platform-sail pnpm build",
"build:website": "corepack pnpm --filter @tenantatlas/website build" "build:website": "corepack pnpm --filter @tenantatlas/website build"
} }
} }

View File

@ -1,36 +0,0 @@
# Specification Quality Checklist: Provider-Backed Action Preflight and Dispatch Gate Unification
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-19
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed on 2026-04-19.
- The chosen spec is aligned with the active / near-term roadmap lane and the highest-priority unspecced qualified candidate in spec-candidates.md.
- No clarification questions were needed; the candidate already had sufficient scope, sequencing, and boundary detail for planning readiness.

View File

@ -1,419 +0,0 @@
openapi: 3.1.0
info:
title: Provider Dispatch Gate Start Contract
version: 1.0.0
description: >-
Internal reference contract for the operator-triggered provider-backed start
surfaces covered by Spec 216. The real implementation remains Filament and
Livewire HTML actions. The vendor media types below document the structured
start-result and accepted-run payloads that must be derivable before
rendering. This is not a public API commitment.
paths:
/admin/t/{tenant}/provider-actions/{operation}/start:
post:
summary: Start a tenant-scoped provider-backed operation
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: operation
in: path
required: true
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderStartRequest'
responses:
'200':
description: Rendered Livewire action response for the start attempt
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-start-outcome+json:
schema:
$ref: '#/components/schemas/ProviderStartOutcome'
'403':
description: Tenant member lacks the required capability for the operation
'404':
description: Tenant is not visible because workspace or tenant entitlement is missing
/admin/provider-connections/{connection}/actions/{operation}/start:
post:
summary: Start a provider-connection-scoped operation
parameters:
- name: connection
in: path
required: true
schema:
type: integer
- name: operation
in: path
required: true
schema:
type: string
responses:
'200':
description: Rendered Livewire action response for the connection-scoped start attempt
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-start-outcome+json:
schema:
$ref: '#/components/schemas/ProviderStartOutcome'
'403':
description: Viewer is in scope but lacks the required capability for the action
'404':
description: Provider connection is not visible because entitlement is missing
/admin/t/{tenant}/restore-runs/{restoreRun}/execute:
post:
summary: Execute a restore through the canonical provider start gate
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: restoreRun
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered restore execute action response
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-start-outcome+json:
schema:
$ref: '#/components/schemas/ProviderStartOutcome'
'403':
description: Tenant member lacks restore execution capability after membership is established
'404':
description: Restore run is not visible because entitlement is missing
/admin/t/{tenant}/directory/groups/sync:
post:
summary: Start directory groups sync
parameters:
- name: tenant
in: path
required: true
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/DirectoryGroupsSyncRequest'
responses:
'200':
description: Rendered directory groups sync action response
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-start-outcome+json:
schema:
$ref: '#/components/schemas/ProviderStartOutcome'
'403':
description: Tenant member lacks sync capability after membership is established
'404':
description: Tenant is not visible because entitlement is missing
/admin/t/{tenant}/directory/role-definitions/sync:
post:
summary: Start role definitions sync
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Rendered role definitions sync action response
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-start-outcome+json:
schema:
$ref: '#/components/schemas/ProviderStartOutcome'
'403':
description: Tenant member lacks the required capability after membership is established
'404':
description: Tenant is not visible because entitlement is missing
/admin/onboarding/{session}/provider-actions/{operation}/start:
post:
summary: Start an onboarding provider verification action
parameters:
- name: session
in: path
required: true
schema:
type: integer
- name: operation
in: path
required: true
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingProviderStartRequest'
responses:
'200':
description: Rendered onboarding verification action response
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-start-outcome+json:
schema:
$ref: '#/components/schemas/ProviderStartOutcome'
'403':
description: Workspace member lacks the required capability after scope is established
'404':
description: Onboarding session is not visible because entitlement is missing
/admin/onboarding/{session}/provider-bootstrap/start:
post:
summary: Start onboarding bootstrap work under sequential protected-scope admission
parameters:
- name: session
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingBootstrapStartRequest'
responses:
'200':
description: Rendered onboarding bootstrap action response
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.onboarding-bootstrap-start-outcome+json:
schema:
$ref: '#/components/schemas/OnboardingBootstrapStartOutcome'
'403':
description: Workspace member lacks the required capability after scope is established
'404':
description: Onboarding session is not visible because entitlement is missing
/admin/operations/{run}:
get:
summary: Canonical provider-backed operation run detail
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered Monitoring → Operations run detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.provider-backed-run-detail+json:
schema:
$ref: '#/components/schemas/ProviderBackedRunDetail'
'403':
description: Viewer is in scope but lacks permission for related actions
'404':
description: Run is not visible because workspace or tenant entitlement is missing
components:
schemas:
ProviderStartRequest:
type: object
properties:
providerConnectionId:
type: integer
nullable: true
sourceSurface:
type: string
targetContext:
type: object
additionalProperties: true
DirectoryGroupsSyncRequest:
type: object
properties:
selectionKey:
type: string
default: all_groups_v1
providerConnectionId:
type: integer
nullable: true
OnboardingProviderStartRequest:
type: object
properties:
providerConnectionId:
type: integer
nullable: true
step:
type: string
OnboardingBootstrapStartRequest:
type: object
required:
- providerConnectionId
- selectedOperations
properties:
providerConnectionId:
type: integer
selectedOperations:
type: array
minItems: 1
items:
type: string
sourceSurface:
type: string
default: onboarding.bootstrap
ProviderStartOutcome:
type: object
description: >-
Canonical start-result shape derived before queue admission for every
route-bounded provider-backed start covered by Spec 216.
required:
- status
- operationType
- operatorMessage
properties:
status:
type: string
enum:
- accepted
- deduped
- scope_busy
- blocked
operationType:
type: string
operatorVerb:
type: string
operatorTarget:
type: string
operatorMessage:
type: string
shortReason:
type: string
nullable: true
providerConnection:
$ref: '#/components/schemas/ProviderConnectionContext'
run:
$ref: '#/components/schemas/RunReference'
nextSteps:
type: array
items:
$ref: '#/components/schemas/NextStep'
actions:
type: array
items:
$ref: '#/components/schemas/ActionLink'
OnboardingBootstrapStartOutcome:
allOf:
- $ref: '#/components/schemas/ProviderStartOutcome'
- type: object
properties:
acceptedOperation:
type: string
nullable: true
pendingOperations:
type: array
items:
type: string
ProviderConnectionContext:
type: object
properties:
id:
type: integer
provider:
type: string
label:
type: string
RunReference:
type: object
properties:
id:
type: integer
url:
type: string
status:
type: string
NextStep:
type: object
required:
- label
properties:
label:
type: string
description:
type: string
nullable: true
href:
type: string
nullable: true
actionType:
type: string
nullable: true
ActionLink:
type: object
required:
- label
- href
properties:
label:
type: string
href:
type: string
kind:
type: string
nullable: true
ProviderBackedRunDetail:
type: object
description: >-
Canonical Monitoring run detail contract for accepted provider-backed
work. The rendered page must reuse the same translated reason family
for operator-triggered and scheduled or system-initiated runs, while
terminal notifications remain initiator-only.
required:
- runId
- operationType
- executionStatus
properties:
runId:
type: integer
operationType:
type: string
executionStatus:
type: string
outcome:
type: string
nullable: true
providerConnection:
$ref: '#/components/schemas/ProviderConnectionContext'
protectedScope:
type: object
additionalProperties: true
shortReason:
type: string
nullable: true
nextSteps:
type: array
items:
$ref: '#/components/schemas/NextStep'
diagnosticsAvailable:
type: boolean

View File

@ -1,236 +0,0 @@
# Data Model: Provider-Backed Action Preflight and Dispatch Gate Unification
## Overview
This feature does not introduce new persisted entities or tables. It extends the existing provider-backed start contract around `ProviderConnection`, `OperationRun`, and onboarding draft state so every covered operator-triggered start follows the same queue-admission, conflict-protection, and operator-feedback rules.
The key design constraint is that start truth remains service-owned and derived from existing runtime records:
- provider readiness and connection identity from `ProviderConnection` plus `ProviderConnectionResolver`
- accepted or prevented work truth from `OperationRun`
- click-time queue-admission decisions from `ProviderOperationStartGate`
- onboarding bootstrap continuity from existing `TenantOnboardingSession.state`
- operator feedback from existing Ops UX helpers plus one thin shared provider-start presentation helper
## Existing Persistent Inputs
### 1. ProviderConnection
- Purpose: Tenant-owned provider credential and readiness record that defines which delegated connection a provider-backed operation can use.
- Key persisted fields used by this feature:
- `id`
- `tenant_id`
- `provider`
- `entra_tenant_id`
- Existing runtime facts consumed through current services:
- default-vs-explicit selection
- consent readiness
- credential usability
- provider identity and scope targeting
- Relationships used by this feature:
- owning tenant
- related operation runs through `OperationRun.context.provider_connection_id`
### 2. OperationRun
- Purpose: Canonical operational truth for queued or executed provider-backed work and for blocked preflight attempts that must remain observable.
- Key persisted fields used by this feature:
- `id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `reason_code`
- `context`
- `summary_counts`
- `started_at`
- `completed_at`
- Existing relationships or references used by this feature:
- initiator user
- tenant scope
- canonical Monitoring → Operations detail route
### 3. TenantOnboardingSession
- Purpose: Workspace-owned onboarding workflow record that already stores onboarding progress and step state, including bootstrap operation selections and related run references.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id` (nullable until attached)
- `current_step`
- `state`
- Existing state keys used by this feature:
- `bootstrap_operation_types`
- `bootstrap_operation_runs`
- Relationships used by this feature:
- workspace
- attached tenant when present
### 4. RestoreRun
- Purpose: Tenant-owned restore execution record whose execute action becomes part of the canonical provider-backed start contract.
- Key persisted fields used by this feature:
- `id`
- `tenant_id`
- restore configuration and preview state already captured before execution
- Relationships used by this feature:
- tenant
- backup source records and restore safety flow already owned by existing restore logic
## Existing Service-Owned Inputs
### A. ProviderOperationRegistry Entry
This is an existing logical definition, not a new persisted entity.
| Field | Meaning |
|---|---|
| `operationType` | The write-time operation type string admitted by the gate |
| `module` | Current operation family/module metadata used in run context |
| `dispatcher` | The queue-dispatch callback or equivalent start hook |
| `requiredCapability` | Capability gate required to start the operation |
| `providerScopeExpectation` | Whether the operation is connection-scoped and therefore protected by click-time conflict rules |
Rules:
- First-slice coverage adds entries for every covered action host instead of introducing a second registry.
- This feature does not normalize historical operation names; registry entries follow current write-time operation types.
### B. ProviderConnectionResolution
Logical output of `ProviderConnectionResolver` and `ProviderOperationStartGate`.
| Field | Meaning |
|---|---|
| `providerConnectionId` | Explicit resolved connection identity when admission is possible |
| `provider` | Provider family used for copy and downstream dispatch |
| `targetScope` | Current tenant/provider scope metadata for run context |
| `reasonCode` | Stable blocked reason when admission is prevented |
| `reasonMeta` | Sanitized structured detail for translation and next steps |
| `nextSteps` | Resolution path metadata used by the shared presentation helper |
Rules:
- Every accepted covered start must resolve this state before queue admission.
- Blocked resolutions never admit a queued job.
## Derived Coordination Entities
### 1. ProtectedProviderScope
This is a logical concurrency boundary, not a new table.
| Field | Meaning |
|---|---|
| `tenantId` | Tenant boundary for the operation |
| `providerConnectionId` | Connection boundary for provider-backed conflict protection |
| `activeRunId` | Existing queued or running run occupying the scope, when present |
| `activeOperationType` | Operation currently using the protected scope |
Rules:
- At most one provider-backed operation may be accepted at a time for one protected scope.
- If the same operation type is already active on the scope, the result is `deduped`.
- If a different covered operation is already active on the same scope, the result is `scope_busy`.
### 2. PreflightStartResult
This is the shared logical result of a covered start attempt.
| Field | Meaning | Source |
|---|---|---|
| `internalState` | Current service-owned state such as `started`, `deduped`, `scope_busy`, or `blocked` | `ProviderOperationStartResult` |
| `operatorState` | Operator-facing vocabulary: `accepted`, `deduped`, `scope_busy`, `blocked` | shared presenter |
| `operationType` | Covered operation being started | action host + registry |
| `runId` | Canonical run for accepted, deduped, or scope-busy results, and optionally for blocked truth where already created | `OperationRun` |
| `providerConnectionId` | Resolved connection identity when known | gate + resolver |
| `reasonCode` | Stable problem class for blocked or other directed outcomes | current reason system |
| `nextSteps` | Structured resolution or follow-up guidance | next-step registry / helper |
Validation rules:
- `blocked` must never dispatch a background job.
- `accepted`, `deduped`, and `scope_busy` must point to the canonical run the operator should inspect.
- `accepted` must carry the explicit `provider_connection_id` into accepted-work context.
### 3. AcceptedProviderBackedRunContext
Logical accepted-work context persisted inside `OperationRun.context`.
| Field | Meaning |
|---|---|
| `provider_connection_id` | Explicit connection identity used by the accepted run |
| `provider` | Provider family label for display and diagnostics |
| `target_scope` | Sanitized tenant/provider scope metadata |
| `source_surface` | Action-host family such as tenant detail, provider connection, onboarding, restore, or directory sync |
| `initiator_user_id` | Starting actor |
| `operation_specific_context` | Existing per-operation context already needed by downstream jobs |
Rules:
- Jobs must receive the same `provider_connection_id` that was accepted at click time.
- Monitoring and notifications explain accepted work using this context rather than a runtime default connection lookup.
### 4. ProviderStartPresentation
Logical derived presentation output returned by the shared start-result helper.
| Field | Meaning |
|---|---|
| `title` | Standardized accepted/deduped/scope-busy/blocked headline |
| `body` | Short reason or queue message aligned with the spec vocabulary |
| `statusStyle` | Existing toast/notification severity |
| `viewRunAction` | Canonical open-run action when a run exists |
| `nextStepActions[]` | Optional resolution actions or follow-up links |
| `domainVerb` | Local action verb preserved from the host surface |
| `domainTarget` | Local object noun preserved from the host surface |
Rules:
- Accepted and deduped outcomes continue to use the existing `OperationUxPresenter` toast style.
- Blocked and scope-busy outcomes must no longer rely on page-local copy branches.
- Domain verb and target remain local so the shared contract does not flatten the product vocabulary into generic verbs.
### 5. OnboardingBootstrapAdmission
Logical onboarding-only coordination model built from existing draft state.
| Field | Meaning |
|---|---|
| `selectedOperationTypes[]` | Bootstrap operations the operator selected |
| `acceptedOperationType` | The one operation type admitted for the current submission |
| `pendingOperationTypes[]` | Remaining selected types still waiting to run |
| `runIdsByOperationType` | Existing and newly accepted run references persisted in draft state |
Rules:
- A bootstrap submission may accept at most one provider-backed operation for the protected scope.
- Remaining bootstrap selections stay in existing draft state rather than spawning a new orchestration entity.
- The onboarding step continues to be the only primary context; operators should not need a new workflow page to understand the pending follow-up.
## State Transitions
### Covered Start Attempt
```text
requested
-> blocked (no queue admission, blocked truth retained where applicable)
-> deduped (existing same-operation active run reused)
-> scope_busy (existing different-operation active run reused)
-> accepted (run admitted, queued job dispatched with explicit provider connection)
```
### Onboarding Bootstrap Submission
```text
selected operations
-> blocked / deduped / scope_busy (no new run admitted)
-> accepted operation + pending operations retained in draft state
```
## Non-Goals In The Data Model
- No new provider-start table or umbrella batch entity
- No new persisted summary or presentation artifact
- No platform-wide operation-type rename or taxonomy rewrite
- No new status family beyond the shared start-result vocabulary already required by the spec

View File

@ -1,228 +0,0 @@
# Implementation Plan: Provider-Backed Action Preflight and Dispatch Gate Unification
**Branch**: `216-provider-dispatch-gate` | **Date**: 2026-04-19 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/216-provider-dispatch-gate/spec.md`
## Summary
Unify every covered operator-triggered provider-backed start behind the existing `ProviderOperationStartGate` so preventable provider blockers, same-operation dedupe, and cross-operation scope conflicts are resolved before queue admission instead of surfacing later as execution failures. The implementation extends the current gate and registry, adds one thin shared start-result presentation helper over the existing Ops UX stack, standardizes the public start wording as `queued`, `already running`, `scope busy`, and `blocked` over the canonical `accepted`, `deduped`, `scope busy`, and `blocked` result model, pins accepted work to an explicit `provider_connection_id`, keeps scheduled and system-run Monitoring language compatible without widening click-time UX scope, and migrates the first-slice tenant, provider-connection, restore, directory-sync, and onboarding start surfaces without adding new persistence or a second orchestration framework.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
**Storage**: PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned
**Testing**: Pest unit and focused feature tests for gate transitions, Filament action hosts, onboarding wizard flows, and Ops UX/run-detail alignment
**Validation Lanes**: `fast-feedback`, `confidence`
**Target Platform**: Laravel admin web app under Sail, rendered through Filament on Linux containers
**Project Type**: Monorepo with one Laravel platform application in `apps/platform` plus docs/spec artifacts at repository root
**Performance Goals**: Start preflight remains DB-only at click time, performs no Graph call before queue admission, adds no new remote latency source to the click-time path, and admits at most one accepted provider-backed run per protected scope
**Constraints**: No new provider-start framework, no parallel queue-admission path, no new persistence, no operation-type naming rewrite, no inline remote work on start surfaces, no drift from RBAC 404/403 semantics, no drift from Ops-UX 3-surface feedback, and no hidden bypass of the gate on covered start surfaces
**Scale/Scope**: First slice covers every current operator-triggered provider-backed start reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding: tenant verification, provider-connection check/inventory/compliance, restore execute, directory groups sync, role definitions sync, onboarding verification, and onboarding bootstrap
## Filament v5 Implementation Contract
- **Livewire v4.0+ compliance**: Preserved. This feature stays within existing Filament v5 and Livewire v4 patterns and does not introduce any Livewire v3-era APIs.
- **Provider registration location**: Unchanged. No panel/provider work is planned; existing panel providers remain registered in `bootstrap/providers.php`, not `bootstrap/app.php`.
- **Global search coverage**:
- `TenantResource` remains globally searchable and already has both view and edit pages.
- `EntraGroupResource` remains tenant-scoped for global search and already has a view page.
- `ProviderConnectionResource` keeps global search disabled (`$isGloballySearchable = false`).
- `RestoreRunResource` is not being newly enabled for global search in this feature; its existing view page remains available if current search behavior references it.
- **Destructive actions**: No new destructive actions are introduced. Restore execution remains a destructive-like flow that continues to rely on existing confirmation and authorization requirements; existing connection lifecycle actions remain `Action::make(...)->action(...)` based and keep `->requiresConfirmation()` where already required.
- **Asset strategy**: No new panel or shared assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets change.
- **Testing plan**: Keep one supporting unit seam for the gate and shared presentation mapping, then prove the feature through focused Filament/Livewire feature coverage on tenant, provider-connection, onboarding, restore, directory-sync, and run-detail alignment. No browser lane or new heavy-governance family is planned.
## UI / Surface Guardrail Plan
- **Guardrail scope**: Changed surfaces across existing tenant-scoped provider-backed starts, provider-connection start actions, restore execute, directory sync starts, onboarding provider steps, and canonical Monitoring run drill-in language
- **Native vs custom classification summary**: Mixed shared-family change using native Filament actions, native notifications, and existing shared Ops UX helpers; no new custom shell or bespoke panel surface
- **Shared-family relevance**: Shared provider action family across `TenantResource`, `TenantVerificationReport`, `ProviderConnectionResource`, `RestoreRunResource`, `ListEntraGroups`, `ManagedTenantOnboardingWizard`, and related run-detail explanation surfaces
- **State layers in scope**: `page`, `detail`, `wizard-step`, and existing run-link drill-in; no new URL-query or shell ownership added
- **Handling modes by drift class or surface**: Hard-stop for blocked preflight and protected-scope conflicts; review-mandatory for any remaining direct dispatch path on a covered surface
- **Repository-signal treatment**: Review-mandatory because the feature changes a shared action-host family and canonical run-entry semantics
- **Special surface test profiles**: `standard-native-filament`, `workflow / wizard`, `monitoring-state-page`
- **Required tests or manual smoke**: `functional-core`, `state-contract`, `manual-smoke`
- **Exception path and spread control**: One named exception boundary only: onboarding bootstrap currently batches multiple provider-backed operations and must be normalized to one accepted protected-scope run without introducing a new orchestration framework
- **Active feature PR close-out entry**: `Guardrail`
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design: still passed with one bounded helper addition and no new persisted truth.*
| Gate | Status | Plan Notes |
|------|--------|------------|
| Inventory-first / read-write separation | PASS | The feature changes start admission and feedback only. Accepted work still executes asynchronously; restore keeps preview/confirmation/audit semantics and no new snapshot or runtime truth is introduced. |
| Single Graph contract path / no inline remote work | PASS | Start surfaces remain authorize + preflight + enqueue only. Existing queued jobs continue to own Graph calls through the current provider abstractions; no render-time or click-time Graph call is added. |
| RBAC, workspace isolation, tenant isolation | PASS | Covered starts remain inside existing tenant/workspace scopes. Non-members remain 404, members missing capability remain 403, and provider readiness messaging must not leak cross-tenant active runs or connection identity. |
| Run observability / Ops-UX 3-surface feedback | PASS | Covered starts continue to create or reuse canonical `OperationRun` truth. Accepted starts keep toast-only intent feedback, progress stays in active widgets/run detail, and terminal DB notifications remain owned by `OperationRunService` for accepted work only. |
| Proportionality / no premature abstraction / few layers | PASS | The plan extends the existing gate and registry and adds one thin presentation helper to absorb five-plus duplicated local result branches. No second gate, coordinator framework, or persisted summary layer is introduced. |
| UI semantics / Filament-native action discipline | PASS | Existing action hosts remain in place. The feature standardizes start-result language without adding new navigation models, new shell surfaces, or page-local status frameworks. |
| Test governance | PASS | Proof stays in unit plus focused feature lanes with explicit provider/tenant context opt-in. No new browser or heavy-governance family is required. |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for gate transition and presentation mapping seams; `Feature` for tenant/provider/onboarding/restore/directory start surfaces and accepted-run reason alignment; `Heavy-Governance`: none; `Browser`: none
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The business truth is server-side preflight, authorization, dedupe/scope busy handling, queue admission, and consistent operator feedback on existing action hosts. That behavior is fully provable with direct feature tests and one small unit seam; browser coverage would add cost without proving unique behavior.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php tests/Feature/Tenants/TenantProviderBackedActionStartTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Restore/RestoreRunProviderStartTest.php tests/Feature/Directory/ProviderBackedDirectoryStartTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php tests/Feature/Filament/RestoreRunUiEnforcementTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php tests/Feature/Guards/ProviderDispatchGateCoverageTest.php tests/Feature/Guards/TestLaneManifestTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Provider-backed start fixtures need tenant membership, explicit provider connections, active-run setup, and onboarding draft state; these remain explicit per test and must not become default helpers.
- **Expensive defaults or shared helper growth introduced?**: No. Existing factories and helpers remain opt-in; no global provider/workspace bootstrap is planned.
- **Heavy-family additions, promotions, or visibility changes**: None
- **Surface-class relief / special coverage rule**: Standard native Filament relief for resource/page action hosts; onboarding uses a named wizard-step profile but still remains feature-testable without browser automation.
- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test command above, then do one human smoke pass for blocked, deduped, scope busy, and queued outcomes across tenant, provider-connection, onboarding, and restore. Reviewers should confirm there is no remaining direct-dispatch bypass on a route-bounded covered surface, that scheduled or system-run Monitoring wording stays compatible with the same problem classes, and that no new custom completion notification path was introduced.
- **Budget / baseline / trend follow-up**: None expected; document in feature if the new focused suite materially expands runtime beyond ordinary feature-local upkeep.
- **Review-stop questions**: Did any coverage drift into browser/heavy lanes without a unique proving need? Did any helper start providing implicit tenant/provider context? Did any route-bounded covered start remain on local `ensureRun*/dispatch` logic? Did scheduled or system-run compatibility widen into click-time UX scope? Did any test assert presentation details that should stay in the shared presenter seam only?
- **Escalation path**: `document-in-feature`
- **Active feature PR close-out entry**: `Guardrail`
- **Why no dedicated follow-up spec is needed**: This change is feature-local hardening around existing start surfaces. Only recurring operation-type normalization or structural lane-cost drift would justify a follow-up spec.
## Project Structure
### Documentation (this feature)
```text
specs/216-provider-dispatch-gate/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── provider-dispatch-gate.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Operations/TenantlessOperationRunViewer.php
│ │ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php
│ │ ├── Resources/EntraGroupResource/Pages/ListEntraGroups.php
│ │ ├── Resources/OperationRunResource.php
│ │ ├── Resources/ProviderConnectionResource.php
│ │ ├── Resources/RestoreRunResource.php
│ │ └── Resources/TenantResource.php
│ ├── Notifications/
│ │ └── OperationRunCompleted.php
│ ├── Services/
│ │ ├── Directory/RoleDefinitionsSyncService.php
│ │ ├── Providers/ProviderConnectionResolver.php
│ │ ├── Providers/ProviderOperationRegistry.php
│ │ ├── Providers/ProviderOperationStartGate.php
│ │ └── Verification/StartVerification.php
│ └── Support/
│ ├── OperationRunLinks.php
│ ├── OpsUx/OperationUxPresenter.php
│ ├── OpsUx/ProviderOperationStartResultPresenter.php
│ ├── ReasonTranslation/
│ └── Providers/
└── tests/
├── Feature/
│ ├── Directory/
│ ├── Filament/
│ ├── Guards/
│ ├── Onboarding/
│ ├── Operations/
│ ├── OpsUx/
│ ├── ProviderConnections/
│ ├── Restore/
│ ├── Tenants/
│ └── Workspaces/
├── Support/
└── Unit/Providers/
```
**Structure Decision**: Single Laravel application inside the monorepo. All runtime work lands in `apps/platform`, while planning artifacts stay under `specs/216-provider-dispatch-gate`.
## Complexity Tracking
No constitutional violation is planned. One bounded addition is tracked explicitly because it adds a thin presentation layer.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| BLOAT-001 bounded presenter helper | Five-plus real start surfaces already duplicate blocked/deduped/scope busy/accepted notification branches; one shared helper is the narrowest way to keep one operator vocabulary without inventing a framework | Keeping page-local branches would fail FR-216-008/009, preserve inconsistent copy drift, and force every new covered surface to duplicate the same start contract again |
## Proportionality Review
- **Current operator problem**: Operators can trigger provider-backed actions that look accepted at click time but only fail later because provider connection, consent, credential, or protected-scope prerequisites were missing. The same blocker also renders differently across tenant, provider-connection, onboarding, restore, and directory-sync surfaces.
- **Existing structure is insufficient because**: The canonical gate already exists, but only a subset of surfaces use it and those surfaces still duplicate result rendering locally. Legacy `ensureRun*/dispatch` starts also resolve provider identity too late, which allows runtime default drift and inconsistent click-time feedback.
- **Narrowest correct implementation**: Extend the existing `ProviderOperationRegistry` and `ProviderOperationStartGate`, add one thin start-result presentation helper over the current Ops UX primitives, and migrate the first-slice action hosts. Keep blocked/accepted truth in existing `OperationRun` records and do not add new persistence.
- **Ownership cost created**: One small shared presentation helper, a broader but still focused feature test surface, and explicit registry entries for each newly covered operation type.
- **Alternative intentionally rejected**: A new provider-start coordinator/orchestration framework or a second queue-admission path. Those would duplicate existing gate behavior, broaden the slice into architecture work, and violate FR-216-014.
- **Release truth**: Current-release truth. The problem exists today on current operator-facing start surfaces.
## Phase 0 Research Summary
- Reuse the current gate and registry; adoption breadth is the gap, not missing queue-admission infrastructure.
- Resolve and pin `provider_connection_id` at dispatch time for every accepted covered start to prevent runtime default drift.
- Preserve blocked preflight truth as canonical blocked start state, but never admit background work for blocked starts.
- Add one shared presentation helper over `OperationUxPresenter`, `ReasonPresenter`, `ProviderNextStepsRegistry`, and `OperationRunLinks` rather than leaving local `Notification::make()` branches in place.
- Keep the operator-facing start vocabulary explicit: the canonical outcomes remain `accepted`, `deduped`, `scope busy`, and `blocked`, while public wording stays `queued`, `already running`, `scope busy`, and `blocked`.
- Keep the first implementation slice bounded to tenant, provider-connection, onboarding, restore, and directory start surfaces named by the spec; workspace-level baseline/evidence/review generators remain outside this route-bounded slice.
- Do not combine dispatch-gate hardening with operation-type normalization; legacy write-time operation strings stay as-is in this feature.
- Keep scheduled and system-triggered compatibility bounded to Monitoring-side reason reuse and initiator-aware notification behavior; no click-time UX is added for those runs.
- Normalize onboarding bootstrap from multi-start batch admission to sequential protected-scope admission without adding a new orchestration framework.
## Phase 1 Design Summary
- `data-model.md` documents the feature as a service-owned extension over existing `ProviderConnection`, `OperationRun`, and onboarding draft state plus new logical models for protected scope, preflight result, accepted run context, and shared presentation output.
- `contracts/provider-dispatch-gate.logical.openapi.yaml` defines the internal action-start contract for tenant, provider-connection, restore, directory-sync, and onboarding action hosts plus canonical run-detail output.
- `quickstart.md` defines the focused verification path for blocked, deduped, scope busy, and accepted results, including the onboarding bootstrap sequentialization case.
## Implementation Strategy
1. **Extend canonical gate coverage**
- Expand `ProviderOperationRegistry` to include every first-slice covered operation type.
- Keep the existing `ProviderOperationStartGate` as the single queue-admission path.
- Ensure each migrated start resolves provider access before dispatch and passes the explicit `provider_connection_id` into both `OperationRun` context and job args.
2. **Centralize start-result presentation**
- Add one shared helper that consumes `ProviderOperationStartResult` and emits the standardized operator feedback mapping: `accepted -> queued`, `deduped -> already running`, `scope busy -> scope busy`, and `blocked -> blocked`.
- Reuse `OperationUxPresenter` for queued and already running toast semantics and `ReasonPresenter` plus next-step metadata for blocked and scope busy outcomes.
- Keep domain verbs and target nouns local to each action host while unifying outcome vocabulary and run-link behavior.
3. **Migrate already-gated surfaces first**
- Refactor `StartVerification`, `TenantResource`, `TenantVerificationReport`, `ProviderConnectionResource`, and the onboarding verification step to call the shared presentation helper instead of keeping local result-branching code.
- Preserve current protection while removing duplicated operator copy paths.
4. **Migrate legacy direct-dispatch starts**
- Replace local `ensureRun*/dispatch` logic on restore execute, directory groups sync, role definitions sync, and onboarding bootstrap with the canonical gate.
- Keep destructive restore semantics intact: existing preview, warning, confirmation, and authorization remain unchanged; only queue admission and feedback unify.
5. **Normalize onboarding bootstrap sequencing**
- Convert bootstrap admission from “start every selected provider-backed operation immediately” to “accept one protected-scope provider-backed operation at a time.”
- Reuse existing onboarding draft state to retain pending bootstrap selections and run references instead of introducing an umbrella batch entity or new orchestration model.
6. **Align accepted-run diagnostics**
- Ensure canonical run detail and terminal notification reason translation stay aligned with the shared start-result problem classes whenever they describe the same accepted operation.
- Keep scheduled and system-triggered Monitoring reason reuse in the same public language family without adding click-time start UX or breaking initiator-only notification policy.
- Keep blocked starts distinguishable from accepted execution failures in monitoring and audit surfaces.
7. **Backfill proof and regression guards**
- Extend the gate unit suite for newly registered operation types and onboarding sequencing.
- Add one route-bounded guard that inventories covered start hosts and fails if any first-slice action still bypasses `ProviderOperationStartGate` through local `ensureRun*/dispatch` logic.
- Add focused feature coverage around each migrated action host and around cross-surface reason vocabulary alignment.
## Risks and Mitigations
- **Onboarding bootstrap behavior change**: The current wizard can admit more than one provider-backed run for one connection. The mitigation is to normalize the flow to one accepted protected-scope run per submission and keep the rest as pending follow-up using existing draft state.
- **Legacy operation-type aliases**: Directory sync operations currently use legacy write-time names. The mitigation is to keep those names stable in this feature and avoid widening the scope into operation taxonomy cleanup.
- **Restore flow regression risk**: Restore is destructive-like and already confirmation-heavy. The mitigation is to change only the start path and feedback contract, not the preview/confirmation/authorization model.
- **Hidden direct-dispatch bypasses**: Additional provider-backed starts may exist outside obvious action hosts. The mitigation is to treat any remaining first-slice `ensureRun*/dispatch` path as a review blocker and keep the route-bounded scope explicit.
- **Over-testing via expensive fixtures**: Provider-backed starts need tenant, provider, and active-run context. The mitigation is to keep those fixtures opt-in and avoid a new shared “full provider world” helper.
## Post-Design Re-check
Phase 0 and Phase 1 outputs resolve the earlier design unknowns without introducing new gates, new persisted truth, or a second UI semantics framework. The plan remains constitution-compliant, bounded to current-release operator pain, and ready for `/speckit.tasks`.
## Implementation Close-Out
- **Final lane outcome**: Focused verification completed with `pint --dirty --format agent`, the documented quickstart suite, the broader migrated-surface regression suite, and the route-bounded guard/manifest close-out suite all passing during implementation.
- **Route-bounded covered-surface audit**: `tests/Feature/Guards/ProviderDispatchGateCoverageTest.php` now guards the first-slice route-bounded provider-backed starts so tenant verification, provider-connection actions, restore execute, directory sync, role definitions sync, and onboarding bootstrap stay on canonical gate-owned entry points rather than regressing to local `ensureRun` / `dispatchOrFail` admission paths.
- **Guardrail close-out**: Livewire v4.0+ compliance is preserved, provider registration remains in `bootstrap/providers.php`, no new global-search behavior was introduced, destructive restore semantics remain on existing confirmation/authorization paths, and no new Filament assets were added beyond the standing `php artisan filament:assets` deployment expectation when registered assets change.
- **Test governance disposition**: `document-in-feature` remains the explicit disposition. The proof stays in unit plus focused feature lanes, with one new heavy-governance guard family (`provider-dispatch-gate-coverage`) recorded in the manifest instead of widening into browser or new heavy workflow suites.

View File

@ -1,164 +0,0 @@
# Quickstart: Provider-Backed Action Preflight and Dispatch Gate Unification
## Goal
Validate that every covered operator-triggered provider-backed start now resolves click-time provider blockers, same-operation dedupe, and protected-scope conflicts before queue admission, while preserving one shared operator vocabulary and truthful Monitoring → Operations drill-in for accepted work.
## Prerequisites
1. Start Sail if it is not already running.
2. Use a tenant member with the exact capability required by the target action host.
3. Prepare at least one tenant with:
- one usable provider connection,
- one blocked provider condition such as missing default connection, missing consent, or unusable credentials,
- one active queued or running provider-backed `OperationRun` for same-operation dedupe checks,
- one active queued or running provider-backed `OperationRun` for cross-operation `scope_busy` checks,
- one restore run ready for execute validation,
- one onboarding session with provider verification and bootstrap steps available.
4. Keep a second user or session available if you want to validate tenant isolation and initiator-only notification behavior.
## Focused Automated Verification
Run formatting first:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
Then run the narrowest focused suite that proves the contract:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact \
tests/Unit/Providers/ProviderOperationStartGateTest.php \
tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php \
tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php \
tests/Feature/Tenants/TenantProviderBackedActionStartTest.php \
tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php \
tests/Feature/Restore/RestoreRunProviderStartTest.php \
tests/Feature/Directory/ProviderBackedDirectoryStartTest.php \
tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php
```
If only one action host changed during implementation, rerun the smallest relevant subset before broadening to the full focused suite.
## Manual Validation Pass
### 1. Tenant-scoped start surfaces
Trigger a covered tenant-scoped provider-backed action such as verification, restore execute, or directory sync in these four conditions:
- blocked by missing or unusable provider access,
- deduped by an equivalent active run,
- `scope_busy` because a different covered operation is already active for the same provider connection,
- accepted with a valid explicit provider connection.
Confirm that:
- no blocked case queues background work,
- the operator sees one consistent accepted/deduped/scope-busy/blocked vocabulary,
- each non-blocked result points to the correct existing or accepted run,
- and no page-local notification copy contradicts the shared contract.
### 2. Provider-connection resource surfaces
From the provider-connection list and detail pages, start the covered connection-scoped actions.
Confirm that:
- the same blocker or active-run condition produces the same outcome category used on tenant surfaces,
- the provider connection identity shown to the operator matches the accepted run context,
- and connection lifecycle actions remain where they were before this feature.
### 3. Restore execute flow
Start a restore execution from the existing restore surface.
Confirm that:
- existing preview, warning, and confirmation behavior remains intact,
- blocked preflight prevents queue admission before execution starts,
- accepted execution opens the canonical run link,
- and restore-specific destructive semantics are unchanged apart from the unified start contract.
### 4. Onboarding verification and bootstrap
Use the onboarding wizard to trigger the provider verification step and the bootstrap step.
Confirm that:
- provider verification uses the same outcome categories as the other covered action hosts,
- bootstrap no longer admits multiple provider-backed runs concurrently for the same connection,
- a blocked, deduped, or `scope_busy` bootstrap attempt does not leave hidden extra queued work behind,
- and any remaining bootstrap work is visible as a follow-up state on the existing step rather than on a new workflow page.
### 5. Monitoring → Operations run detail
Open the run detail pages linked from accepted, deduped, and `scope_busy` results.
Confirm that:
- accepted work shows the same provider connection identity that was chosen at click time,
- run detail and terminal notification reason translation align with the same problem class used at the start surface,
- blocked starts remain distinguishable from accepted work that later fails,
- and no cross-tenant provider identity or active-run existence leaks through canonical monitoring views.
### 6. Authorization and isolation non-regression
Confirm that:
- non-members still receive 404 behavior and learn nothing about provider readiness or active runs in other tenants,
- members missing the required capability still receive server-enforced 403 on execution,
- accepted-run notifications remain initiator-only,
- and no new start action bypasses the central capability registry.
### 7. Ten-second scan check
Timebox the first visible read of one result from each action-host family:
- tenant-scoped start surface,
- provider-connection surface,
- onboarding provider step,
- Monitoring run detail.
Confirm that within 10 seconds the operator can answer:
- did the operation get accepted,
- if not, why not,
- and what should happen next.
## Final Verification
Before merge, re-run:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact \
tests/Unit/Providers/ProviderOperationStartGateTest.php \
tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php \
tests/Feature/Tenants/TenantProviderBackedActionStartTest.php \
tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php \
tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php \
tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php \
tests/Feature/Filament/TenantVerificationReportWidgetTest.php \
tests/Feature/Restore/RestoreRunProviderStartTest.php \
tests/Feature/RestoreRunWizardExecuteTest.php \
tests/Feature/RestoreRunRerunTest.php \
tests/Feature/Directory/ProviderBackedDirectoryStartTest.php \
tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php \
tests/Feature/DirectoryGroups/StartSyncTest.php \
tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php \
tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php \
tests/Feature/ManagedTenantOnboardingWizardTest.php \
tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php \
tests/Feature/Filament/RestoreRunUiEnforcementTest.php \
tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php \
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php \
tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php \
tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php \
tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php \
tests/Feature/Guards/ProviderDispatchGateCoverageTest.php \
tests/Feature/Guards/ActionSurfaceContractTest.php \
tests/Feature/Guards/TestLaneManifestTest.php
```
If broader confidence is wanted after the focused suite, offer a full application test run as a separate follow-up step.

View File

@ -1,73 +0,0 @@
# Research: Provider-Backed Action Preflight and Dispatch Gate Unification
## Decision 1: Extend the existing gate and registry; do not create a second provider-start framework
- Decision: Migrate every covered operator-triggered provider-backed start onto the existing `ProviderOperationStartGate` by expanding `ProviderOperationRegistry` and updating the current action hosts to call the same queue-admission path.
- Rationale: The repo already has the right hardening seam. `ProviderOperationStartGate` resolves connection readiness, blocks missing provider prerequisites before queue admission, dedupes same-operation starts, blocks conflicting operations on the same protected scope, and returns a shared result object. The problem is adoption breadth, not missing infrastructure.
- Alternatives considered:
- Introduce a new `ProviderStartCoordinator` or second orchestration pipeline. Rejected because it would duplicate the gate, widen the change into architecture work, and violate FR-216-014.
- Keep local `ensureRun*/dispatch` flows and copy the same preflight into each action. Rejected because that preserves semantic drift and repeats the same blocker logic across tenant, provider-connection, restore, directory, and onboarding surfaces.
## Decision 2: Accepted work must pin `provider_connection_id` at dispatch time
- Decision: Every migrated accepted start resolves its provider connection before queue admission and persists the chosen `provider_connection_id` into `OperationRun.context` and job arguments.
- Rationale: Existing non-gate starts often resolve the default connection at job execution time. That allows runtime default drift if the default connection changes between click time and job execution. Dispatch-time pinning is already the correct pattern on the gated starts and is required by FR-216-006.
- Alternatives considered:
- Continue resolving the default connection inside queued jobs. Rejected because the same operator action can execute against a different connection than the one implied at click time.
- Store only a display label or provider name in context. Rejected because monitoring, dedupe, and run-detail explanation need the stable connection identity, not only presentation data.
## Decision 3: Keep blocked starts as canonical prevented-from-starting truth, but never admit background work
- Decision: A blocked preflight continues to produce canonical blocked-start truth where the current gate already does so, but blocked starts never enqueue jobs and must remain distinguishable from accepted runs that later fail during execution.
- Rationale: FR-216-004 and FR-216-015 require the product to stop turning preventable prerequisite problems into ordinary execution failures. Preserving canonical blocked truth keeps auditability and resolution links intact while still ensuring no remote work was accepted.
- Alternatives considered:
- Show only an ephemeral toast and create no run truth at all. Rejected because operators and support would lose the canonical blocked audit trail and linked next-step metadata.
- Queue the work and let the job fail fast. Rejected because that is the failure mode this spec is correcting.
## Decision 4: Standardize operator feedback through one thin presentation helper layered over the existing Ops UX stack
- Decision: Add one narrow start-result presentation helper that consumes `ProviderOperationStartResult` and composes the existing `OperationUxPresenter`, `ReasonPresenter`, `ProviderNextStepsRegistry`, and `OperationRunLinks` building blocks.
- Rationale: The current gated surfaces already prove that the shared start result is viable, but they still duplicate local `if/else` notification code across tenant, widget, provider-connection, and onboarding surfaces. A thin presenter absorbs that duplication without introducing a new UI semantics framework.
- Alternatives considered:
- Leave each surface with its own `Notification::make()` branching. Rejected because it fails FR-216-008/009 and guarantees future copy drift.
- Invent a larger badge/explanation framework for provider starts. Rejected because the repo constitution explicitly discourages turning UI semantics into their own mandatory architecture.
## Decision 5: The first slice is bounded by the spec routes, not by every possible provider-backed job in the codebase
- Decision: The first implementation slice covers every current operator-triggered provider-backed start reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding: tenant verification, provider-connection check/inventory/compliance actions, restore execute, directory groups sync, role definitions sync, onboarding verification, and onboarding bootstrap.
- Rationale: FR-216-012 defines the route-bounded first slice. Read-only exploration also surfaced workspace-level baseline/evidence/review generators and other background operations, but those lie outside the spec's primary routes and would expand this hardening feature into adjacent workflow areas.
- Alternatives considered:
- Expand the first slice to every provider-backed operation in the entire repo. Rejected because it would overshoot the spec and slow delivery.
- Limit the slice to restore only. Rejected because the same operator pain already exists across onboarding, directory sync, and provider-connection action hosts.
## Decision 6: Keep current write-time operation type strings in this feature; do not combine hardening with operation-type normalization
- Decision: Migrated starts keep their current write-time operation type strings, even where `OperationCatalog` exposes newer aliases or canonical dotted names.
- Rationale: The operator problem here is queue admission and start-result consistency, not operation-type taxonomy cleanup. Renaming start types would widen the blast radius into monitoring, audit, test fixtures, and historical read-model expectations.
- Alternatives considered:
- Rename legacy operation types such as `entra_group_sync` or `directory_role_definitions.sync` during the gate migration. Rejected because that is a separate normalization concern and not required to deliver block-before-queue semantics.
- Add a new translation layer inside the gate just for this feature. Rejected because that adds semantic machinery without solving the core operator problem.
## Decision 7: Onboarding bootstrap must normalize to sequential protected-scope admission
- Decision: The onboarding wizard can no longer admit multiple provider-backed operations concurrently for the same provider connection. The existing wizard flow remains, but queue admission becomes sequential: one accepted provider-backed run per protected scope, with remaining selected bootstrap work retained as follow-up state.
- Rationale: The current bootstrap implementation explicitly starts more than one provider-backed run for the same connection if no run is active at the beginning of the transaction. That conflicts with FR-216-007 and SC-216-003, which require click-time conflict protection and at most one accepted provider-backed operation per protected scope.
- Alternatives considered:
- Keep the existing batch-start bypass for onboarding only. Rejected because it would leave a permanent exception to the canonical start contract inside the first slice.
- Introduce a new umbrella bootstrap orchestration entity. Rejected because it would create a second start framework and unnecessary new semantics.
## Decision 8: The operator contract uses accepted/deduped/scope-busy/blocked vocabulary, even if the internal result object keeps `started` for compatibility
- Decision: The shared operator-facing contract standardizes on `accepted`, `deduped`, `scope busy`, and `blocked`, while the internal `ProviderOperationStartResult` can retain its current `started` variant in this slice if that avoids unnecessary churn.
- Rationale: FR-216-002 is about operator-visible start outcomes. The narrowest implementation is to keep internal compatibility where it helps while converging every visible surface and logical contract on the same operator vocabulary.
- Alternatives considered:
- Rename every internal `started` code path immediately. Rejected because it widens refactor scope without increasing product certainty.
- Keep operator-facing copy inconsistent with the spec language. Rejected because that would fail the core purpose of the feature.
## Decision 9: Testing should stay feature-first with one supporting unit seam
- Decision: Reuse and extend the existing `ProviderOperationStartGateTest` unit suite, then prove the feature through focused feature coverage on real Filament action hosts and canonical run-detail reason alignment.
- Rationale: The business truth is server-side authorization, preflight, dedupe, scope-busy handling, queue admission, and cross-surface reason consistency. Those are best proven with targeted feature tests against current action hosts, not with browser-heavy coverage or a new dedicated presenter harness.
- Alternatives considered:
- Rely mainly on browser tests. Rejected because the critical behavior is server-owned and already easier to prove through existing resource/page test families.
- Create a large presenter-only test harness. Rejected because it would shift effort from the real action hosts to indirection created only for the test suite.

View File

@ -1,237 +0,0 @@
# Feature Specification: Provider-Backed Action Preflight and Dispatch Gate Unification
**Feature Branch**: `216-provider-dispatch-gate`
**Created**: 2026-04-19
**Status**: Draft
**Input**: User description: "Provider-Backed Action Preflight and Dispatch Gate Unification"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Provider-backed actions currently use two different start patterns, so the same missing-prerequisite or concurrency problem can be blocked before queue on one surface but only discovered later as a failed run on another.
- **Today's failure**: Operators click a provider-backed action, see nothing obviously wrong, and only later discover that the job failed because a primary connection, valid consent, usable credentials, or an available scope was missing.
- **User-visible improvement**: Covered provider-backed action surfaces all answer the same first question immediately using one public vocabulary: was the start queued, is it already running, is the scope busy, or is it blocked and what should I do next.
- **Smallest enterprise-capable version**: Extend one canonical preflight-and-dispatch path plus one shared result-presentation contract to every current operator-triggered provider-backed start surface, without redesigning provider architecture or adding new provider domains.
- **Explicit non-goals**: No provider-connection label redesign, no broad provider-domain expansion, no operation naming overhaul, no new workflow hub, no legacy-credential cleanup, and no full backend normalization of every legacy provider service in this spec.
- **Permanent complexity imported**: One expanded start-gate contract, one shared start-result presentation contract, focused regression coverage across existing action hosts, and limited next-step translation expansion where current blocked reasons are insufficient.
- **Why now**: The roadmap marks this as an active adjacent hardening lane, the proven start-gate pattern already exists, and every new provider-backed action that bypasses it increases trust debt on core operator surfaces.
- **Why not local**: The failure mode spans tenant action surfaces, provider-connection surfaces, onboarding, and monitoring. Per-action fixes would recreate inconsistent blocked, deduped, and next-step language on roughly twenty workflows.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New shared support contract and broad multi-surface rollout. This remains justified because it does not add new persisted truth, it protects operator trust on remote actions, and the narrowest alternative is still one shared gate rather than many local copies.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, tenant, canonical-view
- **Primary Routes**: `/admin/t/{tenant}/...` existing tenant-scoped provider-backed action surfaces, `/admin/provider-connections`, `/admin/onboarding/...`, `/admin/operations/{run}`
- **Data Ownership**: Tenant-owned provider connections and tenant-bound operation runs remain the authoritative runtime records. Workspace context continues to scope access and canonical monitoring views; this spec does not introduce new persisted ownership models.
- **RBAC**: Existing workspace membership, tenant entitlement, and per-action capabilities remain authoritative. Preflight blocks never replace server-side authorization checks.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Canonical run links opened from a tenant-context action continue to preserve the active tenant context and related filtering behavior rather than widening back to all tenants by default.
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run detail, existing-run dedupe links, and blocked next-step guidance must only be built after workspace membership and tenant entitlement are confirmed. Non-members or non-entitled viewers remain deny-as-not-found and must not learn whether another tenant currently has an active conflicting run or configured provider connection.
## 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 |
|---|---|---|---|---|---|---|
| Tenant-scoped provider-backed start surfaces | yes | Native admin actions + shared provider-start feedback | shared provider action family | page, modal, detail | no | Existing start actions remain; only preflight and queue-accept feedback are unified |
| Provider-connection resource surfaces | yes | Native admin actions + shared provider-start feedback | shared provider action family | table, detail | no | Existing connection-check and provider-triggered actions adopt the same result language |
| Onboarding provider step | yes | Native wizard actions + shared provider-start feedback | shared provider action family | wizard step | no | No new onboarding page is introduced |
| Canonical provider-backed operation run detail | yes | Native monitoring detail primitives | shared monitoring family | detail | no | Run detail keeps diagnostics secondary to translated execution meaning |
## 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 |
|---|---|---|---|---|---|---|---|
| Tenant-scoped provider-backed start surfaces | Primary Decision Surface | Decide whether to start remote provider work now or resolve a blocker first | Start accepted vs blocked vs already running vs scope busy, short reason, next step, existing-run path when relevant | Full run detail, low-level provider diagnostics, connection detail | Primary because this is the moment an operator commits to remote work or resolves a blocker | Follows the action-start workflow directly on the tenant work surface | Removes the need to click, wait, and later inspect a failed run for preventable prerequisite problems |
| Provider-connection resource surfaces | Secondary Context Surface | Resolve provider readiness and retry a blocked provider-backed action | Current start-prevention reason, next step, retry path | Connection detail, historical runs, deeper diagnostics | Secondary because the connection surface supports the real start decision rather than replacing it | Follows provider-readiness remediation workflow | Shortens recovery from blocked states without inventing a second start dialect |
| Onboarding provider step | Primary Decision Surface | Decide whether onboarding can proceed with provider-backed verification or needs operator follow-up | Start accepted vs blocked, reason, next step, safe continue path | Provider detail, existing run detail, low-level diagnostics | Primary because onboarding must tell the operator immediately whether the next remote step can proceed | Follows onboarding completion workflow, not monitoring navigation | Prevents onboarding from feeling successful until a hidden failed run appears later |
| Canonical provider-backed operation run detail | Tertiary Evidence / Diagnostics Surface | Understand what happened after a covered operation was actually accepted | Human-readable outcome, dominant reason, next step, scope identity | Raw payload context, low-level diagnostics, implementation detail | Tertiary because operators usually arrive here after a start surface or notification already answered the first decision | Follows drill-in and troubleshooting workflow | Keeps deep diagnostics available without making them the first explanation for start-preventing problems |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant-scoped provider-backed start surfaces | Record / Detail / Edit | Detail-first Operational Surface | Start work or resolve the blocker that prevented it | Existing detail page or modal on the current tenant surface | forbidden | Existing safe navigation remains secondary to the primary start decision | Existing dangerous actions stay where they already live; this spec adds none | `/admin/t/{tenant}/...` | existing tenant-scoped detail routes | Workspace and tenant scope, action target, provider readiness | Provider-backed operations / Provider-backed operation | Whether the action can start now and what the operator should do next | none |
| Provider-connection resource surfaces | List / Table / Bulk | CRUD / List-first Resource | Inspect provider readiness or start a connection-scoped check | Full-row click to connection detail where already used | allowed | Existing secondary actions remain grouped or contextual | Existing destructive connection lifecycle actions remain in danger placements | `/admin/provider-connections` | existing provider-connection detail route | Workspace and tenant scope, provider identity, readiness state | Provider connections / Provider connection | Whether the connection is usable for the requested operation | none |
| Onboarding provider step | Workflow / Wizard / Launch | Wizard / Step-driven Flow | Continue onboarding or resolve provider prerequisites | Existing wizard step as the only primary context | forbidden | Secondary navigation stays outside the primary step action | Destructive actions are out of scope | `/admin/onboarding/...` | existing onboarding step route | Workspace and tenant scope, onboarding phase, provider readiness | Onboarding / Onboarding step | Whether onboarding can continue with provider-backed work right now | none |
| Canonical provider-backed operation run detail | Record / Detail / Edit | Detail-first Operational Surface | Inspect the accepted run or open the related resolution surface | Explicit run detail page | forbidden | Related navigation and diagnostics remain secondary | Destructive actions are out of scope | `/admin/operations` | `/admin/operations/{run}` | Workspace and tenant scope, operation target, provider identity, active vs terminal state | Operation runs / Operation run | What actually happened after the operation was accepted | canonical monitoring detail |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant-scoped provider-backed start surfaces | Tenant operator or manager | Decide whether to start remote provider work now | Detail / launch affordance | Can this operation start now, and if not, what should I do next? | Start result, short reason, next step, existing-run path where relevant | Low-level provider diagnostics, raw failure context, deeper connection detail | start eligibility, dedupe, concurrency, actionability | Microsoft tenant and TenantPilot run creation when accepted | Start operation, open existing run, open resolution surface | none added |
| Provider-connection resource surfaces | Tenant manager or owner | Resolve connection readiness and retry blocked work | List/detail | Is this connection ready for the operation I am trying to start? | Start result, reason, next step, connection identity | Historical diagnostics, low-level provider context | readiness, actionability | TenantPilot only until an operation is accepted | Inspect connection, run connection-scoped checks, retry action | Existing destructive connection actions unchanged |
| Onboarding provider step | Tenant manager or onboarding operator | Continue onboarding or stop to resolve provider readiness | Wizard step | Can onboarding proceed with the next provider-backed action? | Start result, reason, next step, safe continuation or resolution path | Deep provider diagnostics, monitoring drill-in | readiness, actionability, progress continuity | TenantPilot onboarding flow and remote provider work when accepted | Continue onboarding, open resolution path | none added |
| Canonical provider-backed operation run detail | Tenant operator, workspace operator, support reviewer | Diagnose accepted provider-backed work after it started | Detail | What happened to the accepted operation, and what should happen next? | Human-readable outcome, short reason, next step, scope identity | Raw payload context, exception detail, low-level provider metadata | execution outcome, completeness, actionability | none | Open related target, inspect diagnostics | none added |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators cannot trust provider-backed start behavior because equivalent prerequisite and concurrency problems are surfaced at different times and in different languages depending on the action host.
- **Existing structure is insufficient because**: The current split between one guarded start path and many legacy local start paths creates inconsistent operator truth and inconsistent queue behavior across the same class of remote actions.
- **Narrowest correct implementation**: Reuse the existing canonical start-gate pattern and extend it to all current operator-triggered provider-backed starts, with one shared result-presentation contract and no new provider-domain framework.
- **Ownership cost**: One broader start-gate contract, one shared result-presentation contract, and focused regression coverage across covered action hosts and monitoring surfaces.
- **Alternative intentionally rejected**: Per-action preflight fixes on each surface. That would look cheaper short term but would preserve multiple provider-start dialects and duplicate concurrency logic in local action handlers.
- **Release truth**: Current-release truth. This feature closes a present operator and monitoring trust gap on already-shipped provider-backed workflows.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: The change is proven by operator-visible start behavior, queue-accept semantics, dedupe and scope busy outcomes, and monitoring/notification alignment on existing action hosts. Focused feature coverage is the narrowest sufficient proof.
- **New or expanded test families**: Expanded action-surface feature coverage for tenant start surfaces, provider-connection surfaces, onboarding, and provider-backed run/notification alignment. Limited focused unit coverage may support start-result mapping, but the proving purpose remains feature-level.
- **Fixture / helper cost impact**: Moderate. Tests need provider connection state, tenant membership, action capability context, and active-run fixtures, but can reuse existing workspace and tenant setup.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament
- **Standard-native relief or required special coverage**: Standard admin-surface feature coverage is sufficient; no browser or heavy-governance lane is required for the first slice.
- **Reviewer handoff**: Reviewers must confirm that missing prerequisites and concurrency collisions are caught before queue acceptance, that covered start surfaces all use the same result vocabulary, and that accepted runs still follow the canonical monitoring feedback contract.
- **Budget / baseline / trend impact**: Low-to-moderate increase in feature assertions around start surfaces and provider-backed run outcomes; no new heavy cost center expected.
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderBackedActionStart`; `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderConnection`; `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=OperationRun`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Block Before Queue (Priority: P1)
An operator starts a provider-backed action and needs the product to stop immediately when the operation cannot legitimately start, instead of silently queueing work that only fails later.
**Why this priority**: This is the direct trust and workflow problem the spec exists to solve. If preventable failures still appear only as later failed runs, the spec has not delivered its core value.
**Independent Test**: Can be fully tested by triggering covered actions under missing-connection, unusable-access, and active-run-conflict conditions and verifying that the operator receives an immediate blocked, deduped, or scope busy result before any background work is accepted.
**Acceptance Scenarios**:
1. **Given** a covered provider-backed action has no usable provider connection or access, **When** an operator starts it, **Then** the product blocks the start immediately and shows the cause and next step before any background work begins.
2. **Given** an equivalent action is already active for the same tenant or scope, **When** an operator starts the same action again, **Then** the product returns a deduped or scope busy result and points the operator to the existing work instead of queueing a duplicate.
---
### User Story 2 - Same Start Semantics on Every Covered Surface (Priority: P2)
An operator encounters the same provider problem from different action surfaces and needs the product to describe it in the same way every time.
**Why this priority**: Shared start semantics are the leverage of the spec. Without cross-surface consistency, the product keeps teaching a different operational language per action host.
**Independent Test**: Can be fully tested by triggering the same blocked or allowed scenario from tenant surfaces, provider-connection surfaces, and onboarding, then verifying that all of them return the same result categories and next-step structure.
**Acceptance Scenarios**:
1. **Given** the same blocked prerequisite appears from a tenant action and from onboarding, **When** the operator starts both actions, **Then** both surfaces use the same outcome category and the same next-step pattern.
2. **Given** the same action can start from a tenant surface and a provider-connection surface, **When** the operator starts it from either place, **Then** both surfaces confirm acceptance using the same queued-start pattern and the same run-link semantics.
---
### User Story 3 - Keep Monitoring Truthful After Accepted Work (Priority: P3)
An operator starts provider-backed work successfully and later needs monitoring and notifications to explain accepted work in the same human-readable language family used on the start surface.
**Why this priority**: Start-surface trust must carry through to monitoring. Otherwise the product still feels split between action UX and run UX.
**Independent Test**: Can be fully tested by accepting covered work, letting it finish in successful or failed terminal states, and verifying that run detail and terminal notifications preserve the same translated problem and next-step direction without pretending a preflight block was an execution failure.
**Acceptance Scenarios**:
1. **Given** a provider-backed action was accepted and later fails for execution-time reasons, **When** the operator opens run detail, **Then** the page explains the dominant problem and next step using the same humanized language family as the start surface.
2. **Given** preflight prevented a covered action from starting, **When** the operator later checks monitoring, **Then** there is no misleading ordinary failed run implying that remote work actually executed.
### Edge Cases
- Membership or capability denial must retain 404 and 403 semantics and must not be flattened into generic blocked-prerequisite copy.
- A provider connection can change after click time; accepted work must remain bound to the selected connection identity or record that identity before execution continues.
- Legacy operator-triggered actions that still rely on implicit provider resolution remain in scope for start gating even before full provider-connection normalization lands.
- Scheduled or system-triggered provider work is out of scope for immediate click-time feedback, but any shared reason language reused by monitoring must stay aligned with the covered start contract.
- Busy, blocked, and degraded follow-up conditions can overlap conceptually; the operator surface must lead with one dominant result and one next step instead of several equal-weight warnings.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes how existing provider-backed remote work is admitted to execution. It does not introduce new remote domains or new contract-registry object types. All underlying provider calls remain on the existing contract path. The gate runs before remote work is accepted and must preserve tenant isolation, run observability for accepted work, and focused regression coverage.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds one shared start-gate expansion and one shared result-presentation contract because the operator problem is already cross-surface and cannot be solved safely with local action copy. No new persisted entity, no second source of truth, and no new top-level operation state family are introduced.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature coverage over existing action hosts and monitoring surfaces. No browser or heavy-governance family is added. Any helper introduced for provider start fixtures must keep provider, workspace, membership, and active-run context explicit rather than default.
**Constitution alignment (OPS-UX):** Accepted provider-backed starts continue to use the three-surface feedback contract: queued intent feedback, active progress surfaces, and one terminal database notification. Preflight-blocked starts do not emit accepted-start feedback and must not masquerade as ordinary execution-failed runs. Any accepted run continues to use the canonical run-transition service and numeric summary-count rules.
**Constitution alignment (RBAC-UX):** Covered start surfaces live on the tenant/admin plane and the canonical monitoring plane. Non-members or non-entitled viewers remain 404. Established members missing the required capability remain 403. Server-side authorization remains authoritative for every covered start action, and preflight guidance must never leak another tenant's connection or active-run existence.
**Constitution alignment (OPS-EX-AUTH-001):** No authentication-handshake exception is used. This feature does not introduce synchronous outbound provider work on monitoring or auth routes.
**Constitution alignment (BADGE-001):** Start-result and run-outcome semantics remain centralized. Covered surfaces must not invent local status mappings for blocked, deduped, scope busy, or accepted provider-backed starts.
**Constitution alignment (UI-FIL-001):** Covered surfaces continue to use native admin actions, shared notifications, and shared monitoring detail primitives. The feature standardizes action feedback and explanation order without introducing custom local status widgets.
**Constitution alignment (UI-NAMING-001):** The target objects remain the existing operation nouns such as sync, restore, capture, generate, or check. The canonical start-result model remains `accepted`, `deduped`, `scope busy`, and `blocked`, while operator-facing wording maps those outcomes to `queued`, `already running`, `scope busy`, and `blocked`. That mapping and next-step guidance must stay aligned across buttons, modals, run links, notifications, and monitoring detail.
**Canonical vocabulary mapping:** The shared start-result model uses the internal outcomes `accepted`, `deduped`, `scope busy`, and `blocked`. Operator-facing wording presents those outcomes respectively as `queued`, `already running`, `scope busy`, and `blocked`.
**Constitution alignment (DECIDE-001):** Tenant-scoped start actions and onboarding remain the primary decision moments because they determine whether remote work begins. Provider-connection surfaces remain supporting context for resolving blocked starts. Canonical run detail remains the diagnostic drill-in after work was actually accepted.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature does not add a new inspect model or new primary pages. Existing action hosts keep navigation separate from mutation, secondary navigation stays grouped or contextual, destructive actions remain in their current danger placements, and run detail remains the canonical diagnostic destination for accepted work.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Covered surfaces may standardize start-result feedback, but they must not mix navigation, mutation, and remediation into an unstructured catch-all. Existing start actions remain the primary action. Existing run links or resolution links remain secondary and contextual.
### Functional Requirements
- **FR-216-001**: Every current operator-triggered provider-backed action that can accept remote provider work MUST pass through one canonical preflight-and-dispatch path before background execution is admitted.
- **FR-216-002**: The canonical start path MUST use one shared result model with exactly four canonical outcomes: accepted, deduped, scope busy, and blocked. Operator-facing wording MUST present those outcomes consistently as queued, already running, scope busy, and blocked.
- **FR-216-003**: Missing or unusable provider access, missing required permissions, non-operable tenant state, and equivalent start-preventing conditions MUST be detected before queue admission on all covered start surfaces.
- **FR-216-004**: A covered action that is blocked during preflight MUST not silently queue work and later appear as an ordinary execution-failed run.
- **FR-216-005**: Deduped and scope busy outcomes MUST be decided at click time for covered operations and MUST direct the operator to the existing work or the correct resolution path.
- **FR-216-006**: Accepted starts MUST bind to a specific provider connection identity and carry that identity into accepted-work context so later monitoring and notifications can explain which connection the work used.
- **FR-216-007**: Covered starts MUST use dispatch-time conflict protection so two conflicting provider-backed operations for the same protected scope cannot both be accepted at once.
- **FR-216-008**: Covered start surfaces MUST render the shared start outcomes through one shared presentation contract rather than action-local conditional copy.
- **FR-216-009**: The shared presentation contract MUST preserve each action's domain verb and target object while keeping the outcome vocabulary and next-step structure consistent across all covered start surfaces.
- **FR-216-010**: Terminal notifications and canonical run detail for accepted provider-backed work MUST use the same translated problem and next-step direction as the covered start surfaces whenever they are explaining the same problem class.
- **FR-216-011**: Existing guarded provider-backed starts that already use the canonical preflight pattern MUST migrate to the shared presentation contract without losing any current protection.
- **FR-216-012**: The first implementation slice MUST cover all current operator-triggered provider-backed starts reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding.
- **FR-216-013**: Scheduled or system-triggered provider operations are out of scope for click-time UX. In scope for this feature is only that Monitoring-side shared reason vocabulary and initiator-aware notification behavior for those runs remain compatible with the covered start contract where the same problem class is shown.
- **FR-216-014**: The feature MUST not introduce a second provider-start framework, a parallel queue-admission path, or new per-action local preflight logic for covered surfaces.
- **FR-216-015**: Monitoring and audit surfaces MUST continue to distinguish between work that was actually accepted and executed versus work that was prevented from starting.
- **FR-216-016**: Covered start and monitoring surfaces MUST preserve deny-as-not-found tenant isolation and must not leak other tenants' connection readiness, active-run existence, or provider identity through start-result messaging.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant-scoped provider-backed start surfaces | Existing tenant detail pages and tenant-scoped resource pages that already expose provider-backed starts | Existing start actions such as sync, restore, capture, generate, or verify remain; no new header action is introduced by this spec | Existing inspect model remains unchanged on each host surface | Existing safe start affordances remain; no new visible destructive row action is added | Existing grouped bulk actions remain unchanged | Existing empty-state guidance remains unchanged | Existing start actions continue where already present | Existing create and edit flows remain unchanged | Existing operation-start and accepted-run audit behavior remains authoritative | This spec standardizes preflight result handling, not action inventory |
| Provider-connection resource surfaces | Existing provider-connection list and detail surfaces | Existing connection-management actions remain; connection-scoped checks adopt the shared start-result pattern where relevant | Full-row click or explicit inspect behavior remains unchanged | Existing safe actions such as inspect or connection health checks remain; no new destructive row action is added | Existing grouped bulk actions remain unchanged | Existing add-connection CTA remains unchanged | Existing connection-scoped start actions continue where already present | Existing create and edit flows remain unchanged | Existing connection lifecycle and accepted-run audit behavior remains authoritative | No connection-label redesign is introduced here |
| Onboarding provider step | Existing onboarding wizard step that triggers provider-backed verification or follow-up work | none added | n/a | n/a | none | Existing continue/setup CTA remains | Existing step actions remain the only primary controls | Existing save/continue flow remains | Existing onboarding audit behavior remains authoritative | The step adopts the shared blocked, deduped, scope busy, and accepted semantics |
| Canonical provider-backed operation run detail | Existing canonical monitoring detail surface | no new header action | Existing run detail open model remains canonical | none added | none | n/a | Existing related navigation remains | n/a | Existing run audit behavior remains authoritative | Detail hierarchy changes only insofar as it must stay aligned with the shared start-result language |
### Key Entities *(include if feature involves data)*
- **Provider-backed Start Surface**: Any existing operator-facing action host that can admit remote provider work and therefore must answer whether the operation can start now.
- **Preflight Start Result**: The shared start outcome for a covered action, including the canonical results accepted, deduped, scope busy, or blocked plus the operator-facing mapping to queued, already running, scope busy, or blocked, the short reason, and the next-step direction.
- **Accepted Provider-backed Run Context**: The canonical accepted-work context that records scope identity, chosen provider connection, and the related run or resolution path once work is admitted.
## Assumptions & Dependencies
- Provider Connection Resolution Normalization remains a soft dependency. This spec may use bridge behavior where current operator-triggered starts still rely on legacy provider-resolution internals, but accepted work must still record the chosen connection identity consistently.
- Shared outcome vocabulary and translated next-step guidance from the operator-truth lane are available to extend rather than re-invent.
- No new provider domain, no platform-wide operation naming rewrite, and no provider-connection UX relabeling are pulled into this scope.
- Existing canonical monitoring detail and terminal notification behavior for accepted runs remain authoritative and are aligned, not replaced.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-216-001**: In regression coverage for all covered start surfaces, 100% of seeded missing-prerequisite and scope-conflict scenarios are surfaced to the operator before any background work is accepted.
- **SC-216-002**: In release review across the covered start surfaces, operators encounter exactly one shared public start vocabulary: queued, already running, scope busy, and blocked.
- **SC-216-003**: In concurrent-start regression scenarios for the same protected scope, at most one provider-backed operation is accepted and all additional attempts are resolved as deduped or scope busy before queue admission.
- **SC-216-004**: In acceptance review, an operator can identify the next action for a blocked covered start within 10 seconds without opening low-level diagnostics.
- **SC-216-005**: Equivalent prerequisite problems no longer surface as immediate blocked guidance on one covered action and only as an after-the-fact ordinary failed run on another covered action.
- **SC-216-006**: In regression coverage for scheduled or system-triggered provider-backed runs that reuse shared reason translation, Monitoring preserves the same public wording for the same problem class without emitting initiator-only completion UX meant for click-time starts.

View File

@ -1,242 +0,0 @@
# Tasks: Provider-Backed Action Preflight and Dispatch Gate Unification
**Input**: Design documents from `/specs/216-provider-dispatch-gate/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature keeps proof in the `fast-feedback` and `confidence` lanes with one supporting unit seam and focused feature coverage on the existing Filament and Livewire action hosts.
## Phase 1: Setup (Shared Gate Scaffolding)
**Purpose**: Introduce the bounded shared testing and presentation seams needed before touching operator-facing start hosts.
- [x] T001 [P] Add failing unit coverage for newly covered operation types, explicit `provider_connection_id` binding, and onboarding bootstrap protected-scope admission in `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`
- [x] T002 [P] Add failing unit coverage for shared accepted, deduped, scope busy, and blocked presentation behavior in `apps/platform/tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php`
- [x] T003 [P] Create the bounded shared presenter for provider-backed start outcomes in `apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Extend the canonical gate, reason translation, and shared Ops UX wiring that every user story depends on.
**Critical**: No user story work should begin until this phase is complete.
- [x] T004 Expand first-slice provider-backed operation definitions and dispatch callbacks in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`
- [x] T005 Update canonical gate and result plumbing for explicit `provider_connection_id`, protected-scope conflict handling, and blocked-start truth in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` and `apps/platform/app/Services/Providers/ProviderOperationStartResult.php`
- [x] T006 [P] Extend provider blocker translation and next-step mapping for the shared start contract in `apps/platform/app/Support/Providers/ProviderNextStepsRegistry.php` and `apps/platform/app/Support/ReasonTranslation/ReasonPresenter.php`
- [x] T007 [P] Wire the shared presenter into existing toast and canonical run-link primitives in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php`, and `apps/platform/app/Support/OperationRunLinks.php`
- [x] T008 [P] Keep capability and alias-aware operation metadata aligned for the covered start types in `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php` and `apps/platform/app/Support/OperationCatalog.php`
**Checkpoint**: The canonical gate, presenter seam, and shared reason vocabulary are ready; user story work can now proceed.
---
## Phase 3: User Story 1 - Block Before Queue (Priority: P1)
**Goal**: Every covered operator-triggered provider-backed start resolves blocked, deduped, and scope busy outcomes before queue admission.
**Independent Test**: Trigger covered starts under missing-connection, unusable-access, same-operation active-run, and conflicting active-run conditions and verify the operator gets an immediate blocked, deduped, or scope busy result before any background work is accepted.
### Tests for User Story 1
- [x] T009 [P] [US1] Add covered tenant and provider-connection gate-admission coverage for blocked, deduped, scope busy, and accepted starts in `apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php`
- [x] T010 [P] [US1] Add restore and directory gate-admission coverage for explicit connection pinning and no-queue-on-block behavior in `apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php` and `apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php`
- [x] T011 [P] [US1] Add onboarding verification and bootstrap gate-admission coverage, including protected-scope serialization, in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`
### Implementation for User Story 1
- [x] T012 [P] [US1] Migrate restore execute to canonical gate admission and explicit connection-aware queued execution in `apps/platform/app/Filament/Resources/RestoreRunResource.php` and `apps/platform/app/Jobs/ExecuteRestoreRunJob.php`
- [x] T013 [P] [US1] Migrate directory sync action hosts and queued jobs to canonical gate admission and explicit connection-aware execution in `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Services/Directory/EntraGroupSyncService.php`, `apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php`, `apps/platform/app/Jobs/EntraGroupSyncJob.php`, and `apps/platform/app/Jobs/SyncRoleDefinitionsJob.php`
- [x] T014 [P] [US1] Migrate onboarding bootstrap to canonical gate admission and sequential protected-scope execution in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T015 [US1] Verify existing gated tenant and provider-connection starts keep explicit connection context and no-queue-on-block behavior in `apps/platform/app/Services/Verification/StartVerification.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
- [x] T016 [US1] Run the US1 focused verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md`
**Checkpoint**: User Story 1 is independently deliverable as the core block-before-queue hardening slice.
---
## Phase 4: User Story 2 - Same Start Semantics on Every Covered Surface (Priority: P2)
**Goal**: Covered tenant, provider-connection, restore, directory, and onboarding surfaces use one shared public start vocabulary and next-step structure.
**Independent Test**: Trigger the same blocked and accepted scenarios from tenant surfaces, provider-connection surfaces, onboarding, restore, and directory starts and verify all of them render the same queued, already running, scope busy, and blocked wording with the same run-link and next-step pattern.
### Tests for User Story 2
- [x] T017 [P] [US2] Add cross-surface queued, already running, scope busy, and blocked vocabulary parity assertions for tenant and provider-connection hosts in `apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php`
- [x] T018 [P] [US2] Add onboarding, restore, and directory shared-language plus 404 and 403 assertions in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`, `apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php`, `apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`, and `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
- [x] T019 [P] [US2] Update Filament action-surface contract coverage for the affected start hosts in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 2
- [x] T020 [US2] Replace tenant and provider-connection local start-result branching with the shared presenter and public queued or already running vocabulary in `apps/platform/app/Services/Verification/StartVerification.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
- [x] T021 [P] [US2] Apply the shared presenter and safe continuation wording to onboarding verification and bootstrap surfaces in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T022 [US2] Apply the shared presenter and aligned `Verb + Object` copy plus queued or already running wording to restore and directory start surfaces in `apps/platform/app/Filament/Resources/RestoreRunResource.php`, `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, and `apps/platform/app/Filament/Resources/TenantResource.php`
- [x] T023 [US2] Run the US2 cross-surface verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md`
**Checkpoint**: User Stories 1 and 2 both work independently, with start gating and operator copy fully aligned across the covered action hosts.
---
## Phase 5: User Story 3 - Keep Monitoring Truthful After Accepted Work (Priority: P3)
**Goal**: Accepted provider-backed work preserves the same translated problem and next-step direction in Monitoring and terminal notifications without turning prevented starts into ordinary execution failures.
**Independent Test**: Accept covered provider-backed work, let it finish in successful or failed terminal states, and verify canonical run detail and terminal notifications stay aligned with the start contract while blocked starts remain distinguishable from executed work.
### Tests for User Story 3
- [x] T024 [P] [US3] Add provider-backed run-detail reason-alignment, canonical view-run-link coverage, and scheduled or system-run shared-vocabulary compatibility assertions in `apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php` and `apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
- [x] T025 [P] [US3] Add blocked-vs-executed monitoring truth, initiator-only no-queued-db-notification, and non-initiator compatibility assertions in `apps/platform/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php`, `apps/platform/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `apps/platform/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php`
### Implementation for User Story 3
- [x] T026 [P] [US3] Align terminal notification translation, initiator-only delivery, and canonical view-run actions with the shared start contract in `apps/platform/app/Notifications/OperationRunCompleted.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/OperationRunLinks.php`
- [x] T027 [P] [US3] Align canonical Monitoring run detail with accepted provider-backed start semantics and scheduled or system-run shared reason reuse in `apps/platform/app/Filament/Resources/OperationRunResource.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [x] T028 [P] [US3] Keep provider-backed jobs on service-owned lifecycle transitions and flat summary-count discipline in `apps/platform/app/Jobs/ExecuteRestoreRunJob.php`, `apps/platform/app/Jobs/EntraGroupSyncJob.php`, `apps/platform/app/Jobs/SyncRoleDefinitionsJob.php`, and `apps/platform/app/Services/OperationRunService.php`
- [x] T029 [US3] Run the US3 monitoring and notification verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md`
**Checkpoint**: All user stories are independently functional and monitoring truth stays aligned with the accepted-start contract.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finalize lane metadata, contract notes, and close-out proof across all stories.
- [x] T030 [P] Add and register a route-bounded first-slice direct-dispatch bypass guard in `apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php`, `apps/platform/tests/Support/TestLaneManifest.php`, and `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php`
- [x] T031 [P] Refresh the logical start contract and verification steps after implementation in `specs/216-provider-dispatch-gate/contracts/provider-dispatch-gate.logical.openapi.yaml` and `specs/216-provider-dispatch-gate/quickstart.md`
- [x] T032 Run formatting and the final focused Pest command set documented in `specs/216-provider-dispatch-gate/quickstart.md`
- [x] T033 Record the final lane outcome, route-bounded covered-surface audit, guardrail close-out, and `document-in-feature` test-governance disposition in `specs/216-provider-dispatch-gate/plan.md`
---
## 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 heavy-governance or 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 or `standard-native-filament` relief is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow the stable gate adoption from User Story 1 on shared action hosts.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and on the shared start vocabulary being stable enough for Monitoring parity.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: This is the MVP and should ship first.
- **User Story 2 (P2)**: Conceptually independent after Phase 2, but it reuses the shared presenter and touches some of the same action hosts stabilized in User Story 1.
- **User Story 3 (P3)**: Conceptually independent after Phase 2, but it must preserve parity with the accepted-start contract implemented by User Stories 1 and 2.
### Within Each User Story
- Tests should be written and fail before the corresponding implementation tasks.
- Shared gate and presenter changes must land before any surface-specific adoption task uses them.
- For shared files such as `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, serialize edits even when the surrounding story tasks are otherwise parallelizable.
- Finish each story's verification task before moving to the next priority when working sequentially.
### Parallel Opportunities
- **Setup**: `T001`, `T002`, and `T003` can run in parallel.
- **Foundational**: `T006`, `T007`, and `T008` can run in parallel after `T004` and `T005` settle the canonical gate contract.
- **US1 tests**: `T009`, `T010`, and `T011` can run in parallel.
- **US1 implementation**: `T012`, `T013`, and `T014` can run in parallel; `T015` should follow once the legacy-start migrations are in place.
- **US2 tests**: `T017`, `T018`, and `T019` can run in parallel.
- **US2 implementation**: `T021` can run in parallel with `T020` or `T022`, but `T020` and `T022` should serialize because both touch `apps/platform/app/Filament/Resources/TenantResource.php`.
- **US3**: `T024` and `T025` can run in parallel, and `T026`, `T027`, and `T028` can run in parallel once the test expectations are fixed.
- **Polish**: `T030` and `T031` can run in parallel before `T032` and `T033`.
---
## Parallel Example: User Story 1
```bash
# Run US1 coverage in parallel:
T009 apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php and apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php
T010 apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php and apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php
T011 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php
# Then split the non-overlapping legacy start migrations:
T012 apps/platform/app/Filament/Resources/RestoreRunResource.php and apps/platform/app/Jobs/ExecuteRestoreRunJob.php
T013 apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php, apps/platform/app/Services/Directory/EntraGroupSyncService.php, apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php, apps/platform/app/Jobs/EntraGroupSyncJob.php, and apps/platform/app/Jobs/SyncRoleDefinitionsJob.php
T014 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
```
---
## Parallel Example: User Story 2
```bash
# Run US2 parity and contract assertions in parallel:
T017 apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php and apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php
T018 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php, apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php, apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php, apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php, and apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php
T019 apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
# Then split presenter adoption where files do not overlap:
T020 apps/platform/app/Services/Verification/StartVerification.php, apps/platform/app/Filament/Resources/TenantResource.php, apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php, apps/platform/app/Filament/Resources/ProviderConnectionResource.php, and apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php
T021 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
```
---
## Parallel Example: User Story 3
```bash
# Run US3 monitoring and notification assertions in parallel:
T024 apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php and apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
T025 apps/platform/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php, apps/platform/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php, and apps/platform/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php
# Then split the non-overlapping Monitoring and notification implementation:
T026 apps/platform/app/Notifications/OperationRunCompleted.php, apps/platform/app/Support/OpsUx/OperationUxPresenter.php, and apps/platform/app/Support/OperationRunLinks.php
T027 apps/platform/app/Filament/Resources/OperationRunResource.php and apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
T028 apps/platform/app/Jobs/ExecuteRestoreRunJob.php, apps/platform/app/Jobs/EntraGroupSyncJob.php, apps/platform/app/Jobs/SyncRoleDefinitionsJob.php, and apps/platform/app/Services/OperationRunService.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Stop and validate with `T016`.
5. Demo or ship the block-before-queue hardening before layering shared copy and Monitoring parity on top.
### Incremental Delivery
1. Setup and Foundational establish the canonical gate, presenter seam, and shared blocker vocabulary.
2. Add User Story 1 and validate legacy start migration and protected-scope admission.
3. Add User Story 2 and validate cross-surface copy, next-step, and authorization consistency.
4. Add User Story 3 and validate Monitoring and terminal notification parity.
5. Finish with lane metadata, quickstart and contract refresh, formatting, and close-out proof.
### Parallel Team Strategy
With multiple developers:
1. Complete Setup and Foundational together.
2. After Phase 2:
- Developer A: restore and directory start migration in `apps/platform/app/Filament/Resources/RestoreRunResource.php`, `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, and related jobs.
- Developer B: onboarding migration and shared presenter adoption in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`.
- Developer C: tenant and provider-connection shared presenter adoption plus Monitoring parity in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Resources/OperationRunResource.php`.
3. Serialize edits in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` because multiple stories touch those files.
---
## Notes
- `[P]` marks tasks that can run in parallel once their prerequisites are satisfied and the files do not overlap.
- `[US1]`, `[US2]`, and `[US3]` map directly to the spec's independently testable user stories.
- The narrowest proving lane remains `fast-feedback` plus `confidence`; do not widen into browser or heavy-governance without explicit follow-up justification.
- Keep new fixtures and helpers opt-in so provider, workspace, membership, active-run, and onboarding draft context do not become expensive test defaults.