Add cross-tenant promotion execution (spec 264) #320
@ -12,8 +12,13 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||
@ -23,13 +28,16 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use DomainException;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
@ -192,6 +200,7 @@ protected function getHeaderActions(): array
|
||||
->label('Generate promotion preflight')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => ! is_array($this->preflight))
|
||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||
@ -201,6 +210,7 @@ protected function getHeaderActions(): array
|
||||
fn (): ?Workspace => $this->workspace(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveVisibility()
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||
->apply()
|
||||
@ -223,6 +233,19 @@ protected function getHeaderActions(): array
|
||||
|
||||
$actions[] = $preflightAction;
|
||||
|
||||
$actions[] = Action::make('executePromotion')
|
||||
->label('Execute promotion')
|
||||
->icon('heroicon-o-play')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => is_array($this->preflight))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Execute promotion')
|
||||
->modalDescription(fn (): string => $this->executePromotionConfirmationDescription())
|
||||
->modalSubmitActionLabel('Queue promotion')
|
||||
->disabled(fn (): bool => $this->executePromotionDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->executePromotionDisabledReason())
|
||||
->action(fn (): mixed => $this->executePromotion());
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
@ -282,6 +305,74 @@ public function generatePromotionPreflight(): void
|
||||
}
|
||||
}
|
||||
|
||||
public function executePromotion(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->authorizePromotionExecution();
|
||||
|
||||
if (! is_array($this->preview) || ! is_array($this->preflight)) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Generate a current promotion preflight before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Refresh the compare selection before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = app(CrossTenantPromotionExecutionService::class)->start(
|
||||
selection: $selection,
|
||||
preview: $this->preview,
|
||||
preflight: $this->preflight,
|
||||
actor: $user,
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
} catch (DomainException|InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||
result: $result,
|
||||
blockedTitle: 'Promotion execution blocked',
|
||||
runUrl: OperationRunLinks::tenantlessView($result->run),
|
||||
scopeBusyTitle: 'Promotion scope busy',
|
||||
scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.',
|
||||
);
|
||||
|
||||
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}
|
||||
|
||||
public function clearSelectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters([
|
||||
@ -453,6 +544,30 @@ private function authorizePreflightExecution(): void
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizePromotionExecution(): void
|
||||
{
|
||||
$this->authorizePreflightExecution();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $targetTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compareSelection(): ?CrossTenantCompareSelection
|
||||
{
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
@ -593,6 +708,73 @@ private function preflightDisabledReason(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionDisabledReason(): ?string
|
||||
{
|
||||
if ($this->selectionMessage !== null) {
|
||||
return $this->selectionMessage;
|
||||
}
|
||||
|
||||
if (! is_array($this->preview)) {
|
||||
return 'Run compare preview before executing promotion.';
|
||||
}
|
||||
|
||||
if (! is_array($this->preflight)) {
|
||||
return 'Generate a current promotion preflight before executing promotion.';
|
||||
}
|
||||
|
||||
if ((int) data_get($this->preflight, 'summary.ready', 0) <= 0) {
|
||||
return 'Current promotion preflight has no ready governed subjects to execute.';
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if ($workspaceResolver->isMember($user, $workspace)
|
||||
&& ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return 'You need workspace baseline manage access to execute promotion.';
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You need target tenant manage access to execute promotion.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionConfirmationDescription(): string
|
||||
{
|
||||
$selection = $this->compareSelection();
|
||||
$ready = (int) data_get($this->preflight, 'summary.ready', 0);
|
||||
$blocked = (int) data_get($this->preflight, 'summary.blocked', 0);
|
||||
$manualMappingRequired = (int) data_get($this->preflight, 'summary.manual_mapping_required', 0);
|
||||
$excluded = $blocked + $manualMappingRequired;
|
||||
|
||||
$sourceTenantName = $selection?->sourceTenant->name ?? 'Source tenant';
|
||||
$targetTenantName = $selection?->targetTenant->name ?? 'Target tenant';
|
||||
|
||||
return sprintf(
|
||||
'Queue one promotion run from %s to %s for %d ready governed subject%s. %d subject%s remain excluded on the compare page.',
|
||||
$sourceTenantName,
|
||||
$targetTenantName,
|
||||
$ready,
|
||||
$ready === 1 ? '' : 's',
|
||||
$excluded,
|
||||
$excluded === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
|
||||
@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class CrossTenantPromotionExecutionJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 420;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(OperationRun $operationRun)
|
||||
{
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [
|
||||
new EnsureQueuedExecutionLegitimate,
|
||||
new TrackOperationRun,
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRuns,
|
||||
RestoreService $restoreService,
|
||||
TargetScopeConcurrencyLimiter $limiter,
|
||||
WorkspaceAuditLogger $auditLogger,
|
||||
): void {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for promotion execution.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === OperationRunStatus::Completed->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $this->operationRun->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Promotion execution target tenant is missing.');
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot((int) $tenant->getKey(), $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$this->release(max(1, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$plan = is_array(data_get($context, 'promotion_execution.plan'))
|
||||
? data_get($context, 'promotion_execution.plan')
|
||||
: null;
|
||||
|
||||
if (! is_array($plan)) {
|
||||
throw new RuntimeException('Promotion execution plan is missing from operation context.');
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
$failures = [];
|
||||
|
||||
$items = is_array($plan['items'] ?? null) ? array_values(array_filter($plan['items'], 'is_array')) : [];
|
||||
$summary['total'] = count($items);
|
||||
|
||||
[$backupSet, $selectedItemIds, $preRestoreSummary, $preRestoreFailures] = $this->buildRestoreInputs(
|
||||
tenant: $tenant,
|
||||
operationRun: $this->operationRun,
|
||||
items: $items,
|
||||
);
|
||||
|
||||
$summary = array_replace($summary, $preRestoreSummary);
|
||||
$failures = array_merge($failures, $preRestoreFailures);
|
||||
|
||||
$restoreRun = null;
|
||||
|
||||
if ($selectedItemIds !== []) {
|
||||
$restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => $restoreService->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
dryRun: false,
|
||||
actorEmail: $this->operationRun->user?->email,
|
||||
actorName: $this->operationRun->initiator_name,
|
||||
providerConnectionId: is_numeric($context['provider_connection_id'] ?? null) ? (int) $context['provider_connection_id'] : null,
|
||||
));
|
||||
|
||||
RestoreRun::withoutEvents(function () use ($restoreRun): void {
|
||||
$restoreRun->forceFill(['operation_run_id' => (int) $this->operationRun?->getKey()])->save();
|
||||
});
|
||||
|
||||
[$restoreSummary, $restoreFailures] = $this->summaryFromRestoreRun($restoreRun, $items);
|
||||
$summary = $this->mergeSummary($summary, $restoreSummary);
|
||||
$failures = array_merge($failures, $restoreFailures);
|
||||
|
||||
$context['restore_run_id'] = (int) $restoreRun->getKey();
|
||||
$context['backup_set_id'] = (int) $backupSet->getKey();
|
||||
$this->operationRun->forceFill(['context' => $context])->save();
|
||||
} else {
|
||||
$backupSet?->delete();
|
||||
}
|
||||
|
||||
$outcome = $this->outcome($summary);
|
||||
|
||||
$updated = $operationRuns->updateRun(
|
||||
run: $this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summary,
|
||||
failures: $failures,
|
||||
);
|
||||
|
||||
$auditLogger->logCrossTenantPromotionExecutionCompleted(
|
||||
operationRun: $updated,
|
||||
sourceTenantId: is_numeric($context['source_tenant_id'] ?? null) ? (int) $context['source_tenant_id'] : null,
|
||||
targetTenant: $tenant,
|
||||
summaryCounts: $summary,
|
||||
restoreRun: $restoreRun,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
throw $exception;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
public function getOperationRun(): ?OperationRun
|
||||
{
|
||||
return $this->operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return array{0: ?BackupSet, 1: list<int>, 2: array<string, int>, 3: list<array{code: string, message: string}>}
|
||||
*/
|
||||
private function buildRestoreInputs(Tenant $tenant, OperationRun $operationRun, array $items): array
|
||||
{
|
||||
$summary = [
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
$failures = [];
|
||||
$backupSet = BackupSet::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Cross-tenant promotion • Operation #'.$operationRun->getKey(),
|
||||
'created_by' => $operationRun->user?->email,
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
'metadata' => [
|
||||
'source' => 'cross_tenant_promotion',
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
],
|
||||
]);
|
||||
$selectedItemIds = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$action = (string) ($item['execution_action'] ?? '');
|
||||
|
||||
if ($action === 'skip_aligned') {
|
||||
$summary['processed']++;
|
||||
$summary['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionId = data_get($item, 'source.policy_version_id');
|
||||
$sourceTenantId = data_get($item, 'source.tenant_id');
|
||||
|
||||
$version = is_numeric($versionId) && is_numeric($sourceTenantId)
|
||||
? PolicyVersion::query()
|
||||
->with('policy')
|
||||
->whereKey((int) $versionId)
|
||||
->where('tenant_id', (int) $sourceTenantId)
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if (! $version instanceof PolicyVersion || ! $version->policy instanceof Policy) {
|
||||
$summary['processed']++;
|
||||
$summary['failed']++;
|
||||
$failures[] = [
|
||||
'code' => 'promotion.source_version_missing',
|
||||
'message' => 'Source policy version for '.$this->itemLabel($item).' was not found.',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourcePolicy = $version->policy;
|
||||
$targetExternalId = data_get($item, 'target.subject_external_id');
|
||||
$sourceExternalId = data_get($item, 'source.subject_external_id');
|
||||
$policyIdentifier = is_string($targetExternalId) && trim($targetExternalId) !== ''
|
||||
? trim($targetExternalId)
|
||||
: (is_string($sourceExternalId) && trim($sourceExternalId) !== '' ? trim($sourceExternalId) : (string) $sourcePolicy->external_id);
|
||||
|
||||
$targetPolicy = Policy::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('policy_type', (string) $sourcePolicy->policy_type)
|
||||
->where('external_id', $policyIdentifier)
|
||||
->first();
|
||||
|
||||
$backupItem = BackupItem::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => $targetPolicy?->getKey(),
|
||||
'policy_identifier' => $policyIdentifier,
|
||||
'policy_type' => (string) $sourcePolicy->policy_type,
|
||||
'platform' => (string) $sourcePolicy->platform,
|
||||
'captured_at' => $version->captured_at ?? CarbonImmutable::now(),
|
||||
'payload' => is_array($version->snapshot) ? $version->snapshot : [],
|
||||
'metadata' => [
|
||||
'source' => 'cross_tenant_promotion',
|
||||
'display_name' => (string) $sourcePolicy->display_name,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'source_tenant_id' => (int) $sourcePolicy->tenant_id,
|
||||
'source_policy_id' => (int) $sourcePolicy->getKey(),
|
||||
'source_policy_version_id' => (int) $version->getKey(),
|
||||
'source_subject_key' => (string) ($item['subject_key'] ?? ''),
|
||||
'execution_action' => $action,
|
||||
'target_subject_external_id' => is_string($targetExternalId) ? $targetExternalId : null,
|
||||
],
|
||||
'assignments' => is_array($version->assignments) ? $version->assignments : [],
|
||||
]);
|
||||
|
||||
$selectedItemIds[] = (int) $backupItem->getKey();
|
||||
}
|
||||
|
||||
$backupSet->forceFill(['item_count' => count($selectedItemIds)])->save();
|
||||
|
||||
return [$backupSet, $selectedItemIds, $summary, $failures];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return array{0: array<string, int>, 1: list<array{code: string, message: string}>}
|
||||
*/
|
||||
private function summaryFromRestoreRun(RestoreRun $restoreRun, array $items): array
|
||||
{
|
||||
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
|
||||
$resultItems = is_array($results['items'] ?? null) ? $results['items'] : [];
|
||||
$succeeded = (int) ($metadata['succeeded'] ?? 0);
|
||||
$failed = (int) ($metadata['failed'] ?? 0) + (int) ($metadata['partial'] ?? 0);
|
||||
$skipped = (int) ($metadata['skipped'] ?? 0);
|
||||
$processed = $succeeded + $failed + $skipped;
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$failures = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$action = (string) ($item['execution_action'] ?? '');
|
||||
|
||||
if ($action === 'create_missing') {
|
||||
$created++;
|
||||
} elseif ($action === 'update_existing') {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($resultItems as $result) {
|
||||
if (! is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = (string) ($result['status'] ?? '');
|
||||
|
||||
if (in_array($status, ['applied', 'dry_run'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$failures[] = [
|
||||
'code' => 'promotion.restore_item_not_applied',
|
||||
'message' => (string) ($result['reason'] ?? 'Promotion restore item did not apply.'),
|
||||
];
|
||||
}
|
||||
|
||||
return [[
|
||||
'processed' => $processed,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
'created' => min($created, $succeeded),
|
||||
'updated' => min($updated, max(0, $succeeded - min($created, $succeeded))),
|
||||
], $failures];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $left
|
||||
* @param array<string, int> $right
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function mergeSummary(array $left, array $right): array
|
||||
{
|
||||
foreach ($right as $key => $value) {
|
||||
$left[$key] = (int) ($left[$key] ?? 0) + (int) $value;
|
||||
}
|
||||
|
||||
return $left;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summary
|
||||
*/
|
||||
private function outcome(array $summary): string
|
||||
{
|
||||
$total = (int) ($summary['total'] ?? 0);
|
||||
$failed = (int) ($summary['failed'] ?? 0);
|
||||
$succeeded = (int) ($summary['succeeded'] ?? 0);
|
||||
$skipped = (int) ($summary['skipped'] ?? 0);
|
||||
|
||||
if ($total > 0 && $failed >= $total) {
|
||||
return OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
return OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
if ($succeeded > 0 || $skipped > 0) {
|
||||
return OperationRunOutcome::Succeeded->value;
|
||||
}
|
||||
|
||||
return OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function itemLabel(array $item): string
|
||||
{
|
||||
$displayName = (string) ($item['display_name'] ?? '');
|
||||
|
||||
if ($displayName !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
return (string) ($item['subject_key'] ?? 'unknown subject');
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -176,6 +177,85 @@ public function logCrossTenantPromotionPreflightGenerated(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $plan
|
||||
*/
|
||||
public function logCrossTenantPromotionExecutionQueued(
|
||||
Workspace $workspace,
|
||||
Tenant $sourceTenant,
|
||||
Tenant $targetTenant,
|
||||
OperationRun $operationRun,
|
||||
array $plan,
|
||||
User|PlatformUser|null $actor = null,
|
||||
): \App\Models\AuditLog {
|
||||
$summary = is_array($plan['summary'] ?? null) ? $plan['summary'] : [];
|
||||
|
||||
return $this->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::CrossTenantPromotionExecutionQueued,
|
||||
context: [
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'source_tenant_name' => (string) $sourceTenant->name,
|
||||
'target_tenant_id' => (int) $targetTenant->getKey(),
|
||||
'target_tenant_name' => (string) $targetTenant->name,
|
||||
'selection' => is_array($plan['selection'] ?? null) ? $plan['selection'] : [],
|
||||
'ready_count' => (int) ($summary['ready'] ?? 0),
|
||||
'excluded_count' => (int) ($summary['excluded'] ?? 0),
|
||||
'created_count' => (int) ($summary['created'] ?? 0),
|
||||
'updated_count' => (int) ($summary['updated'] ?? 0),
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'queued',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
targetLabel: $sourceTenant->name.' -> '.$targetTenant->name,
|
||||
summary: 'Cross-tenant promotion execution queued for '.$sourceTenant->name.' -> '.$targetTenant->name,
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
tenant: $targetTenant,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
*/
|
||||
public function logCrossTenantPromotionExecutionCompleted(
|
||||
OperationRun $operationRun,
|
||||
?int $sourceTenantId,
|
||||
Tenant $targetTenant,
|
||||
array $summaryCounts,
|
||||
?RestoreRun $restoreRun = null,
|
||||
): \App\Models\AuditLog {
|
||||
$context = is_array($operationRun->context) ? $operationRun->context : [];
|
||||
$sourceTenantName = is_string($context['source_tenant_name'] ?? null)
|
||||
? (string) $context['source_tenant_name']
|
||||
: null;
|
||||
|
||||
return $this->log(
|
||||
workspace: $targetTenant->workspace,
|
||||
action: AuditActionId::CrossTenantPromotionExecutionCompleted,
|
||||
context: [
|
||||
'source_tenant_id' => $sourceTenantId,
|
||||
'source_tenant_name' => $sourceTenantName,
|
||||
'target_tenant_id' => (int) $targetTenant->getKey(),
|
||||
'target_tenant_name' => (string) $targetTenant->name,
|
||||
'summary_counts' => $summaryCounts,
|
||||
'restore_run_id' => $restoreRun?->getKey(),
|
||||
'operation_outcome' => (string) $operationRun->outcome,
|
||||
],
|
||||
status: match ((string) $operationRun->outcome) {
|
||||
'failed' => 'failed',
|
||||
'partially_succeeded' => 'partial',
|
||||
default => 'success',
|
||||
},
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
targetLabel: ($sourceTenantName !== null ? $sourceTenantName.' -> ' : '').$targetTenant->name,
|
||||
summary: 'Cross-tenant promotion execution completed for '.(($sourceTenantName !== null ? $sourceTenantName.' -> ' : '')).$targetTenant->name,
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
tenant: $targetTenant,
|
||||
);
|
||||
}
|
||||
|
||||
public function logSupportRequestCreated(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor = null,
|
||||
|
||||
@ -86,6 +86,19 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($context->workspaceRequiredCapability !== null) {
|
||||
$checks['capability'] = $this->initiatorHasRequiredWorkspaceCapability($context) ? 'passed' : 'failed';
|
||||
|
||||
if ($checks['capability'] === 'failed') {
|
||||
return QueuedExecutionLegitimacyDecision::deny(
|
||||
$context,
|
||||
$checks,
|
||||
ExecutionDenialReasonCode::MissingCapability,
|
||||
['required_capability' => $context->workspaceRequiredCapability],
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (! $this->isSystemAuthorityAllowed($context->operationType)) {
|
||||
$checks['execution_prerequisites'] = 'failed';
|
||||
@ -151,6 +164,9 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
|
||||
requiredCapability: is_string($context['required_capability'] ?? null)
|
||||
? $context['required_capability']
|
||||
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
|
||||
workspaceRequiredCapability: is_string($context['workspace_required_capability'] ?? null)
|
||||
? $context['workspace_required_capability']
|
||||
: null,
|
||||
providerConnectionId: $providerConnectionId,
|
||||
targetScope: [
|
||||
'workspace_id' => $workspaceId,
|
||||
@ -259,6 +275,29 @@ private function initiatorHasRequiredCapability(QueuedExecutionContext $context)
|
||||
);
|
||||
}
|
||||
|
||||
private function initiatorHasRequiredWorkspaceCapability(QueuedExecutionContext $context): bool
|
||||
{
|
||||
if (! $context->initiator instanceof User || ! is_string($context->workspaceRequiredCapability) || $context->workspaceRequiredCapability === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($context->workspaceId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = $context->run->tenant?->workspace ?? $context->run->workspace()->first();
|
||||
|
||||
if ($workspace === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->workspaceCapabilityResolver->can(
|
||||
$context->initiator,
|
||||
$workspace,
|
||||
$context->workspaceRequiredCapability,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@ -270,7 +309,7 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
||||
$prerequisites[] = 'provider_connection';
|
||||
}
|
||||
|
||||
if (str_starts_with($operationType, 'restore.')) {
|
||||
if (str_starts_with($operationType, 'restore.') || $operationType === 'promotion.execute') {
|
||||
$prerequisites[] = 'write_gate';
|
||||
}
|
||||
|
||||
@ -279,6 +318,10 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
||||
|
||||
private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion
|
||||
{
|
||||
if ($context->operationType === 'promotion.execute') {
|
||||
return TenantOperabilityQuestion::RestoreEligibility;
|
||||
}
|
||||
|
||||
if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) {
|
||||
return TenantOperabilityQuestion::VerificationReadinessEligibility;
|
||||
}
|
||||
|
||||
@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\PortfolioCompare;
|
||||
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionExecutionPlanner;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class CrossTenantPromotionExecutionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CrossTenantPromotionExecutionPlanner $planner,
|
||||
private readonly OperationRunService $operationRuns,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
private readonly OperationalControlEvaluator $operationalControls,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $preview
|
||||
* @param array<string, mixed> $preflight
|
||||
*/
|
||||
public function start(
|
||||
CrossTenantCompareSelection $selection,
|
||||
array $preview,
|
||||
array $preflight,
|
||||
User $actor,
|
||||
): ProviderOperationStartResult {
|
||||
$workspace = $selection->targetTenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new \RuntimeException('Promotion execution requires a workspace context.');
|
||||
}
|
||||
|
||||
$decision = $this->operationalControls->evaluate('promotion.execute', $workspace);
|
||||
|
||||
if ($decision->isPaused()) {
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||
context: [
|
||||
'metadata' => array_filter([
|
||||
'control_key' => $decision->controlKey,
|
||||
'scope_type' => $decision->matchedScopeType,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'reason_text' => $decision->reasonText,
|
||||
'expires_at' => $decision->expiresAt?->toIso8601String(),
|
||||
'actor_id' => (int) $actor->getKey(),
|
||||
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
|
||||
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||
'requested_scope' => 'promotion.execute',
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'blocked',
|
||||
resourceType: 'operational_control',
|
||||
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||
targetLabel: 'Promotion execution',
|
||||
summary: 'Promotion execution blocked by operational control',
|
||||
tenant: $selection->targetTenant,
|
||||
);
|
||||
|
||||
throw OperationalControlBlockedException::forDecision($decision, 'Promotion execution');
|
||||
}
|
||||
|
||||
$plan = $this->planner->build($preview, $preflight);
|
||||
$providerConnection = $this->defaultProviderConnection((int) $selection->targetTenant->getKey());
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
$identity = array_replace($plan['identity'], [
|
||||
'provider_connection_id' => $providerConnection?->getKey(),
|
||||
]);
|
||||
|
||||
$context = [
|
||||
'operation_type' => 'promotion.execute',
|
||||
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
|
||||
'source_tenant_name' => (string) $selection->sourceTenant->name,
|
||||
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||
'target_tenant_name' => (string) $selection->targetTenant->name,
|
||||
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
'target_scope' => [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
|
||||
'entra_tenant_id' => $providerConnection instanceof ProviderConnection
|
||||
? (string) $providerConnection->entra_tenant_id
|
||||
: (string) ($selection->targetTenant->tenant_id ?? $selection->targetTenant->external_id ?? $selection->targetTenant->getKey()),
|
||||
],
|
||||
'promotion_execution' => [
|
||||
'queued_at' => $now->toIso8601String(),
|
||||
'queued_by_user_id' => (int) $actor->getKey(),
|
||||
'plan' => $plan,
|
||||
],
|
||||
'selection' => $plan['selection'],
|
||||
];
|
||||
|
||||
$run = $this->operationRuns->ensureRunWithIdentity(
|
||||
tenant: $selection->targetTenant,
|
||||
type: 'promotion.execute',
|
||||
identityInputs: $identity,
|
||||
context: $context,
|
||||
initiator: $actor,
|
||||
);
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
return ProviderOperationStartResult::deduped($run);
|
||||
}
|
||||
|
||||
$this->operationRuns->updateRun($run, OperationRunStatus::Queued->value, summaryCounts: [
|
||||
'total' => (int) $plan['summary']['ready'],
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
]);
|
||||
|
||||
$this->operationRuns->dispatchOrFail(
|
||||
$run,
|
||||
fn (OperationRun $operationRun): mixed => CrossTenantPromotionExecutionJob::dispatch($operationRun),
|
||||
);
|
||||
|
||||
$this->auditLogger->logCrossTenantPromotionExecutionQueued(
|
||||
workspace: $workspace,
|
||||
sourceTenant: $selection->sourceTenant,
|
||||
targetTenant: $selection->targetTenant,
|
||||
operationRun: $run->fresh() ?? $run,
|
||||
plan: $plan,
|
||||
actor: $actor,
|
||||
);
|
||||
|
||||
return ProviderOperationStartResult::started($run->fresh() ?? $run, true);
|
||||
}
|
||||
|
||||
private function defaultProviderConnection(int $tenantId): ?ProviderConnection
|
||||
{
|
||||
return ProviderConnection::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -73,6 +73,8 @@ enum AuditActionId: string
|
||||
case BaselineCompareCompleted = 'baseline_compare.completed';
|
||||
case BaselineCompareFailed = 'baseline_compare.failed';
|
||||
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
|
||||
case CrossTenantPromotionExecutionQueued = 'cross_tenant_promotion_execution.queued';
|
||||
case CrossTenantPromotionExecutionCompleted = 'cross_tenant_promotion_execution.completed';
|
||||
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
||||
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
||||
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
||||
@ -227,6 +229,8 @@ private static function labels(): array
|
||||
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
||||
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
||||
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
||||
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
||||
@ -326,6 +330,8 @@ private static function summaries(): array
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||
|
||||
@ -257,6 +257,7 @@ private static function canonicalDefinitions(): array
|
||||
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
|
||||
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
|
||||
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
|
||||
'promotion.execute' => new CanonicalOperationType('promotion.execute', 'platform_foundation', null, 'Promotion execution', true, 120),
|
||||
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
|
||||
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
|
||||
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
|
||||
@ -315,6 +316,7 @@ private static function operationAliases(): array
|
||||
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
|
||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
||||
new OperationTypeAlias('promotion.execute', 'promotion.execute', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
||||
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
||||
|
||||
@ -15,6 +15,7 @@ enum OperationRunType: string
|
||||
case BackupSchedulePurge = 'backup.schedule.purge';
|
||||
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case PromotionExecute = 'promotion.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
case TenantReviewCompose = 'tenant.review.compose';
|
||||
|
||||
@ -17,6 +17,13 @@ final class OperationalControlCatalog
|
||||
'operation_types' => ['restore.execute'],
|
||||
'affected_surfaces' => ['tenant.restore_runs.create'],
|
||||
],
|
||||
'promotion.execute' => [
|
||||
'key' => 'promotion.execute',
|
||||
'label' => 'Promotion execution',
|
||||
'supported_scopes' => ['global', 'workspace'],
|
||||
'operation_types' => ['promotion.execute'],
|
||||
'affected_surfaces' => ['admin.cross_tenant_compare.execute'],
|
||||
],
|
||||
'ai.execution' => [
|
||||
'key' => 'ai.execution',
|
||||
'label' => 'AI execution',
|
||||
|
||||
@ -25,7 +25,7 @@ public function requiredCapabilityForType(string $operationType): ?string
|
||||
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'directory.groups.sync' => Capabilities::TENANT_SYNC,
|
||||
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
||||
@ -51,7 +51,7 @@ public function requiredExecutionCapabilityForType(string $operationType): ?stri
|
||||
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
||||
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
||||
default => $this->requiredCapabilityForType($operationType),
|
||||
};
|
||||
|
||||
@ -23,6 +23,7 @@ public function __construct(
|
||||
public ?User $initiator,
|
||||
public ExecutionAuthorityMode $authorityMode,
|
||||
public ?string $requiredCapability,
|
||||
public ?string $workspaceRequiredCapability,
|
||||
public ?int $providerConnectionId,
|
||||
public array $targetScope,
|
||||
public array $prerequisiteClasses = [],
|
||||
|
||||
@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\PortfolioCompare;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CrossTenantPromotionExecutionPlanner
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $preview
|
||||
* @param array<string, mixed> $preflight
|
||||
* @return array{
|
||||
* selection: array<string, mixed>,
|
||||
* summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int},
|
||||
* items: list<array<string, mixed>>,
|
||||
* excluded: list<array<string, mixed>>,
|
||||
* identity: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function build(array $preview, array $preflight): array
|
||||
{
|
||||
$previewSelection = $this->selection($preview);
|
||||
$preflightSelection = $this->selection($preflight);
|
||||
|
||||
if ($previewSelection !== $preflightSelection) {
|
||||
throw new InvalidArgumentException('Promotion preflight is stale. Regenerate the preflight before execution.');
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$excluded = $this->excludedSubjects($preflight);
|
||||
|
||||
foreach ($this->readySubjects($preflight) as $subject) {
|
||||
$item = $this->executionItem($subject);
|
||||
|
||||
if ($item === null) {
|
||||
$excluded[] = $this->excludedSubject($subject, 'source_policy_version_missing');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
$items = $this->sortItems($items);
|
||||
$excluded = $this->sortItems($excluded);
|
||||
|
||||
if ($items === []) {
|
||||
throw new DomainException('Promotion preflight has no executable ready subjects.');
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total' => count($items) + count($excluded),
|
||||
'ready' => count($items),
|
||||
'excluded' => count($excluded),
|
||||
'skipped' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'skip_aligned')),
|
||||
'created' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'create_missing')),
|
||||
'updated' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'update_existing')),
|
||||
];
|
||||
|
||||
return [
|
||||
'selection' => $previewSelection,
|
||||
'summary' => $summary,
|
||||
'items' => $items,
|
||||
'excluded' => $excluded,
|
||||
'identity' => $this->identity($previewSelection, $items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list<string>}
|
||||
*/
|
||||
private function selection(array $payload): array
|
||||
{
|
||||
$selection = is_array($payload['selection'] ?? null) ? $payload['selection'] : [];
|
||||
$policyTypes = is_array($selection['policyTypes'] ?? null) ? $selection['policyTypes'] : [];
|
||||
|
||||
$policyTypes = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
||||
$policyTypes,
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
|
||||
sort($policyTypes);
|
||||
|
||||
return [
|
||||
'sourceTenantId' => is_numeric($selection['sourceTenantId'] ?? null) ? (int) $selection['sourceTenantId'] : null,
|
||||
'targetTenantId' => is_numeric($selection['targetTenantId'] ?? null) ? (int) $selection['targetTenantId'] : null,
|
||||
'policyTypes' => $policyTypes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $preflight
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function readySubjects(array $preflight): array
|
||||
{
|
||||
$subjects = data_get($preflight, 'buckets.ready', []);
|
||||
|
||||
if (! is_array($subjects)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($subjects, 'is_array'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $preflight
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function excludedSubjects(array $preflight): array
|
||||
{
|
||||
$excluded = [];
|
||||
|
||||
foreach (['blocked', 'manual_mapping_required'] as $bucket) {
|
||||
$subjects = data_get($preflight, 'buckets.'.$bucket, []);
|
||||
|
||||
if (! is_array($subjects)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
if (! is_array($subject)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$excluded[] = $this->excludedSubject($subject, $bucket);
|
||||
}
|
||||
}
|
||||
|
||||
return $excluded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function executionItem(array $subject): ?array
|
||||
{
|
||||
$policyVersionId = data_get($subject, 'source.evidence.policyVersionId');
|
||||
|
||||
if (! is_numeric($policyVersionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
|
||||
$action = match ($state) {
|
||||
'match' => 'skip_aligned',
|
||||
'missing' => 'create_missing',
|
||||
default => 'update_existing',
|
||||
};
|
||||
|
||||
return [
|
||||
'policy_type' => $this->stringValue($subject, 'policyType'),
|
||||
'display_name' => $this->stringValue($subject, 'displayName'),
|
||||
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
||||
'compare_state' => $state,
|
||||
'execution_action' => $action,
|
||||
'readiness_reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
||||
'source' => [
|
||||
'tenant_id' => $this->intValue(data_get($subject, 'source.tenantId')),
|
||||
'inventory_item_id' => $this->intValue(data_get($subject, 'source.inventoryItemId')),
|
||||
'subject_external_id' => $this->nullableString(data_get($subject, 'source.subjectExternalId')),
|
||||
'policy_version_id' => (int) $policyVersionId,
|
||||
'evidence_hash' => $this->nullableString(data_get($subject, 'source.evidence.hash')),
|
||||
],
|
||||
'target' => [
|
||||
'tenant_id' => $this->intValue(data_get($subject, 'target.tenantId')),
|
||||
'inventory_item_id' => $this->intValue(data_get($subject, 'target.inventoryItemId')),
|
||||
'subject_external_id' => $this->nullableString(data_get($subject, 'target.subjectExternalId')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function excludedSubject(array $subject, string $reason): array
|
||||
{
|
||||
return [
|
||||
'policy_type' => $this->stringValue($subject, 'policyType'),
|
||||
'display_name' => $this->stringValue($subject, 'displayName'),
|
||||
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
||||
'compare_state' => $this->stringValue($subject, 'state'),
|
||||
'excluded_reason' => $reason,
|
||||
'reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortItems(array $items): array
|
||||
{
|
||||
usort($items, static function (array $left, array $right): int {
|
||||
return [
|
||||
(string) ($left['policy_type'] ?? ''),
|
||||
(string) ($left['subject_key'] ?? ''),
|
||||
(string) ($left['display_name'] ?? ''),
|
||||
] <=> [
|
||||
(string) ($right['policy_type'] ?? ''),
|
||||
(string) ($right['subject_key'] ?? ''),
|
||||
(string) ($right['display_name'] ?? ''),
|
||||
];
|
||||
});
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $selection
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function identity(array $selection, array $items): array
|
||||
{
|
||||
return [
|
||||
'source_tenant_id' => $selection['sourceTenantId'] ?? null,
|
||||
'target_tenant_id' => $selection['targetTenantId'] ?? null,
|
||||
'policy_types' => $selection['policyTypes'] ?? [],
|
||||
'subjects' => array_map(static fn (array $item): array => [
|
||||
'policy_type' => $item['policy_type'] ?? '',
|
||||
'subject_key' => $item['subject_key'] ?? '',
|
||||
'source_policy_version_id' => data_get($item, 'source.policy_version_id'),
|
||||
'source_evidence_hash' => data_get($item, 'source.evidence_hash'),
|
||||
'target_subject_external_id' => data_get($item, 'target.subject_external_id'),
|
||||
'execution_action' => $item['execution_action'] ?? '',
|
||||
], $items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
*/
|
||||
private function stringValue(array $subject, string $key): string
|
||||
{
|
||||
$value = $subject[$key] ?? null;
|
||||
|
||||
return is_string($value) ? $value : '';
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||
}
|
||||
|
||||
private function intValue(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function stringList(mixed $values): array
|
||||
{
|
||||
if (! is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
||||
$values,
|
||||
), static fn (string $value): bool => $value !== ''));
|
||||
}
|
||||
}
|
||||
@ -92,6 +92,14 @@
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'promotion.execute' => [
|
||||
'job_class' => \App\Jobs\Operations\CrossTenantPromotionExecutionJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1500,
|
||||
'expected_max_runtime_seconds' => 420,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.review_pack.generate' => [
|
||||
'job_class' => \App\Jobs\GenerateReviewPackJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
<x-filament::section heading="Cross-tenant compare">
|
||||
<x-slot name="description">
|
||||
Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only and promotion remains preflight only.
|
||||
Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only until you explicitly confirm promotion execution.
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -147,7 +147,7 @@
|
||||
@if ($preflight !== null)
|
||||
<x-filament::section heading="Promotion preflight">
|
||||
<x-slot name="description">
|
||||
Read-only readiness view. No target mutation, queue dispatch, or operation run is created in this slice.
|
||||
Read-only readiness view until you explicitly confirm Execute promotion. Target mutation happens only through the queued operation run.
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4" data-testid="cross-tenant-preflight">
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
|
||||
uses(BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
pest()->browser()->timeout(15_000);
|
||||
|
||||
it('smokes queued promotion execution handoff from compare page into the operation viewer', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Browser Promotion Policy',
|
||||
snapshot: ['settings' => [['key' => 'browser', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->actingAs($fixture['user'])->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
||||
|
||||
$page = visit(CrossTenantComparePage::getUrl(parameters: [
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
], panel: 'admin'));
|
||||
|
||||
$page
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('Cross-tenant compare')
|
||||
->assertSee('Compare preview')
|
||||
->click('Generate promotion preflight')
|
||||
->waitForText('Promotion preflight')
|
||||
->assertSee('Execute promotion')
|
||||
->click('Execute promotion')
|
||||
->waitForText('Queue promotion')
|
||||
->click('Queue promotion')
|
||||
->waitForText('Promotion execution queued')
|
||||
->assertSee('Open operation');
|
||||
|
||||
$run = OperationRun::query()->latest('id')->firstOrFail();
|
||||
|
||||
$page
|
||||
->click('Open operation')
|
||||
->waitForText(OperationRunLinks::identifier((int) $run->getKey()))
|
||||
->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()])
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee(OperationRunLinks::identifier((int) $run->getKey()));
|
||||
});
|
||||
@ -4,19 +4,24 @@
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class);
|
||||
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class, BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
function crossTenantCompareLaunchQuery(string $url): array
|
||||
{
|
||||
@ -119,6 +124,80 @@ function crossTenantCompareLaunchQuery(string $url): array
|
||||
->assertActionVisible('return_to_origin');
|
||||
});
|
||||
|
||||
it('keeps launch context after queueing promotion from an exact-two registry launch', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor(
|
||||
tenantName: 'Anchor Tenant',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||
createMinimalUserWithTenant(
|
||||
tenant: $targetTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $anchorTenant,
|
||||
displayName: 'Queued Launch Context Policy',
|
||||
snapshot: ['settings' => [['key' => 'launch-context', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$triageState = $this->portfolioReturnFilters(
|
||||
[TenantBackupHealthAssessment::POSTURE_STALE],
|
||||
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
||||
[],
|
||||
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
);
|
||||
|
||||
$expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection(
|
||||
targetTenant: $targetTenant,
|
||||
triageState: $triageState,
|
||||
sourceTenant: $anchorTenant,
|
||||
);
|
||||
$expectedBackUrl = TenantResource::getUrl(panel: 'admin', parameters: $triageState);
|
||||
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
||||
$query['policy_type'] = ['deviceConfiguration'];
|
||||
|
||||
$this->usePortfolioTriageWorkspace($user, $anchorTenant);
|
||||
|
||||
$component = Livewire::withQueryParams($query)
|
||||
->actingAs($user)
|
||||
->test(CrossTenantComparePage::class)
|
||||
->assertSet('sourceTenantId', (string) $anchorTenant->getKey())
|
||||
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
||||
->assertSet('selectedPolicyTypes', ['deviceConfiguration'])
|
||||
->assertActionVisible('return_to_origin')
|
||||
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry'
|
||||
&& $action->getUrl() === $expectedBackUrl);
|
||||
|
||||
$page = $component->instance();
|
||||
$page->generatePromotionPreflight();
|
||||
$page->executePromotion();
|
||||
|
||||
$run = OperationRun::query()->latest('id')->first();
|
||||
$navigationContext = CanonicalNavigationContext::fromPayload($page->navigationContextPayload);
|
||||
|
||||
expect($run)
|
||||
->not->toBeNull()
|
||||
->and($run?->type)->toBe('promotion.execute')
|
||||
->and(data_get($run?->context, 'selection.sourceTenantId'))->toBe((int) $anchorTenant->getKey())
|
||||
->and(data_get($run?->context, 'selection.targetTenantId'))->toBe((int) $targetTenant->getKey())
|
||||
->and(data_get($run?->context, 'selection.policyTypes'))->toBe(['deviceConfiguration'])
|
||||
->and($page->sourceTenantId)->toBe((string) $anchorTenant->getKey())
|
||||
->and($page->targetTenantId)->toBe((string) $targetTenant->getKey())
|
||||
->and($page->selectedPolicyTypes)->toBe(['deviceConfiguration'])
|
||||
->and($page->navigationContextPayload)->toBe($query['nav'])
|
||||
->and($navigationContext?->backLinkLabel)->toBe('Back to tenant registry')
|
||||
->and($navigationContext?->backLinkUrl)->toBe($expectedBackUrl);
|
||||
|
||||
Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool {
|
||||
return $job->getOperationRun()?->is($run);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the bulk compare action until exactly two active tenants are selected', function (): void {
|
||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||
|
||||
@ -65,6 +65,32 @@
|
||||
->assertSee('Windows Compliance');
|
||||
});
|
||||
|
||||
it('shows only one dominant promotion action at a time on the compare page', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Promotable Policy',
|
||||
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->assertActionVisible('generatePromotionPreflight')
|
||||
->assertActionHidden('executePromotion')
|
||||
->call('generatePromotionPreflight')
|
||||
->assertDontSee('Generate promotion preflight')
|
||||
->assertSee('Execute promotion')
|
||||
->assertActionVisible('executePromotion');
|
||||
});
|
||||
|
||||
it('rejects the same tenant as source and target without rendering compare results', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
it('requires a current preflight and queues only ready subjects into the promotion run', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Promotable Policy',
|
||||
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||
);
|
||||
$blocked = $this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Blocked Policy',
|
||||
snapshot: ['settings' => [['key' => 'blocked', 'value' => 1]]],
|
||||
);
|
||||
$blocked['version']->delete();
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Manual Policy',
|
||||
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
|
||||
);
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['targetTenant'],
|
||||
displayName: 'Manual Policy',
|
||||
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
|
||||
);
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['targetTenant'],
|
||||
displayName: 'Manual Policy',
|
||||
snapshot: ['settings' => [['key' => 'manual', 'value' => 2]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$query = [
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
];
|
||||
|
||||
$component = Livewire::withQueryParams($query)
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->call('executePromotion')
|
||||
->assertNotified('Promotion execution unavailable');
|
||||
|
||||
expect(OperationRun::query()->count())->toBe(0);
|
||||
|
||||
$component
|
||||
->call('generatePromotionPreflight')
|
||||
->assertActionVisible('executePromotion')
|
||||
->mountAction('executePromotion')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$run = OperationRun::query()->latest('id')->first();
|
||||
|
||||
expect($run)
|
||||
->not->toBeNull()
|
||||
->and($run?->type)->toBe('promotion.execute')
|
||||
->and(data_get($run?->context, 'promotion_execution.plan.summary.ready'))->toBe(1)
|
||||
->and(data_get($run?->context, 'promotion_execution.plan.summary.excluded'))->toBe(2)
|
||||
->and(data_get($run?->context, 'promotion_execution.plan.items.0.display_name'))->toBe('Promotable Policy')
|
||||
->and(data_get($run?->context, 'promotion_execution.plan.items.0.execution_action'))->toBe('create_missing')
|
||||
->and(collect(data_get($run?->context, 'promotion_execution.plan.excluded', []))->pluck('excluded_reason')->all())
|
||||
->toEqualCanonicalizing(['blocked', 'manual_mapping_required']);
|
||||
|
||||
Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool {
|
||||
return $job->getOperationRun()?->is($run);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not queue a promotion run when the current preflight has no ready governed subjects', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$blocked = $this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Blocked Policy',
|
||||
snapshot: ['settings' => [['key' => 'blocked', 'value' => 1]]],
|
||||
);
|
||||
$blocked['version']->delete();
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->call('generatePromotionPreflight')
|
||||
->assertActionVisible('executePromotion')
|
||||
->assertActionDisabled('executePromotion')
|
||||
->assertActionExists('executePromotion', fn (Action $action): bool => $action->getTooltip() === 'Current promotion preflight has no ready governed subjects to execute.')
|
||||
->call('executePromotion')
|
||||
->assertNotified('Promotion execution unavailable');
|
||||
|
||||
expect(OperationRun::query()->count())->toBe(0);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
it('audits queued promotion execution without creating restore-side writes before the worker starts', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Audit Queue Policy',
|
||||
snapshot: ['settings' => [['key' => 'audit-queue', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$policyVersionCount = PolicyVersion::query()->count();
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->call('generatePromotionPreflight')
|
||||
->mountAction('executePromotion')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$run = OperationRun::query()->latest('id')->first();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
||||
->where('action', AuditActionId::CrossTenantPromotionExecutionQueued->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($audit)->not->toBeNull()
|
||||
->and($audit?->status)->toBe('info')
|
||||
->and($audit?->resource_type)->toBe('operation_run')
|
||||
->and((int) ($audit?->operation_run_id ?? 0))->toBe((int) $run?->getKey())
|
||||
->and(data_get($audit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey())
|
||||
->and(data_get($audit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey())
|
||||
->and(data_get($audit?->metadata, 'ready_count'))->toBe(1)
|
||||
->and(data_get($audit?->metadata, 'excluded_count'))->toBe(0)
|
||||
->and(BackupSet::query()->count())->toBe(0)
|
||||
->and(RestoreRun::query()->count())->toBe(0)
|
||||
->and(PolicyVersion::query()->count())->toBe($policyVersionCount);
|
||||
});
|
||||
|
||||
it('audits terminal promotion execution truth after the queued worker completes', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Audit Completion Policy',
|
||||
snapshot: ['settings' => [['key' => 'audit-complete', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->call('generatePromotionPreflight')
|
||||
->mountAction('executePromotion')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$run = OperationRun::query()->latest('id')->firstOrFail();
|
||||
|
||||
$restoreService = \Mockery::mock(RestoreService::class);
|
||||
$restoreService->shouldReceive('execute')
|
||||
->once()
|
||||
->andReturnUsing(function ($tenant, $backupSet, array $selectedItemIds) {
|
||||
return RestoreRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'requested_items' => $selectedItemIds,
|
||||
'results' => [
|
||||
'items' => [[
|
||||
'status' => 'applied',
|
||||
'policy_identifier' => 'audit-complete-policy',
|
||||
]],
|
||||
],
|
||||
'metadata' => [
|
||||
'succeeded' => count($selectedItemIds),
|
||||
'failed' => 0,
|
||||
'partial' => 0,
|
||||
'skipped' => 0,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
app()->instance(RestoreService::class, $restoreService);
|
||||
|
||||
$job = new CrossTenantPromotionExecutionJob($run);
|
||||
$job->handle(
|
||||
app(OperationRunService::class),
|
||||
$restoreService,
|
||||
app(TargetScopeConcurrencyLimiter::class),
|
||||
app(WorkspaceAuditLogger::class),
|
||||
);
|
||||
|
||||
$completedAudit = AuditLog::query()
|
||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
||||
->where('action', AuditActionId::CrossTenantPromotionExecutionCompleted->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$completedRun = $run->fresh();
|
||||
$restoreRun = RestoreRun::query()->latest('id')->first();
|
||||
|
||||
expect($completedRun)->not->toBeNull()
|
||||
->and($completedRun?->status)->toBe('completed')
|
||||
->and($completedRun?->outcome)->toBe('succeeded')
|
||||
->and($completedAudit)->not->toBeNull()
|
||||
->and($completedAudit?->status)->toBe('success')
|
||||
->and($completedAudit?->resource_type)->toBe('operation_run')
|
||||
->and((int) ($completedAudit?->operation_run_id ?? 0))->toBe((int) $completedRun?->getKey())
|
||||
->and(data_get($completedAudit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey())
|
||||
->and(data_get($completedAudit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey())
|
||||
->and(data_get($completedAudit?->metadata, 'summary_counts.created'))->toBe(1)
|
||||
->and(data_get($completedAudit?->metadata, 'summary_counts.succeeded'))->toBe(1)
|
||||
->and(data_get($completedAudit?->metadata, 'restore_run_id'))->toBe((int) $restoreRun?->getKey())
|
||||
->and(BackupSet::query()->count())->toBe(1)
|
||||
->and(RestoreRun::query()->count())->toBe(1);
|
||||
});
|
||||
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\OperationalControlActivation;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
it('keeps execute promotion visible but disabled for compare-only actors without target manage access', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture(workspaceRole: 'owner', tenantRole: 'readonly');
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Compare Only Policy',
|
||||
snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$query = [
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
];
|
||||
|
||||
Livewire::withQueryParams($query)
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->call('generatePromotionPreflight')
|
||||
->assertActionVisible('executePromotion')
|
||||
->assertActionDisabled('executePromotion')
|
||||
->assertActionExists('executePromotion', fn (Action $action): bool => $action->getTooltip() === 'You need target tenant manage access to execute promotion.')
|
||||
->call('executePromotion')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps execute promotion visible but disabled without workspace baseline manage access and forbids forced execution', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture(workspaceRole: 'readonly', tenantRole: 'owner');
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Workspace Gate Policy',
|
||||
snapshot: ['settings' => [['key' => 'workspace', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$selection = new CrossTenantCompareSelection(
|
||||
$fixture['sourceTenant'],
|
||||
$fixture['targetTenant'],
|
||||
['deviceConfiguration'],
|
||||
);
|
||||
$preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
|
||||
$preflight = app(CrossTenantPromotionPreflight::class)->build($preview);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->set('preview', $preview)
|
||||
->set('preflight', $preflight)
|
||||
->assertActionVisible('executePromotion')
|
||||
->assertActionDisabled('executePromotion')
|
||||
->assertActionExists('executePromotion', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to execute promotion.')
|
||||
->call('executePromotion')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('does not queue a promotion run when the current preflight is stale for the selected target tenant', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Stale Policy',
|
||||
snapshot: ['settings' => [['key' => 'stale', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$staleTarget = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||
'name' => 'Stale Target',
|
||||
]);
|
||||
|
||||
$fixture['user']->tenants()->syncWithoutDetaching([
|
||||
(int) $staleTarget->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
app(CapabilityResolver::class)->clearCache();
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$staleSelection = new CrossTenantCompareSelection(
|
||||
$fixture['sourceTenant'],
|
||||
$staleTarget,
|
||||
['deviceConfiguration'],
|
||||
);
|
||||
$currentSelection = new CrossTenantCompareSelection(
|
||||
$fixture['sourceTenant'],
|
||||
$fixture['targetTenant'],
|
||||
['deviceConfiguration'],
|
||||
);
|
||||
$currentPreview = app(CrossTenantComparePreviewBuilder::class)->build($currentSelection);
|
||||
$stalePreview = app(CrossTenantComparePreviewBuilder::class)->build($staleSelection);
|
||||
$stalePreflight = app(CrossTenantPromotionPreflight::class)->build($stalePreview);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->set('preview', $currentPreview)
|
||||
->set('preflight', $stalePreflight)
|
||||
->call('executePromotion')
|
||||
->assertNotified('Promotion execution unavailable');
|
||||
|
||||
expect(OperationRun::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not queue a promotion run when promotion execution is paused by operational control', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Paused Policy',
|
||||
snapshot: ['settings' => [['key' => 'paused', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
OperationalControlActivation::factory()->workspaceScoped()->create([
|
||||
'control_key' => 'promotion.execute',
|
||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||
'reason_text' => 'Paused during promotion review.',
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->call('generatePromotionPreflight')
|
||||
->call('executePromotion')
|
||||
->assertNotified('Promotion execution paused');
|
||||
|
||||
expect(OperationRun::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('returns 404 and does not queue a promotion run when the requested target tenant is outside the actor scope', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
$hiddenTarget = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||
'name' => 'Hidden Promotion Target',
|
||||
]);
|
||||
|
||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$this->withSession($session)
|
||||
->get(CrossTenantComparePage::getUrl(parameters: [
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $hiddenTarget->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
|
||||
expect(OperationRun::query()->count())->toBe(0);
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
it('uses the shared queued and deduped start-result ux for promotion execution', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Run UX Policy',
|
||||
snapshot: ['settings' => [['key' => 'ux', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$query = [
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
];
|
||||
|
||||
$component = Livewire::withQueryParams($query)
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->call('generatePromotionPreflight')
|
||||
->mountAction('executePromotion')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified('Promotion execution queued');
|
||||
|
||||
$run = OperationRun::query()->latest('id')->first();
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run?->type)->toBe('promotion.execute')
|
||||
->and(data_get($run?->context, 'required_capability'))->toBe('tenant.manage')
|
||||
->and(data_get($run?->context, 'workspace_required_capability'))->toBe('workspace_baselines.manage')
|
||||
->and(data_get($run?->context, 'target_scope.workspace_id'))->toBe((int) $fixture['workspace']->getKey())
|
||||
->and(data_get($run?->context, 'selection.sourceTenantId'))->toBe((int) $fixture['sourceTenant']->getKey())
|
||||
->and(data_get($run?->context, 'selection.targetTenantId'))->toBe((int) $fixture['targetTenant']->getKey());
|
||||
|
||||
$component
|
||||
->mountAction('executePromotion')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified('Promotion execution already running');
|
||||
|
||||
expect(OperationRun::query()->where('type', 'promotion.execute')->count())->toBe(1);
|
||||
|
||||
Queue::assertPushed(CrossTenantPromotionExecutionJob::class, 1);
|
||||
|
||||
$this->actingAs($fixture['user'])
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey()])
|
||||
->get(OperationRunLinks::tenantlessView($run))
|
||||
->assertOk();
|
||||
});
|
||||
@ -18,6 +18,7 @@
|
||||
'backup_set.update',
|
||||
'backup.schedule.execute',
|
||||
'restore.execute',
|
||||
'promotion.execute',
|
||||
'tenant.review_pack.generate',
|
||||
'tenant.review.compose',
|
||||
'tenant.evidence.snapshot.generate',
|
||||
@ -43,5 +44,6 @@
|
||||
->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240)
|
||||
->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue()
|
||||
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)
|
||||
->and($validator->jobTimeoutSeconds('promotion.execute'))->toBe(420)
|
||||
->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue();
|
||||
});
|
||||
|
||||
@ -251,6 +251,36 @@
|
||||
->and($decision->checks['execution_prerequisites'])->toBe('failed');
|
||||
});
|
||||
|
||||
it('denies promotion execution when the initiator loses workspace baseline manage capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'readonly');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'promotion.execute',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
],
|
||||
]);
|
||||
|
||||
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
|
||||
|
||||
expect($decision->allowed)->toBeFalse()
|
||||
->and($decision->denialClass)->toBe(ExecutionDenialClass::CapabilityDenied)
|
||||
->and($decision->reasonCode)->toBe(ExecutionDenialReasonCode::MissingCapability)
|
||||
->and($decision->retryable)->toBeFalse()
|
||||
->and($decision->checks)->toMatchArray([
|
||||
'workspace_scope' => 'passed',
|
||||
'tenant_scope' => 'passed',
|
||||
'capability' => 'failed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('infers tenant sync capability for policy sync runs from the central resolver', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -294,3 +324,27 @@
|
||||
'user_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds promotion execution context with workspace reauthorization and write-gate prerequisites', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'promotion.execute',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
],
|
||||
]);
|
||||
|
||||
$context = app(QueuedExecutionLegitimacyGate::class)->buildContext($run);
|
||||
|
||||
expect($context->requiredCapability)->toBe(Capabilities::TENANT_MANAGE)
|
||||
->and($context->workspaceRequiredCapability)->toBe(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->and($context->prerequisiteClasses)->toContain('write_gate');
|
||||
});
|
||||
|
||||
@ -7,12 +7,18 @@
|
||||
it('exposes only active runtime controls in the bounded control catalog', function (): void {
|
||||
$catalog = app(OperationalControlCatalog::class);
|
||||
|
||||
expect($catalog->keys())->toBe(['restore.execute', 'ai.execution'])
|
||||
expect($catalog->keys())->toBe(['restore.execute', 'promotion.execute', 'ai.execution'])
|
||||
->and($catalog->definition('restore.execute'))->toMatchArray([
|
||||
'key' => 'restore.execute',
|
||||
'label' => 'Restore execution',
|
||||
'supported_scopes' => ['global', 'workspace'],
|
||||
'operation_types' => ['restore.execute'],
|
||||
])
|
||||
->and($catalog->definition('promotion.execute'))->toMatchArray([
|
||||
'key' => 'promotion.execute',
|
||||
'label' => 'Promotion execution',
|
||||
'supported_scopes' => ['global', 'workspace'],
|
||||
'operation_types' => ['promotion.execute'],
|
||||
])
|
||||
->and($catalog->definition('ai.execution'))->toMatchArray([
|
||||
'key' => 'ai.execution',
|
||||
|
||||
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionExecutionPlanner;
|
||||
|
||||
it('builds a ready-only promotion execution plan with stable identity inputs', function (): void {
|
||||
$planner = app(CrossTenantPromotionExecutionPlanner::class);
|
||||
|
||||
$preview = [
|
||||
'selection' => [
|
||||
'sourceTenantId' => 10,
|
||||
'targetTenantId' => 20,
|
||||
'policyTypes' => ['settingsCatalog', 'deviceConfiguration'],
|
||||
],
|
||||
];
|
||||
|
||||
$preflight = [
|
||||
'selection' => [
|
||||
'sourceTenantId' => 10,
|
||||
'targetTenantId' => 20,
|
||||
'policyTypes' => ['deviceConfiguration', 'settingsCatalog'],
|
||||
],
|
||||
'buckets' => [
|
||||
'ready' => [
|
||||
plannerSubject('Aligned Policy', 'aligned-subject', 'deviceConfiguration', 'match', 10, 20, 101, 'hash-aligned'),
|
||||
plannerSubject('Create Policy', 'create-subject', 'deviceConfiguration', 'missing', 10, 20, 102, 'hash-create'),
|
||||
plannerSubject('Update Policy', 'update-subject', 'settingsCatalog', 'different', 10, 20, 103, 'hash-update'),
|
||||
plannerSubject('Missing Version Policy', 'missing-version-subject', 'deviceConfiguration', 'missing', 10, 20, null, null),
|
||||
],
|
||||
'blocked' => [
|
||||
plannerSubject('Blocked Policy', 'blocked-subject', 'deviceConfiguration', 'different', 10, 20, 104, 'hash-blocked', ['write_gate_blocked']),
|
||||
],
|
||||
'manual_mapping_required' => [
|
||||
plannerSubject('Manual Policy', 'manual-subject', 'deviceConfiguration', 'different', 10, 20, 105, 'hash-manual', ['target_subject_ambiguous']),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$plan = $planner->build($preview, $preflight);
|
||||
|
||||
expect($plan['summary'])->toBe([
|
||||
'total' => 6,
|
||||
'ready' => 3,
|
||||
'excluded' => 3,
|
||||
'skipped' => 1,
|
||||
'created' => 1,
|
||||
'updated' => 1,
|
||||
])
|
||||
->and($plan['selection'])->toBe([
|
||||
'sourceTenantId' => 10,
|
||||
'targetTenantId' => 20,
|
||||
'policyTypes' => ['deviceConfiguration', 'settingsCatalog'],
|
||||
])
|
||||
->and(array_column($plan['items'], 'execution_action'))->toBe([
|
||||
'skip_aligned',
|
||||
'create_missing',
|
||||
'update_existing',
|
||||
])
|
||||
->and(array_column($plan['excluded'], 'excluded_reason'))->toContain('blocked', 'manual_mapping_required', 'source_policy_version_missing')
|
||||
->and($plan['identity'])->toMatchArray([
|
||||
'source_tenant_id' => 10,
|
||||
'target_tenant_id' => 20,
|
||||
'policy_types' => ['deviceConfiguration', 'settingsCatalog'],
|
||||
])
|
||||
->and($plan['identity']['subjects'])->toBe([
|
||||
[
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'aligned-subject',
|
||||
'source_policy_version_id' => 101,
|
||||
'source_evidence_hash' => 'hash-aligned',
|
||||
'target_subject_external_id' => 'target-aligned-subject',
|
||||
'execution_action' => 'skip_aligned',
|
||||
],
|
||||
[
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'create-subject',
|
||||
'source_policy_version_id' => 102,
|
||||
'source_evidence_hash' => 'hash-create',
|
||||
'target_subject_external_id' => 'target-create-subject',
|
||||
'execution_action' => 'create_missing',
|
||||
],
|
||||
[
|
||||
'policy_type' => 'settingsCatalog',
|
||||
'subject_key' => 'update-subject',
|
||||
'source_policy_version_id' => 103,
|
||||
'source_evidence_hash' => 'hash-update',
|
||||
'target_subject_external_id' => 'target-update-subject',
|
||||
'execution_action' => 'update_existing',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects stale promotion preflight selections', function (): void {
|
||||
$planner = app(CrossTenantPromotionExecutionPlanner::class);
|
||||
|
||||
expect(fn (): array => $planner->build(
|
||||
['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 20, 'policyTypes' => ['deviceConfiguration']]],
|
||||
['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 21, 'policyTypes' => ['deviceConfiguration']], 'buckets' => ['ready' => [], 'blocked' => [], 'manual_mapping_required' => []]],
|
||||
))->toThrow(InvalidArgumentException::class, 'Promotion preflight is stale. Regenerate the preflight before execution.');
|
||||
});
|
||||
|
||||
it('rejects promotion preflights with no executable ready subjects', function (): void {
|
||||
$planner = app(CrossTenantPromotionExecutionPlanner::class);
|
||||
|
||||
expect(fn (): array => $planner->build(
|
||||
['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 20, 'policyTypes' => ['deviceConfiguration']]],
|
||||
['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 20, 'policyTypes' => ['deviceConfiguration']], 'buckets' => ['ready' => [], 'blocked' => [plannerSubject('Blocked Policy', 'blocked-subject', 'deviceConfiguration', 'different', 10, 20, 104, 'hash-blocked')], 'manual_mapping_required' => []]],
|
||||
))->toThrow(DomainException::class, 'Promotion preflight has no executable ready subjects.');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param list<string> $reasonCodes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function plannerSubject(
|
||||
string $displayName,
|
||||
string $subjectKey,
|
||||
string $policyType,
|
||||
string $state,
|
||||
int $sourceTenantId,
|
||||
int $targetTenantId,
|
||||
?int $policyVersionId,
|
||||
?string $evidenceHash,
|
||||
array $reasonCodes = [],
|
||||
): array {
|
||||
return [
|
||||
'displayName' => $displayName,
|
||||
'subjectKey' => $subjectKey,
|
||||
'policyType' => $policyType,
|
||||
'state' => $state,
|
||||
'source' => [
|
||||
'tenantId' => $sourceTenantId,
|
||||
'inventoryItemId' => $policyVersionId !== null ? $policyVersionId + 1000 : null,
|
||||
'subjectExternalId' => 'source-'.$subjectKey,
|
||||
'evidence' => [
|
||||
'policyVersionId' => $policyVersionId,
|
||||
'hash' => $evidenceHash,
|
||||
],
|
||||
],
|
||||
'target' => [
|
||||
'tenantId' => $targetTenantId,
|
||||
'inventoryItemId' => $policyVersionId !== null ? $policyVersionId + 2000 : null,
|
||||
'subjectExternalId' => 'target-'.$subjectKey,
|
||||
],
|
||||
'preflight' => [
|
||||
'reasonCodes' => $reasonCodes,
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -4,7 +4,7 @@ # TenantPilot Implementation Ledger
|
||||
> **Last reviewed:** 2026-05-02
|
||||
> **Use for:** Repo-based implementation status and product-surface maturity assessment
|
||||
> **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch
|
||||
> **Scoped maintenance:** 2026-05-02 ledger drift correction and alignment with `docs/product/roadmap.md` plus `docs/product/spec-candidates.md` after the repo-truth review of roadmap drift, manual-promotion backlog, and implementation maturity.
|
||||
> **Scoped maintenance:** 2026-05-02 ledger drift correction and alignment with `docs/product/roadmap.md` plus `docs/product/spec-candidates.md` after the repo-truth review of roadmap drift, manual-promotion backlog, deep-research alignment, and current spec promotions.
|
||||
|
||||
## Purpose
|
||||
|
||||
@ -134,11 +134,13 @@ ## Not Implemented
|
||||
|
||||
- Auditor Pack Delivery & Executive Export v1
|
||||
- Cross-Tenant Promotion Execution v1
|
||||
- Governance Decision Pack & Approval Workflow v1
|
||||
- Decision Register & Approval Workflow v1
|
||||
- Governance Artifact Lifecycle & Retention v1
|
||||
- Customer-Facing Localization Adoption v1
|
||||
- Billing & Subscription Truth Layer v1
|
||||
- Stored Reports Surface v1
|
||||
- Workspace & Tenant Closure Lifecycle v1
|
||||
- Enterprise Access Boundary & Support Access Governance v1
|
||||
- First Governed AI Runtime Consumer v1
|
||||
- Human-in-the-Loop Autonomous Governance
|
||||
- Standardization & Policy Quality / Intune Linting
|
||||
@ -217,22 +219,24 @@ ## Open Gaps & Blockers
|
||||
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|
||||
|---|---|---|---|---|
|
||||
| No safe automatic next-best-prep target is currently active | Planning boundary | `docs/product/spec-candidates.md` now keeps the active queue empty, so the next slice must be promoted deliberately instead of selected automatically | Product planning / queue hygiene | none - require explicit manual promotion |
|
||||
| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery without dedicated packaging | R2 review delivery | `Auditor Pack Delivery & Executive Export v1` |
|
||||
| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent | MSP Portfolio & Operations | `Cross-Tenant Promotion Execution v1` |
|
||||
| Governance decision pack and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready action package with audit trail | Decision-based operating | `Governance Decision Pack & Approval Workflow v1` |
|
||||
| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery, even though the dedicated follow-through is now spec-backed | R2 review delivery | `specs/263-auditor-pack-executive-export/spec.md` |
|
||||
| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent even though the execution package is now spec-backed | MSP Portfolio & Operations | `specs/264-cross-tenant-promotion-execution/spec.md` |
|
||||
| Decision register and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with audit trail | Decision-based operating | `Decision Register & Approval Workflow v1` |
|
||||
| Governance-artifact lifecycle runtime is still missing | Trust / auditability blocker | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack immutable-reference, hold, export, delete, and suspended/read-only runtime semantics | Lifecycle governance / enterprise trust | `Governance Artifact Lifecycle & Retention v1` |
|
||||
| Customer-facing localization adoption is incomplete | Productization blocker | Locale groundwork is repo-real, but customer-safe adoption remains incomplete | Localization / review productization | `Customer-Facing Localization Adoption v1` |
|
||||
| Billing and subscription truth is missing | Commercial blocker | Entitlements and lifecycle state handling stop short of a durable billing/subscription truth layer | Commercial readiness | `Billing & Subscription Truth Layer v1` |
|
||||
| Stored reports still lack a clear product surface | Product blocker | Retained evidence and review artifacts remain harder to consume than they should be | Reports / evidence consumption | `Stored Reports Surface v1` |
|
||||
| Workspace and tenant closure follow-through is not started | Strategic blocker | The taxonomy exists, but closure/runtime semantics are not yet productized | Lifecycle governance / enterprise trust | `Workspace & Tenant Closure Lifecycle v1` |
|
||||
| Support-access governance is still missing | Access governance blocker | Break-glass and support access seams exist, but customer-visible TTL, reason, approval, and export semantics are not productized | Enterprise access boundary | `Enterprise Access Boundary & Support Access Governance v1` |
|
||||
| First governed AI runtime consumer is missing | Architecture blocker | The policy foundation exists, but there is no bounded runtime consumer proving the model end-to-end | Governed AI follow-through | `First Governed AI Runtime Consumer v1` |
|
||||
|
||||
## Recommended Manual Promotions
|
||||
|
||||
- `Auditor Pack Delivery & Executive Export v1` -> anchored by `specs/109-review-pack-export/spec.md`, `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/258-customer-review-productization/spec.md`, `specs/259-compliance-evidence-mapping/spec.md`, and `specs/260-governance-service-packaging/spec.md`
|
||||
- `Cross-Tenant Promotion Execution v1` -> anchored by `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
||||
- `Governance Decision Pack & Approval Workflow v1` -> anchored by `specs/257-governance-decision-convergence/spec.md` and `docs/product/roadmap.md`
|
||||
- `Decision Register & Approval Workflow v1` -> anchored by `specs/250-decision-governance-inbox/spec.md`, `specs/257-governance-decision-convergence/spec.md`, and `docs/product/roadmap.md`
|
||||
- `Governance Artifact Lifecycle & Retention v1` -> anchored by `specs/158-artifact-truth-semantics/spec.md`, `specs/262-lifecycle-governance-taxonomy/spec.md`, and `docs/product/standards/lifecycle-governance.md`
|
||||
- `Customer-Facing Localization Adoption v1` -> anchored by `specs/252-platform-localization-v1/spec.md`, `specs/258-customer-review-productization/spec.md`, and `specs/260-governance-service-packaging/spec.md`
|
||||
- `Billing & Subscription Truth Layer v1` -> anchored by `specs/247-plans-entitlements-billing-readiness/spec.md` and `specs/251-commercial-entitlements-billing-state/spec.md`
|
||||
- `Enterprise Access Boundary & Support Access Governance v1` -> anchored by `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`, `docs/HANDOVER.md`, `specs/065-tenant-rbac-v1/spec.md`, and `specs/066-rbac-ui-enforcement-helper/spec.md`
|
||||
- `Stored Reports Surface v1` -> anchored by `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/260-governance-service-packaging/spec.md`, and `docs/product/implementation-ledger.md`
|
||||
- `Workspace & Tenant Closure Lifecycle v1` -> anchored by `specs/262-lifecycle-governance-taxonomy/spec.md`
|
||||
- `First Governed AI Runtime Consumer v1` -> anchored by `specs/248-private-ai-policy-foundation/spec.md`
|
||||
|
||||
@ -4,7 +4,7 @@ # Product Roadmap
|
||||
> **Last reviewed:** 2026-05-02
|
||||
> **Use for:** Current product roadmap, release themes, and prioritization context
|
||||
> **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification
|
||||
> **Scoped maintenance:** 2026-05-02 repo-based roadmap drift correction and queue/backlog alignment after the audit-derived product-truth review.
|
||||
> **Scoped maintenance:** 2026-05-02 repo-based roadmap drift correction, manual-promotion backlog alignment, and enterprise-SaaS deep-research calibration against current specs, standards, and product-truth docs.
|
||||
>
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
@ -26,19 +26,73 @@ ## Current Productization & Moat Priorities
|
||||
|
||||
This is the repo-based prioritization overlay for the next sellable lanes. The bottleneck is no longer raw backend truth alone. The next roadmap slices should make existing governance foundations customer-safe, decision-centered, auditable, and MSP-sellable before opening more backend-only islands.
|
||||
|
||||
| Order | Theme | Repo truth | Product posture | Why now | Candidate posture |
|
||||
| Order | Theme | Alignment status | Repo truth | Why now | Queue posture |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Auditor Pack Delivery & Executive Export v1 | Review-pack export, evidence, tenant review, customer review productization, compliance mapping, and governance packaging foundations are already spec-backed and partially repo-real | fast sellable | clearest remaining step between repo-real governance truth and auditor-/executive-ready delivery | manual promotion only, not auto-prep |
|
||||
| 2 | Customer Review Workspace Productization v1 | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace, and accepted-risk foundations are repo-real | fast sellable | clearest customer-safe governance-of-record surface gap | spec-backed follow-through, not active queue |
|
||||
| 3 | Governance Decision Surface Convergence | Governance Inbox, My Findings, Intake, and Exception Queue are repo-real, but convergence remains incomplete | implemented but not productized | reduces admin-tool sprawl and turns multiple queue surfaces into calmer decision work | spec-backed follow-through, not active queue |
|
||||
| 4 | Compliance Evidence Mapping v1 | Canonical controls, evidence, stored reports, reviews, and findings foundations are repo-real; customer-safe compliance mapping follow-through remains | implemented but not productized | strong governance moat for compliance-oriented MSP and Mittelstand reviews without certification claims | spec-backed follow-through, not active queue |
|
||||
| 5 | Governance-as-a-Service Packaging v1 | Review packs, exports, evidence, and accepted-risk foundations are repo-real; recurring executive/MSP packaging follow-through remains | implemented but not productized | turns governance truth into a repeatable MSP deliverable instead of one-off manual reporting | spec-backed follow-through, not active queue |
|
||||
| 6 | Cross-Tenant Promotion Execution v1 | Cross-tenant compare preview and promotion preflight are repo-real; execution is not | not implemented | strongest MSP multiplier after review and packaging lanes are calmer | manual promotion only, not auto-prep |
|
||||
| 7 | Governance Decision Pack & Approval Workflow v1 | Decision convergence and decision-based operating framing are strong enough for a bounded human-in-the-loop slice, but the workflow itself is not implemented | not implemented | next narrow decision workflow without autonomous remediation | manual promotion only, not auto-prep |
|
||||
| 8 | Customer-Facing Localization Adoption v1 | Locale foundation is repo-real; customer-facing adoption, glossary completion, and regression hardening remain | implemented but not productized | turns existing localization groundwork into customer-safe polish | manual promotion only, not auto-prep |
|
||||
| 9 | Billing & Subscription Truth Layer v1 | Plans, entitlements, and commercial lifecycle are repo-real; billing/subscription truth is not | not implemented | closes the remaining commercial truth gap | manual promotion only, not auto-prep |
|
||||
| 10 | Stored Reports Surface v1 | Stored-report substrate is repo-real through evidence and review foundations; product surface remains incomplete | foundation-only | makes retained governance artifacts usable without manual digging | manual promotion only, not auto-prep |
|
||||
| 11 | First Governed AI Runtime Consumer v1 | Governed AI policy foundation is repo-real; no first governed runtime consumer is proven | not implemented | bounded post-foundation AI adoption after higher-priority sellability gaps | manual promotion only, not auto-prep |
|
||||
| 1 | Customer Review Workspace Productization v1 | repo-verified, productization gap | Customer-safe review consumption is repo-real through Specs 249, 258, 259, 260, and 263, but the calm sellable surface still needs final productization discipline | clearest sellability lever for Governance-of-Record without creating a parallel customer portal | spec-backed follow-through |
|
||||
| 2 | Decision-Based Governance Inbox + Decision Register v1 | repo-verified, productization gap, roadmap recommendation | Governance inbox, findings queues, alerts, review follow-up, and Specs 250/257 already anchor the decision surface; a bounded decision-register and approval follow-through is still open | biggest remaining operator workflow gap and the cleanest defense against admin-tool sprawl | manual promotion only for the decision-register follow-through |
|
||||
| 3 | Governance Artifact Lifecycle & Retention v1 | foundation-only, roadmap recommendation, spec candidate | Spec 262 and the lifecycle-governance standard provide taxonomy-first guardrails, but governance-artifact runtime semantics are not yet productized | new trust, auditability, export, and retention lever for evidence snapshots, stored reports, review packs, and decision records | manual promotion only |
|
||||
| 4 | Commercial Entitlements & Billing-State Lifecycle v1 | repo-verified, foundation-only, productization gap | Specs 247 and 251 already resolve plan and lifecycle posture, but broader commercial-state-to-artifact-access rules and later billing truth remain open | SaaS trust and lifecycle maturity matter before broader packaging, scale, or AI work | spec-backed follow-through plus narrower billing follow-through |
|
||||
| 5 | External Support Desk / PSA Handoff v1 | repo-verified, productization gap | Spec 256 and the current bounded handoff service already exist, but the portfolio-safe handoff story is still not fully productized | MSP integration should compress follow-through work without turning TenantPilot into a helpdesk | spec-backed follow-through |
|
||||
| 6 | Customer-Facing Localization v1 | foundation-only, productization gap | Spec 252 and current locale resolution are repo-real; glossary completion and customer-facing adoption remain incomplete | customer-safe DACH/EU review consumption should start at glossary, labels, packs, and notifications rather than full operator-UI translation | manual promotion only |
|
||||
| 7 | Cross-Tenant Compare & Promotion with Lineage v1 | repo-verified, productization gap | Spec 043 is repo-real for compare and preflight, and Spec 264 now carries the bounded execution follow-through on this branch | portfolio action matters, but it must stay governance-first with lineage, evidence, approval, and rollback references rather than raw push mechanics | spec-backed follow-through |
|
||||
| 8 | Governance Service Packaging v1 | repo-verified, productization gap | Spec 260 and current governance-package delivery cues are repo-real, but recurring service packaging remains calmer in documentation than in repeatable delivery workflows | MSP value comes from repeatable services and customer-safe consumption, not just more admin pages | spec-backed follow-through |
|
||||
| 9 | Enterprise Access Boundary & Support Access Governance v1 | roadmap recommendation, spec candidate | Break-glass and system-access seams exist in audits and handover docs, but no bounded support-access request, TTL, approval, and customer-visible audit package exists | tighten support access before broad SSO/SCIM expansion; keep SCIM and group-provisioning later | manual promotion only |
|
||||
| 10 | Advanced APIs / Webhooks | later scale-layer, not-now | no bounded repo-ready package yet | useful integration layer, but it trails review, decision, artifact, and commercial clarity | not-now |
|
||||
| 11 | Private AI Execution Governance Foundation v1 / governed runtime follow-through | repo-verified, foundation-only, later scale-layer | Spec 248 is implemented as a governed foundation; visible runtime consumers and broader budget/result governance are still deferred | AI should remain governed foundation-first and provider-auditable before any visible feature island ships | manual promotion only for runtime follow-through |
|
||||
| 12 | AI-assisted Review Summaries / Translation / Next Action Drafting | roadmap recommendation, later scale-layer, not-now | depends on governed AI, review truth, and customer-safe localization/productization | later visible AI lane after review, decision, artifact, and commercial maturity | not-now |
|
||||
|
||||
## Deep-Research Roadmap Alignment
|
||||
|
||||
This section is a deep-research-derived calibration layer. It sharpens roadmap language against current repo truth without reopening already-promoted specs or overstating sellability from foundations alone.
|
||||
|
||||
### Confirmed priorities
|
||||
|
||||
- Deep-Research-derived: Customer Review Workspace remains the primary sellability gap, but the repo already contains the foundational and productization specs. The roadmap priority is calmer customer-safe review consumption, not a second portal or a parallel reporting stack.
|
||||
- Deep-Research-derived: Decision-centered operating remains the primary operator workflow gap. The repo already has governance inbox and convergence anchors, so the remaining roadmap work should narrow toward decision-register, ownership, closure, and approval semantics instead of launching more isolated admin surfaces.
|
||||
- Deep-Research-derived: PSA/ITSM remains an integration lane, not a product-redefinition. The correct posture is handoff, reference continuity, and auditability rather than a TenantPilot-native helpdesk.
|
||||
|
||||
### Newly elevated gaps
|
||||
|
||||
- Deep-Research-derived: Governance Artifact Lifecycle & Retention v1 should be elevated into the now lane. Current repo truth covers lifecycle taxonomy, review-pack retention, and artifact-truth semantics in pieces, but not a unified governance-artifact lifecycle contract.
|
||||
- Roadmap Recommendation: Enterprise Access Boundary & Support Access Governance v1 should exist as a narrow early access-governance slice built around support access request, reason, TTL, approval, banner, and exportable audit trail. Broad workspace SSO/OIDC/SCIM remains later.
|
||||
|
||||
### Reordered priorities
|
||||
|
||||
- Deep-Research-derived: Commercial lifecycle moves up. The commercial lane should be framed as SaaS trust, workspace read-only behavior, artifact access, and lifecycle semantics, not as a future billing engine.
|
||||
- Deep-Research-derived: Cross-tenant compare and promotion remains important, but the roadmap should talk about lineage, approval, evidence, rollback references, and decision linkage before it talks about settings push.
|
||||
- Deep-Research-derived: Auditor-ready delivery and broader governance packaging stay valuable, but they should follow calmer review consumption, decision routing, artifact lifecycle clarity, and commercial-state truth.
|
||||
|
||||
### Deferred / not-now themes
|
||||
|
||||
- Roadmap Recommendation: full operator-UI localization is not the v1 localization target; customer-facing glossary, review, pack, and notification surfaces come first.
|
||||
- Roadmap Recommendation: broad workspace SSO/OIDC, SCIM, group-to-capability mapping, and automated provisioning stay out of P0 unless support-access risk turns acute.
|
||||
- Roadmap Recommendation: advanced APIs/webhooks, visible AI runtime consumers, and AI-assisted drafting stay later scale layers.
|
||||
|
||||
### Productization vs. Foundation distinction
|
||||
|
||||
- Repo-verified foundations do not automatically mean sellable or customer-safe product slices.
|
||||
- Specs 248, 249, 250, 251, 252, 256, 258, 260, 262, 263, and current-branch 264 prove real foundations or prepared follow-through, but the roadmap should still distinguish `foundation-only` from `productization gap`.
|
||||
- Stored reports, localization, commercial lifecycle, governed AI, and governance packaging all already have some repo truth. The open work is mainly calmer consumption, lifecycle semantics, and repeatable product delivery.
|
||||
|
||||
### Risks of admin-tool sprawl
|
||||
|
||||
- Deep-Research-derived: TenantPilot loses focus when every new operator concern gets its own top-level page instead of feeding review, decision, evidence, and governance-package flows.
|
||||
- Deep-Research-derived: More admin surfaces do not close the core gap. Decision records, accepted-risk visibility, evidence lifecycle, customer-safe review consumption, and portfolio-safe workflow continuity do.
|
||||
- Roadmap Recommendation: prefer decision-first routing, diagnostics-second disclosure, and evidence-third drilldown over raw technical dashboards or isolated remediation consoles.
|
||||
|
||||
## Deep-Research Anti-Patterns
|
||||
|
||||
Do not prioritize these themes ahead of the aligned now and next lanes.
|
||||
|
||||
- anti-pattern: generic M365 admin mirror
|
||||
- anti-pattern: generic helpdesk or PSA replacement
|
||||
- anti-pattern: device-action tooling without governance context
|
||||
- anti-pattern: generic automation builder
|
||||
- anti-pattern: raw technical dashboards as the primary product surface
|
||||
- anti-pattern: AI copilot islands without AI governance
|
||||
- anti-pattern: broad multi-cloud expansion before Microsoft governance is productized
|
||||
- anti-pattern: eDiscovery or broad GRC-suite clone
|
||||
- anti-pattern: a new top-level page for every technical state or exception
|
||||
|
||||
Explicit anti-sprawl boundaries for this priority set:
|
||||
|
||||
@ -393,11 +447,13 @@ ## Infrastructure & Platform Debt
|
||||
|------|------|--------|
|
||||
| No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||
| No shared lifecycle taxonomy for workspace, tenant, managed-object, retention, export, purge, and restoreability states | Local fixes such as ghost-policy handling, workspace deactivation, tenant removal, retention, or purge can create inconsistent deletion semantics and audit gaps | Covered by Workspace, Tenant & Managed Object Lifecycle Governance candidate |
|
||||
| Governance-artifact lifecycle runtime is still missing | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack one runtime contract for immutable identity, hold, export, delete, and suspended/read-only behavior | Covered by Governance Artifact Lifecycle & Retention v1 |
|
||||
| No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation |
|
||||
| No bounded support-access governance package yet | Break-glass, system access, and future impersonation/support access could drift without customer-visible TTL, reason, approval, and export semantics | Covered by Enterprise Access Boundary & Support Access Governance v1 |
|
||||
| No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||
| Auditor-ready executive export is not yet productized | Review truth still stops short of auditor-/executive-ready delivery without additional packaging | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Cross-tenant promotion execution is missing | Compare preview and preflight stop short of the actual portfolio action | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Governance decision pack and approval workflow is missing | Decision-based operating still lacks a bounded approval-ready action package with explicit audit trail | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Auditor-ready executive export is not yet productized | Review truth still stops short of calm auditor-/executive-ready delivery even though the spec package now exists | Covered by `specs/263-auditor-pack-executive-export/spec.md` |
|
||||
| Cross-tenant promotion execution is missing | Compare preview and preflight stop short of the actual portfolio action even though the execution spec package now exists on this branch | Covered by `specs/264-cross-tenant-promotion-execution/spec.md` |
|
||||
| Governance decision register and approval workflow is missing | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with explicit audit trail | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Customer-facing localization adoption is incomplete | Repo-real locale groundwork is not yet fully productized across customer-safe governance surfaces | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Billing and subscription truth is missing | Commercial readiness still stops short of a durable billing/subscription truth layer | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Stored reports still lack a clear product surface | Retained evidence and review artifacts remain harder to consume than they should be | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
@ -417,11 +473,13 @@ ## Infrastructure & Platform Debt
|
||||
|
||||
## Priority Ranking (Current Manual Promotion Order)
|
||||
|
||||
1. Auditor Pack Delivery & Executive Export v1
|
||||
2. Cross-Tenant Promotion Execution v1
|
||||
3. Governance Decision Pack & Approval Workflow v1
|
||||
This ranking applies only to still-unspecced or still-manual follow-through items. Auditor-ready delivery and cross-tenant promotion execution already have spec packages and therefore no longer belong in the manual-promotion ordering list.
|
||||
|
||||
1. Decision Register & Approval Workflow v1
|
||||
2. Governance Artifact Lifecycle & Retention v1
|
||||
3. Billing & Subscription Truth Layer v1
|
||||
4. Customer-Facing Localization Adoption v1
|
||||
5. Billing & Subscription Truth Layer v1
|
||||
5. Enterprise Access Boundary & Support Access Governance v1
|
||||
6. Stored Reports Surface v1
|
||||
7. Workspace & Tenant Closure Lifecycle v1
|
||||
8. First Governed AI Runtime Consumer v1
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
# Spec Candidates
|
||||
|
||||
> **Status:** Active
|
||||
> **Last reviewed:** 2026-05-01
|
||||
> **Last reviewed:** 2026-05-02
|
||||
> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs
|
||||
> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification
|
||||
> **Scoped maintenance:** 2026-05-01 repo-based queue re-audit against current `specs/` truth, including refreshed Spec 043 and Specs 251-260; stale active candidates were cleared and no new candidate was promoted.
|
||||
> **Scoped maintenance:** 2026-05-02 repo-based queue re-audit plus enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264.
|
||||
>
|
||||
> Repo-based next-spec queue for TenantPilot.
|
||||
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
|
||||
@ -31,6 +31,101 @@ ## Current Source-Of-Truth Boundary
|
||||
- `discoveries.md` is a staging area for findings that may later be promoted here.
|
||||
- `implementation-ledger.md` is maturity evidence, not a prioritization queue.
|
||||
- Audit-derived candidate packages under `docs/audits/` are historical inputs only unless they are explicitly promoted into this file.
|
||||
- The deep-research alignment reference below sharpens naming, status markers, and target sequencing without reopening already-promoted specs as active queue items.
|
||||
|
||||
## Deep-Research Alignment Reference (non-queue)
|
||||
|
||||
This section is a deep-research-derived calibration layer. It is intentionally not the active queue. Use it to keep candidate naming, scope, and status language aligned with current repo truth.
|
||||
|
||||
### Customer Review Workspace v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: Specs 249 and 258, plus the implementation ledger and current review surfaces, already prove the foundational and productization path for customer-safe review consumption.
|
||||
- **Problem**: Customer-safe review consumption remains the clearest sellability gap whenever review truth, accepted risks, evidence, and package delivery still require operator translation.
|
||||
- **Deep-Research-derived sharpening**: Keep the lane focused on one customer-safe read-only review surface, findings summary, accepted-risk visibility, evidence viewer, review-pack download, management summary, RBAC/capability enforcement, and audit trail.
|
||||
- **Non-goals**: no generic customer portal, no helpdesk surface, no raw diagnostics by default, no admin mirror.
|
||||
|
||||
### Decision-Based Governance Inbox + Decision Register v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap, roadmap recommendation
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: governance inbox, findings queues, operations attention, review follow-up, and Specs 250 and 257 already anchor the decision surface, but there is still no bounded decision-register follow-through.
|
||||
- **Problem**: Findings, alerts, runs, and reviews still need one calmer decision layer with ownership, due state, reason, impact, next action, linked evidence, linked `OperationRun`, accepted-risk path, closure reason, and escalation hooks.
|
||||
- **Deep-Research-derived sharpening**: treat the missing slice as decision-register and approval/closure follow-through over current inbox foundations, not as another queue page.
|
||||
- **Non-goals**: no generic Kanban board, no PSA clone, no XDR incident console.
|
||||
|
||||
### Governance Artifact Lifecycle & Retention v1
|
||||
|
||||
- **Status markers**: foundation-only, roadmap recommendation, spec candidate
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: Spec 262, the lifecycle-governance standard, review-pack retention, and artifact-truth semantics provide taxonomy-first foundations, but not a productized governance-artifact lifecycle.
|
||||
- **Problem**: Evidence snapshots, stored reports, review packs, and future decision records are governance artifacts and must not be treated like short-lived operational rows.
|
||||
- **Roadmap Recommendation**: v1 should cover artifact-type registry, immutable artifact reference, artifact state, retention state, export bundle, preserve or hold state, soft delete or hard delete semantics, suspended/read-only workspace behavior, and audit trail for export/delete/hold.
|
||||
- **Non-goals**: no legal case management, no full eDiscovery system, no Purview clone.
|
||||
|
||||
### Commercial Entitlements & Billing-State Lifecycle v1
|
||||
|
||||
- **Status markers**: repo-verified, foundation-only, productization gap
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: Specs 247 and 251 already ground workspace entitlements, lifecycle state handling, and read-only gating.
|
||||
- **Problem**: Workspace, plan, and billing state still need clearer artifact-access, archive, scheduled-deletion, and customer-trust semantics.
|
||||
- **Deep-Research-derived sharpening**: keep the lane framed as commercial lifecycle, workspace read-only behavior, artifact access by state, capability gating, and audited state change semantics rather than payment-engine work.
|
||||
- **Non-goals**: no payment gateway, no invoicing engine, no tax engine.
|
||||
|
||||
### External Support Desk / PSA Handoff v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 256 and the current support-request handoff already prove a bounded create/link model.
|
||||
- **Problem**: MSP governance work still needs cleaner PSA/ITSM handoff with external reference continuity, evidence/context transfer, due date/status mapping, closure sync or manual reconciliation, audit trail, and webhook-ready shape.
|
||||
- **Deep-Research-derived sharpening**: keep PSA/ITSM as integration and handoff, not as a TenantPilot-native helpdesk.
|
||||
- **Non-goals**: no PSA clone, no project-management suite, no generic helpdesk.
|
||||
|
||||
### Customer-Facing Localization v1
|
||||
|
||||
- **Status markers**: foundation-only, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 252 and the locale resolver already provide the foundation.
|
||||
- **Problem**: DE/EN customer-facing review consumption still lacks full glossary discipline, customer-safe labels, review-pack templates, notification text, and fallback confidence.
|
||||
- **Deep-Research-derived sharpening**: v1 means glossary, review-workspace strings, review-pack templates, evidence labels, status/reason/impact/next-action labels, locale-aware formatting, fallback behavior, and missing-key tests.
|
||||
- **Non-goals**: no full operator-UI localization in v1, no marketing translation project, no uncontrolled string extraction.
|
||||
|
||||
### Cross-Tenant Compare & Promotion with Lineage v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 043 is already repo-real for compare and preflight, and Spec 264 now carries the execution follow-through on this branch.
|
||||
- **Problem**: portfolio action still needs governance-first execution with impact preview, promotion proposal, approval, `OperationRun` trace, before/after evidence, baseline lineage, rollback reference, and decision-record linkage.
|
||||
- **Deep-Research-derived sharpening**: keep this lane governance-first. It is not a generic policy-push or settings-sync surface.
|
||||
- **Non-goals**: no unmanaged mass remediation, no generic settings push, no admin mirror.
|
||||
|
||||
### Governance Service Packaging v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 260 and current governance-package delivery cues already exist.
|
||||
- **Problem**: MSPs need repeatable governance-service packages with review cadence, included controls/reports, stakeholder mapping, schedule, package-specific review-pack semantics, and entitlement binding.
|
||||
- **Deep-Research-derived sharpening**: productize packaging as a repeatable governance service, not as one-off executive exports or bespoke customer projects.
|
||||
- **Non-goals**: no CRM, no PSA, no billing system.
|
||||
|
||||
### Enterprise Access Boundary & Support Access Governance v1
|
||||
|
||||
- **Status markers**: roadmap recommendation, spec candidate
|
||||
- **Roadmap lane**: Next when support-access gaps turn operationally acute; otherwise Later
|
||||
- **Current repo truth**: audit docs and handover material already show break-glass, system access, and platform support seams, but not a bounded support-access governance package.
|
||||
- **Problem**: support access, delegated access, and future impersonation need customer-safe auditability, reason capture, TTL, approval, operator-context banner, and exportable access logs before broader SSO/SCIM work.
|
||||
- **Roadmap Recommendation**: prioritize support-access request, reason required, time-limited access, capability-bound access, customer-visible audit trail, optional approval, break-glass separation, operator context banner, and exportable access log.
|
||||
- **Non-goals**: no full IAM suite, no immediate SCIM requirement unless separately promoted, no unrestricted impersonation.
|
||||
|
||||
### Private AI Execution Governance Foundation v1
|
||||
|
||||
- **Status markers**: repo-verified, foundation-only, later scale-layer
|
||||
- **Roadmap lane**: Later
|
||||
- **Current repo truth**: Spec 248 is already implemented as a governed AI foundation.
|
||||
- **Problem**: visible AI work must still avoid feature-island drift and should only ship on top of governed use-case, provider-class, policy, audit, and approval boundaries.
|
||||
- **Deep-Research-derived sharpening**: keep AI foundation-first. The next visible candidate is a bounded governed runtime consumer or AI-assisted review-drafting lane, not a new foundation reboot.
|
||||
- **Non-goals**: no public LLM by default, no autonomous remediation, no chatbot over raw tenant data, no AI without audit and policy boundaries.
|
||||
|
||||
## Active Candidate Queue
|
||||
|
||||
@ -51,45 +146,49 @@ ## Active Candidate Queue
|
||||
|
||||
These packages already provide the needed preparation surface, and several now carry completed task checklists or implementation close-out history. They must not be auto-selected again by `next-best-prep`.
|
||||
|
||||
Two manual-promotion items have since moved out of backlog status on the current repo state:
|
||||
|
||||
- `Auditor Pack Delivery & Executive Export v1` -> Spec 263
|
||||
- `Cross-Tenant Promotion Execution v1` -> Spec 264
|
||||
|
||||
The historical record for `Workspace, Tenant & Managed Object Lifecycle Governance v1` remains below because it was promoted intentionally, not automatically, into Spec 262 and must not re-enter the active auto-prep queue.
|
||||
|
||||
## Promotable Candidate Backlog
|
||||
|
||||
**Boundary**: manual promotion only, not auto-prep. These items are intentionally outside `next-best-prep` and require an explicit product decision before any future spec refresh or follow-up work.
|
||||
|
||||
### Auditor Pack Delivery & Executive Export v1
|
||||
### Decision Register & Approval Workflow v1
|
||||
|
||||
- **Priority**: 1
|
||||
- **Repo truth**: review-pack export, evidence, tenant review, customer review productization, compliance mapping, and governance packaging foundations are already spec-backed.
|
||||
- **Why promotable now**: this is the clearest remaining step between repo-real governance truth and auditor-/executive-ready delivery.
|
||||
- **Why manual promotion only**: this is a bounded packaging and export decision, not an automatic next-best-prep foundation gap.
|
||||
- **Anchors**:
|
||||
- `specs/109-review-pack-export/spec.md`
|
||||
- `specs/153-evidence-domain-foundation/spec.md`
|
||||
- `specs/155-tenant-review-layer/spec.md`
|
||||
- `specs/258-customer-review-productization/spec.md`
|
||||
- `specs/259-compliance-evidence-mapping/spec.md`
|
||||
- `specs/260-governance-service-packaging/spec.md`
|
||||
|
||||
### Cross-Tenant Promotion Execution v1
|
||||
|
||||
- **Priority**: 2
|
||||
- **Repo truth**: cross-tenant compare preview and promotion preflight are already prepared, but execution is still the missing product action.
|
||||
- **Why promotable now**: this is the next MSP-multiplier after customer-safe review and packaging follow-through.
|
||||
- **Why manual promotion only**: the repo already has the compare/preflight preparation package, so only the narrower execution slice should be promoted deliberately.
|
||||
- **Anchors**:
|
||||
- `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
||||
|
||||
### Governance Decision Pack & Approval Workflow v1
|
||||
|
||||
- **Priority**: 3
|
||||
- **Repo truth**: governance convergence is spec-backed, but the bounded human-in-the-loop decision-pack workflow is still a distinct follow-up gap.
|
||||
- **Why promotable now**: it is the next decision-based operating slice after convergence, without expanding into autonomous remediation.
|
||||
- **Why manual promotion only**: this must stay an explicit product choice because the v1 scope is intentionally narrow: decision pack, reason, impact, evidence, recommended action, approve, reject, snooze, assign, audit trail, optional OperationRun link, no autonomous remediation in v1.
|
||||
- **Repo truth**: governance inbox and convergence are spec-backed and partly repo-real, but the bounded decision-register and approval workflow is still a distinct follow-up gap.
|
||||
- **Why promotable now**: it is the highest-value unspecced operator follow-through once inbox and review consumption are already grounded.
|
||||
- **Why manual promotion only**: this must stay a deliberate product choice because v1 should remain narrow: owner, due date, status, reason, impact, next action, linked evidence, linked `OperationRun`, accepted-risk path, closure reason, escalation hook, and optional approval or closure semantics without autonomous remediation.
|
||||
- **Anchors**:
|
||||
- `specs/250-decision-governance-inbox/spec.md`
|
||||
- `specs/257-governance-decision-convergence/spec.md`
|
||||
- `docs/product/roadmap.md`
|
||||
|
||||
### Governance Artifact Lifecycle & Retention v1
|
||||
|
||||
- **Priority**: 2
|
||||
- **Repo truth**: lifecycle taxonomy, artifact-truth semantics, and point retention rules already exist, but governance artifacts still lack one productized lifecycle runtime contract.
|
||||
- **Why promotable now**: this is the clearest new trust and auditability gap highlighted by the deep research.
|
||||
- **Why manual promotion only**: it crosses lifecycle, artifact, export, retention, and suspension semantics and therefore needs an explicit product boundary rather than automatic prep.
|
||||
- **Anchors**:
|
||||
- `specs/158-artifact-truth-semantics/spec.md`
|
||||
- `specs/262-lifecycle-governance-taxonomy/spec.md`
|
||||
- `docs/product/standards/lifecycle-governance.md`
|
||||
|
||||
### Billing & Subscription Truth Layer v1
|
||||
|
||||
- **Priority**: 3
|
||||
- **Repo truth**: plans, entitlements, and commercial lifecycle maturity are already spec-backed, but the billing/subscription truth layer is still missing.
|
||||
- **Why promotable now**: deep-research alignment moves commercial trust and lifecycle closer to the now lane, even though the remaining unspecced slice is still the narrower billing/subscription follow-through.
|
||||
- **Why manual promotion only**: the broad readiness work is already covered, so this should not reappear as an automatic foundation candidate.
|
||||
- **Anchors**:
|
||||
- `specs/247-plans-entitlements-billing-readiness/spec.md`
|
||||
- `specs/251-commercial-entitlements-billing-state/spec.md`
|
||||
|
||||
### Customer-Facing Localization Adoption v1
|
||||
|
||||
- **Priority**: 4
|
||||
@ -101,15 +200,17 @@ ### Customer-Facing Localization Adoption v1
|
||||
- `specs/258-customer-review-productization/spec.md`
|
||||
- `specs/260-governance-service-packaging/spec.md`
|
||||
|
||||
### Billing & Subscription Truth Layer v1
|
||||
### Enterprise Access Boundary & Support Access Governance v1
|
||||
|
||||
- **Priority**: 5
|
||||
- **Repo truth**: plans, entitlements, and commercial lifecycle maturity are already spec-backed, but the billing/subscription truth layer is still missing.
|
||||
- **Why promotable now**: this is the remaining commercial truth gap after entitlements and lifecycle groundwork.
|
||||
- **Why manual promotion only**: the broad readiness work is already covered, so this should not be reintroduced as an automatic foundation candidate.
|
||||
- **Repo truth**: break-glass and platform access seams are documented, but no bounded support-access governance package currently exists.
|
||||
- **Why promotable now**: this is the narrow early access-governance slice that should happen before broad SSO/SCIM ambitions if support access and customer-visible auditability become pressing.
|
||||
- **Why manual promotion only**: the right cut is product-sensitive and must stay tightly bounded around support access, delegated access, TTL, approval, audit trail, and operator-context visibility.
|
||||
- **Anchors**:
|
||||
- `specs/247-plans-entitlements-billing-readiness/spec.md`
|
||||
- `specs/251-commercial-entitlements-billing-state/spec.md`
|
||||
- `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`
|
||||
- `docs/HANDOVER.md`
|
||||
- `specs/065-tenant-rbac-v1/spec.md`
|
||||
- `specs/066-rbac-ui-enforcement-helper/spec.md`
|
||||
|
||||
### Stored Reports Surface v1
|
||||
|
||||
@ -230,6 +331,8 @@ ## Promoted to Spec
|
||||
- Governance-as-a-Service Packaging v1 -> Spec 260 (`governance-service-packaging`)
|
||||
- Provider-Missing Policy Visibility & Restore Continuity v1 -> Spec 261 (`provider-missing-policy-visibility`)
|
||||
- Workspace, Tenant & Managed Object Lifecycle Governance v1 -> Spec 262 (`lifecycle-governance-taxonomy`)
|
||||
- Auditor Pack Delivery & Executive Export v1 -> Spec 263 (`auditor-pack-executive-export`)
|
||||
- Cross-Tenant Promotion Execution v1 -> Spec 264 (`cross-tenant-promotion-execution`)
|
||||
- Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`)
|
||||
- Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`)
|
||||
- Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`)
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
# Specification Quality Checklist: Cross-Tenant Promotion Execution v1
|
||||
|
||||
**Purpose**: Validate specification completeness and repo fit before implementation
|
||||
**Created**: 2026-05-02
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The spec stays on one bounded follow-up over the already-implemented read-only compare and preflight slice instead of reopening portfolio compare foundations.
|
||||
- [x] The spec is product- and behavior-oriented and does not read like an implementation diff.
|
||||
- [x] The spec explicitly names the current repo-real foundations it builds on: `CrossTenantComparePage`, compare preview, promotion preflight, launch context, shared `OperationRun` UX, and current provider-write seams.
|
||||
- [x] Mandatory repo sections for scope, RBAC, disclosure, testing, operation UX, and proportionality are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No `[NEEDS CLARIFICATION]` markers remain.
|
||||
- [x] Requirements are testable and bounded to one compare-page execution path.
|
||||
- [x] The spec explains what remains in scope versus what is intentionally deferred.
|
||||
- [x] Acceptance scenarios cover queueing execution, Monitoring continuity, and target-safe authorization behavior.
|
||||
- [x] Edge cases cover stale preflight, no-ready execution, active-run dedupe, inaccessible tenants, and operational-control blocking.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and `docs/product/roadmap.md`.
|
||||
- [x] `docs/product/implementation-ledger.md` still records promotion execution as the missing delta after compare and preflight.
|
||||
- [x] Existing Spec 043 was checked for completion and treated as inherited context only; this package is explicitly the execution follow-up, not a rewrite of the read-only slice.
|
||||
- [x] The chosen slice is smaller and higher-priority than deferred alternatives such as batch promotion, approvals, rollback, or mapping automation.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The slice is an explicit delta follow-up over Spec 043 and current code, centered on one canonical compare page and one queued run path.
|
||||
- [x] The spec explicitly forbids new panel or Laravel or Filament service-provider registration changes, new global-search scope, new asset strategy, and new promotion-draft persistence.
|
||||
- [x] The spec explicitly records one canonical `promotion.execute` `OperationRun` path and shared Monitoring continuity.
|
||||
- [x] The artifacts explicitly distinguish conditional `ProviderOperationRegistry` wiring from Laravel or Filament service-provider registration.
|
||||
- [x] The spec acknowledges the current repo constraint that foreign-tenant `PolicyVersion` execution is not directly allowed and therefore requires a bounded bridge instead of a fake direct reuse claim.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned validation stays bounded to `PortfolioCompare` unit and feature families plus one new bounded browser smoke file.
|
||||
- [x] One new browser smoke file is explicitly justified because the feature adds a confirmation modal and compare-to-Monitoring handoff on a live Filament page.
|
||||
- [x] The runtime proof commands stay consistent across spec, plan, and tasks, while Pint remains standard implementation hygiene.
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/product/implementation-ledger.md`, `specs/043-cross-tenant-compare-and-promotion/spec.md`, current PortfolioCompare code and tests under `apps/platform`, current `OperationRun` UX seams, current restore or policy-version write seams, and `.specify/memory/constitution.md` on 2026-05-02.
|
||||
- No application implementation was performed while preparing this package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Outcome**: `keep`
|
||||
- **Reason**: The spec promotes the next real manual backlog item after the implemented read-only compare slice, stays on one canonical compare page plus one run type, explicitly rejects draft persistence and batch drift, and only introduces one justified browser smoke addition.
|
||||
- **Workflow result**: Ready for implementation.
|
||||
230
specs/264-cross-tenant-promotion-execution/plan.md
Normal file
230
specs/264-cross-tenant-promotion-execution/plan.md
Normal file
@ -0,0 +1,230 @@
|
||||
# Implementation Plan: Cross-Tenant Promotion Execution v1
|
||||
|
||||
**Branch**: `264-cross-tenant-promotion-execution` | **Date**: 2026-05-02 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/264-cross-tenant-promotion-execution/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
This plan is the execution delta over Spec 043 and the current cross-tenant compare code path. The existing compare page, compare preview builder, promotion preflight, launch context, and preflight audit are inherited. The implementation scope is only to add one bounded `Execute promotion` path that reuses the current compare and preflight truth, requires explicit confirmation, queues exactly one canonical `promotion.execute` `OperationRun`, and keeps result truth on the shared Monitoring path. The implementation must not add a persisted promotion-draft or compare-snapshot entity.
|
||||
|
||||
The most important repo-truth constraint is already visible in current code: `RestoreService::executeFromPolicyVersion()` rejects foreign-tenant `PolicyVersion` records, so the execution path cannot be implemented as a naive direct call to the existing policy-version restore job. v1 therefore needs one bounded promotion execution planner or bridge that translates source-tenant content into target-safe write inputs while still delegating the actual target mutation through current provider-write seams.
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `CrossTenantComparePage` already owns the canonical `/admin/cross-tenant-compare` selection and preflight surface.
|
||||
- `CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight` already provide reproducible compare and readiness truth.
|
||||
- tenant-registry and portfolio launch context plus return-state continuity already work.
|
||||
- preflight audit already uses the current workspace audit pipeline.
|
||||
- current tests already prove compare preview, preflight, authorization, audit, and launch continuity.
|
||||
|
||||
### Explicit delta in this plan
|
||||
|
||||
- add one queued `promotion.execute` run type and the smallest supporting control, capability, and audit wiring required for it
|
||||
- add one bounded promotion execution planner or bridge that consumes the current compare and preflight truth
|
||||
- wire `Execute promotion` onto the current compare page with explicit confirmation and shared start-result UX
|
||||
- keep Monitoring continuity on the existing `OperationRun` viewer and current run-link contract
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: `CrossTenantComparePage`, `CrossTenantComparePreviewBuilder`, `CrossTenantPromotionPreflight`, `OperationRunService`, `OperationCatalog`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `WorkspaceAuditLogger`, `AuditActionId`, and the current policy-version or restore write seam
|
||||
**Storage**: PostgreSQL tables already in use for `OperationRun`, audit logs, policy versions, inventory, and existing provider-write artifacts
|
||||
**Testing**: Pest unit and feature tests plus one bounded browser smoke
|
||||
**Validation Lanes**: `fast-feedback`, `confidence`, `browser`
|
||||
**Target Platform**: existing Laravel admin runtime under `apps/platform`
|
||||
**Project Type**: Laravel monolith with Filament admin surfaces
|
||||
**Performance Goals**: no synchronous provider mutation from the compare page, no second queue family, and no new heavy browser or provider fixture domain
|
||||
**Constraints**: no promotion-draft table, no compare-snapshot table, no direct foreign-tenant `PolicyVersion` execution, no multi-target batch, no approval chain, no rollback engine
|
||||
**Scale/Scope**: one source tenant, one target tenant, one current compare scope, one queued run
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament compare page plus shared `OperationRun` UX only
|
||||
- **Shared-family relevance**: compare-page header actions, confirmation modal, queued start feedback, run links, Monitoring continuity
|
||||
- **State layers in scope**: page state, query state, preflight state, action state, confirmation modal state, and existing run-link state
|
||||
- **Audience modes in scope**: operator-MSP only
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary and mutation scope first, subject-level execution diagnostics second, raw provider detail last and kept off the compare page
|
||||
- **Raw/support gating plan**: raw provider payloads, provider IDs, and worker detail remain behind existing tenant or Monitoring detail surfaces
|
||||
- **One-primary-action / duplicate-truth control**: the compare page keeps exactly one dominant next action at a time, and Monitoring remains the only progress detail surface after queueing
|
||||
- **Handling modes by drift class or surface**: `review-mandatory` for any drift toward a second promotion surface, draft persistence, or local notification flow
|
||||
- **Repository-signal treatment**: `review-mandatory`
|
||||
- **Special surface test profiles**: `standard-native-filament`
|
||||
- **Required tests or manual smoke**: focused PortfolioCompare feature coverage plus one bounded browser smoke for compare-to-operation handoff
|
||||
- **Exception path and spread control**: none; any proposal for draft persistence, approvals, rollback, or batch execution is a scope split, not an in-feature exception
|
||||
- **Active feature PR close-out entry**: Smoke Coverage
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `CrossTenantComparePage`, compare preview and preflight services, operation catalog and capability wiring, current Monitoring links, audit logging, and the bounded provider-write bridge for target mutation
|
||||
- **Shared abstractions reused**: `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `WorkspaceUiEnforcement`, `WorkspaceAuditLogger`, and current compare and preflight services
|
||||
- **New abstraction introduced? why?**: yes. One bounded `PortfolioCompare` execution planner or bridge is required because current restore helpers are tenant-owned and reject foreign-tenant policy versions. One queued promotion job is required because target mutation must not run synchronously on the compare page.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the current compare and preflight seam is sufficient for readiness and exclusion truth. It is insufficient for execution because it does not generate a target-safe mutation plan or a queued run identity.
|
||||
- **Bounded deviation / spread control**: keep the new logic local to the `PortfolioCompare` domain and current run UX seams. Do not create a new promotion module, workspace, or dashboard family.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Central contract reused**: `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, and `OpsUxBrowserEvents`
|
||||
- **Delegated UX behaviors**: queued or deduped start messaging, blocked or scope-busy messaging, Monitoring link generation, run-enqueued browser event, and terminal notification stay on the shared run UX layer
|
||||
- **Surface-owned behavior kept local**: compare-page confirmation copy, excluded-subject explanation, and current launch or return-state preservation remain local to the compare page
|
||||
- **Queued DB-notification policy**: no new queued-only DB notification path
|
||||
- **Terminal notification path**: existing `OperationRun` completion path remains authoritative
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Provider-owned seams**: current policy-version capture, restore semantics, assignment or scope-tag handling, and provider write execution stay provider-owned
|
||||
- **Platform-core seams**: compare-page wording, run vocabulary, authorization, operational control labels, and Monitoring continuity stay platform-owned
|
||||
- **Neutral platform terms / contracts preserved**: source tenant, target tenant, governed subject, promotion execution, ready subject, blocked reason, manual mapping required
|
||||
- **Retained provider-specific semantics and why**: Microsoft-specific payload and target mutation semantics remain inside the existing provider-write seam because the repo currently has one real provider
|
||||
- **Bounded extraction or follow-up path**: if current provider-write seams cannot support a bounded bridge without widening persistence, stop and split before introducing a second promotion truth
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins and again before merge.*
|
||||
|
||||
- Inventory-first: compare preview and preflight remain derived from current tenant-owned inventory or captured content; execution consumes that truth instead of inventing a parallel catalog
|
||||
- Read/write separation: preflight stays read-only; mutation begins only after explicit confirmation and queued execution
|
||||
- Graph contract path: direct Graph or provider writes must stay behind the current provider-write seam, never in the Filament page action itself
|
||||
- Deterministic capabilities: execution stays on existing capability registries and current workspace or tenant isolation rules
|
||||
- RBAC-UX: inaccessible tenants remain `404`; execution denials remain explicit `403` only for in-scope actors forcing a blocked mutation path
|
||||
- Workspace isolation: unchanged
|
||||
- Tenant isolation: source tenant stays read-only; target tenant owns the mutation boundary
|
||||
- Run observability: exactly one canonical `promotion.execute` `OperationRun` is required
|
||||
- OperationRun start UX: shared start UX remains authoritative
|
||||
- Ops-UX lifecycle and summary counts: stay on existing `OperationSummaryKeys`; no new summary-key family
|
||||
- Test governance: keep proof bounded to PortfolioCompare unit, feature, and one browser smoke file only
|
||||
- Proportionality / persistence / bloat: no new table, no approval chain, no rollback, no draft persistence
|
||||
- Shared pattern first: current compare page and run UX must be extended, not bypassed
|
||||
- Provider boundary: use the bounded bridge only to cross the tenant-owned restore restriction; do not generalize it into a second provider abstraction layer
|
||||
- V1 explicitness / few layers: prefer one planner or bridge plus one job over a subsystem of drafts, approvals, and orchestration records
|
||||
- Filament-native UI: keep the current compare page as the only operator surface; no new panel or Laravel or Filament service-provider registration work is needed
|
||||
- UI or UX surface taxonomy and decision-first operating model: compare remains the decision surface and Monitoring remains the run viewer
|
||||
- Audience-aware disclosure: operators see readiness, mutation scope, and run link first; raw provider detail stays hidden by default
|
||||
- Action-surface discipline: the compare page keeps one dominant next action at a time
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit, Feature, Browser
|
||||
- **Affected validation lanes**: `fast-feedback`, `confidence`, `browser`
|
||||
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves plan derivation and ready-only filtering, feature coverage proves Filament action, auth, and audit behavior, and one browser smoke proves the confirmation modal plus Monitoring handoff on the real compare page
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: reuse current portfolio-compare fixtures and current `OperationRun` assertions; avoid new provider seed domains or broad Monitoring fixtures
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: one new bounded browser smoke file only
|
||||
- **Surface-class relief / special coverage rule**: `standard-native-filament` with required real-browser confirmation coverage
|
||||
- **Closing validation and reviewer handoff**: reviewers should confirm that the queued path, audit trail, and Monitoring handoff all stay on shared seams with no draft persistence
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: lane fit, no-draft persistence, run type or control naming, and bounded target-write bridge only
|
||||
- **Escalation path**: none
|
||||
- **Active feature PR close-out entry**: Smoke Coverage
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/264-cross-tenant-promotion-execution/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (expected implementation surfaces)
|
||||
|
||||
```text
|
||||
apps/platform/app/Filament/Pages/CrossTenantComparePage.php
|
||||
apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php
|
||||
apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php
|
||||
apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionExecutionPlanner.php
|
||||
apps/platform/app/Services/PortfolioCompare/CrossTenantPromotionExecutionService.php
|
||||
apps/platform/app/Jobs/Operations/CrossTenantPromotionExecutionJob.php
|
||||
apps/platform/app/Support/OperationCatalog.php
|
||||
apps/platform/app/Support/OperationRunType.php
|
||||
apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php
|
||||
apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php
|
||||
apps/platform/app/Services/Providers/ProviderOperationRegistry.php
|
||||
apps/platform/app/Support/Audit/AuditActionId.php
|
||||
apps/platform/app/Services/Audit/WorkspaceAuditLogger.php
|
||||
apps/platform/app/Support/OperationRunLinks.php
|
||||
apps/platform/app/Support/Navigation/RelatedNavigationResolver.php
|
||||
apps/platform/app/Services/OperationRunService.php
|
||||
apps/platform/app/Services/Intune/RestoreService.php
|
||||
apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php
|
||||
apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php
|
||||
apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php
|
||||
apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php
|
||||
apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php
|
||||
apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php
|
||||
apps/platform/tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: keep the implementation inside the current compare page, `PortfolioCompare` support or service layer, and current `OperationRun` UX seams. Add at most one bounded planner or bridge, one execution service, and one queued job.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
- Prefer existing `OperationRun.context`, `OperationRun.summary_counts`, and audit metadata over new persistence.
|
||||
- No new table or migration is expected for v1.
|
||||
- If operation or control naming needs only PHP registry changes, keep it there and avoid schema work.
|
||||
- If implementation cannot bridge source content into target-safe mutation inputs without a new persisted promotion-draft or snapshot entity, stop and split the feature rather than widening the current package.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Filament remains v5 on Livewire v4. No new Laravel or Filament service-provider registration change is required, and service-provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- No global search change is required because the affected surface is an existing page and the Monitoring viewer is already in place.
|
||||
- No new asset registration is expected.
|
||||
- The new mutating action is not destructive in the delete sense, but it must still use explicit confirmation on the compare page.
|
||||
- Existing queue workers remain the deployment requirement for the new run path.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Reject any implementation that persists promotion drafts, compare snapshots, or approval records.
|
||||
- Reject any implementation that reuses `RestoreService::executeFromPolicyVersion()` by pretending the source version belongs to the target tenant.
|
||||
- Reject any implementation that introduces a second promotion surface, a second queue domain, or a promotion-specific dashboard.
|
||||
- Reject any implementation that invents new run-summary keys instead of staying on canonical keys.
|
||||
- Keep blocked and manual-mapping-required subjects excluded from target mutation in all paths.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Confirm the bounded execution seam
|
||||
|
||||
- Reconfirm the current compare and preflight truth plus the current restore restriction that foreign-tenant `PolicyVersion` execution is not directly allowed.
|
||||
- Decide the smallest target-safe bridge from source content to target write inputs without adding persistence.
|
||||
|
||||
### Phase 1 - Add operation vocabulary and bounded execution planning
|
||||
|
||||
- Add one canonical `promotion.execute` operation type, its control key, run capability mapping, any `ProviderOperationRegistry` entry only if the chosen shared start-result seam requires it, and audit action IDs.
|
||||
- Add one bounded planner or bridge that derives ready-only execution inputs and stable run identity values from the current compare and preflight truth.
|
||||
|
||||
`ProviderOperationRegistry` remains application operation-vocabulary wiring only. It is not Laravel or Filament service-provider registration.
|
||||
|
||||
### Phase 2 - Wire the compare page action and shared start UX
|
||||
|
||||
- Add `Execute promotion` to the current compare page with explicit confirmation, single-primary-action discipline, and preserved source, target, and return-state context.
|
||||
- Reuse shared start-result UX for queued, deduped, blocked, and Monitoring-link behaviors.
|
||||
|
||||
### Phase 3 - Execute the target mutation through one queued run
|
||||
|
||||
- Add one queued promotion execution job that consumes `OperationRun.context`, delegates actual writes through current provider-write seams, and records summary counts using canonical keys only.
|
||||
- Keep the compare page free of synchronous target mutation.
|
||||
|
||||
### Phase 4 - Audit, Monitoring continuity, and stop
|
||||
|
||||
- Record start and terminal audit metadata for promotion execution.
|
||||
- Ensure Monitoring links, related navigation, and labels remain canonical for the new run type.
|
||||
- Run the planned validation commands and stop without widening into drafts, approval flow, rollback, or batch execution.
|
||||
|
||||
## Why This Plan Is Narrow Enough
|
||||
|
||||
The repo already has the compare page, preflight truth, audit path, queue infrastructure, and Monitoring viewer. This plan adds only the missing bounded execution seam: one confirmed page action, one run type, one ready-only execution planner or bridge, and one queued worker. Everything larger stays explicitly deferred.
|
||||
311
specs/264-cross-tenant-promotion-execution/spec.md
Normal file
311
specs/264-cross-tenant-promotion-execution/spec.md
Normal file
@ -0,0 +1,311 @@
|
||||
# Feature Specification: Cross-Tenant Promotion Execution v1
|
||||
|
||||
**Feature Branch**: `264-cross-tenant-promotion-execution`
|
||||
**Created**: 2026-05-02
|
||||
**Status**: Ready for implementation
|
||||
**Input**: User description: "Cross-Tenant Promotion Execution v1"
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
This package is an explicit delta follow-up over Spec 043 and the current cross-tenant compare code path.
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `CrossTenantComparePage` already owns the canonical `/admin/cross-tenant-compare` decision surface.
|
||||
- `CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight` already produce reproducible read-only compare and readiness truth.
|
||||
- tenant-registry launch context, exact-two compare launch, and return-state preservation already exist.
|
||||
- preflight audit already lands on the existing workspace audit pipeline through `AuditActionId::CrossTenantPromotionPreflightGenerated`.
|
||||
- the current slice is intentionally read-only and explicitly deferred actual promotion execution, queueing, `OperationRun`, persisted compare snapshots, persisted promotion drafts, mapping automation, and customer-facing compare.
|
||||
|
||||
### Explicit delta in this spec
|
||||
|
||||
- add one bounded `Execute promotion` action from the current compare and preflight context
|
||||
- require explicit confirmation before any target mutation starts
|
||||
- queue exactly one canonical `OperationRun` for promotion execution and reuse the shared start/result UX
|
||||
- mutate the target tenant for `ready` subjects only while keeping `blocked` and `manual_mapping_required` subjects excluded and visible
|
||||
- keep execution truth on existing `OperationRun` and audit records instead of adding a new promotion-draft or compare-snapshot table
|
||||
|
||||
Everything broader remains inherited or explicitly out of scope.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: The product can already compare tenants and generate a read-only promotion preflight, but operators still cannot execute the bounded promotion step that the page recommends.
|
||||
- **Today's failure**: Cross-tenant compare ends at advice. Operators still need manual, off-platform steps to apply source settings to the target tenant and then separately reconstruct whether the action actually ran.
|
||||
- **User-visible improvement**: An authorized workspace operator can review a current compare preflight, confirm the scope, queue one cross-tenant promotion run, and follow that run through the existing Monitoring flow without leaving the canonical compare page.
|
||||
- **Smallest enterprise-capable version**: One compare-page execution action, one confirmation modal, one queued `OperationRun`, one bounded promotion execution planner and worker, one truthful Monitoring handoff, and explicit audit metadata. No draft persistence, no batch execution, and no rollback workflow ship in v1.
|
||||
- **Explicit non-goals**: No persisted promotion draft entity, no scheduled or recurring promotion, no multi-target batch execution, no approval workflow, no rollback engine, no customer-facing promotion, no cross-workspace execution, and no multi-provider abstraction expansion.
|
||||
- **Permanent complexity imported**: One bounded execution planner or bridge, one queued promotion job, one new canonical operation type plus control key, a small audit extension, and focused unit, feature, and browser proof.
|
||||
- **Why now**: `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and `docs/product/implementation-ledger.md` all still identify execution as the missing follow-up after the already-implemented read-only compare and preflight slice.
|
||||
- **Why not local**: A page-local mutation button without a shared run contract would bypass the current compare truth, safety gates, audit seams, and Monitoring continuity.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: New mutating action, new queued run type, new write bridge over an existing read-only workflow. Defense: the slice stays on one compare page, one target tenant, one run type, zero new tables, and existing provider write seams.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- existing canonical `/admin/cross-tenant-compare` page for selection, preflight review, and execution confirmation
|
||||
- existing `/admin/tenants` registry or portfolio-triage launch surfaces as the compare entrypoint and return context
|
||||
- existing Monitoring operation-run detail flow as the canonical follow-up after a promotion run is queued
|
||||
- **Data Ownership**:
|
||||
- source-of-truth remains the current compare preview, preflight result, source-tenant `PolicyVersion` or equivalent captured content, and target-tenant inventory or restore truth
|
||||
- runtime truth remains on `OperationRun`, current audit logs, and existing provider-write artifacts; no `CrossTenantPromotionDraft`, compare snapshot, or promotion-plan table is introduced
|
||||
- any execution context must live in `OperationRun.context`, `OperationRun.summary_counts`, and audit metadata only
|
||||
- **RBAC**:
|
||||
- workspace and tenant scoping stay deny-as-not-found first for out-of-scope actors or tenants
|
||||
- compare viewing keeps the existing 043 requirements: workspace baselines view plus tenant view on both source and target
|
||||
- execution requires the compare-view permissions plus `Capabilities::WORKSPACE_BASELINES_MANAGE` on the workspace and `Capabilities::TENANT_MANAGE` on the target tenant
|
||||
- source-tenant access stays read-only (`Capabilities::TENANT_VIEW`); the mutation boundary is the target tenant only
|
||||
- members who can view compare but cannot execute still see the execution affordance only where the page already stays decision-oriented, and that affordance must be disabled with explicit permission guidance while forced execution attempts return `403`
|
||||
- no new promotion-specific capability family may be introduced in v1
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: registry and portfolio launches continue to prefill the launched tenant as the `target tenant`, preserve the return-state token, and keep the `source tenant` intentionally chosen by the operator unless the current exact-two launch path already supplies both tenants.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: the page must re-resolve workspace membership, source tenant scope, target tenant scope, and execution capability before preflight or execution logic runs. Any inaccessible tenant input is treated as not found, and no `OperationRun` may be created from inaccessible or stale selection input.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: compare-page header actions, confirmation modal copy, queued start notifications, Monitoring links, run status messaging, audit metadata, and return-state continuity
|
||||
- **Systems touched**: `CrossTenantComparePage`, tenant-registry launch context, `CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, `CrossTenantPromotionPreflight`, `OperationRunService`, `OperationCatalog`, `OperationRunType`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `OpsUxBrowserEvents`, `WorkspaceAuditLogger`, `AuditActionId`, `OperationalControlCatalog`, and the current policy-version or restore write seam
|
||||
- **Existing pattern(s) to extend**: canonical compare page, current portfolio launch and return-state contract, shared `OperationRun` start UX, and existing Monitoring deep-link semantics
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `WorkspaceUiEnforcement`, `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `OpsUxBrowserEvents`, and the existing compare preview and preflight builders
|
||||
- **Operation-registry distinction**: any `ProviderOperationRegistry` update in v1 is app-level operation-vocabulary wiring only, and only if the chosen shared start-result seam requires it. It is not Laravel or Filament service-provider registration.
|
||||
- **Why the existing shared path is sufficient or insufficient**: compare and preflight already solve the selection and blocked-reason truth. They are insufficient for v1 execution because they do not produce a queued run, start/result UX, or target mutation plan. The current restore and policy-version seams solve provider write behavior and run lifecycle, but they are tenant-owned and cannot be pointed at a foreign tenant without a bounded promotion bridge.
|
||||
- **Allowed deviation and why**: none. The feature must extend the current compare page and shared run UX instead of creating a second promotion console or a promotion-specific dashboard.
|
||||
- **Consistency impact**: the terms `source tenant`, `target tenant`, `promotion preflight`, `Execute promotion`, `ready`, `blocked`, `manual mapping`, and `Open operation` must stay consistent across compare copy, confirmation copy, audit summaries, notifications, and Monitoring labels.
|
||||
- **Review focus**: reviewers must block any persisted draft entity, direct Graph write from the page action, or local notification/run-link flow that bypasses the shared `OperationRun` UX contract.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, and `OpsUxBrowserEvents`
|
||||
- **Delegated start/completion UX behaviors**: queued toast, deduped-or-already-running messaging, blocked or scope-busy result messaging, canonical Monitoring link generation, run-enqueued browser event dispatch, and normal terminal notification handling stay delegated to the shared run UX layer
|
||||
- **Local surface-owned behavior that remains**: compare selection state, preflight summary, excluded-subject explanation, and confirmation-modal wording remain owned by `CrossTenantComparePage`
|
||||
- **Queued DB-notification policy**: no new queued-only database-notification policy is introduced; v1 relies on the compare-page start result plus the existing terminal notification path
|
||||
- **Terminal notification path**: keep the existing initiator-aware `OperationRun` completion path
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Boundary classification**: mixed
|
||||
- **Seams affected**: compare selection to execution planning, source content resolution, target mutation bridge, operation vocabulary, and provider-backed write delegation
|
||||
- **Neutral platform terms preserved or introduced**: `source tenant`, `target tenant`, `governed subject`, `promotion execution`, `ready subject`, `blocked reason`, and `manual mapping required`
|
||||
- **Provider-specific semantics retained and why**: Microsoft-first policy types, assignment semantics, scope tags, and provider payload translation remain inside the current policy-version, restore, and provider write seams because the repo currently has one real provider domain
|
||||
- **Why this does not deepen provider coupling accidentally**: the page contract stays anchored on compare and preflight truth, and the queued mutation delegates into existing provider-write paths instead of exposing Graph-specific inputs or raw payload editing in the page surface
|
||||
- **Follow-up path**: multi-provider promotion remains a separate follow-up if it ever becomes current-release truth
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Canonical cross-tenant compare page | yes | Native Filament page plus shared compare and ops-ux primitives | compare summary, confirmation modal, run-start UX, Monitoring link continuity | page, query state, preflight state, action state, confirmation modal state | no | Reuses the current compare page; no second promotion surface is allowed |
|
||||
| Tenant registry / portfolio launch action | no | N/A | current launch context only | none beyond existing deep-link state | no | Launch behavior remains inherited from Spec 043 |
|
||||
| Monitoring operation-run detail | no | N/A | existing shared operation-run viewer | none beyond normal new-type rendering | no | v1 reuses the existing Monitoring viewer rather than introducing a promotion-specific detail screen |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Canonical cross-tenant compare page | Primary Decision Surface | Operator decides whether the ready portion of the current source selection should mutate the target tenant now | source and target summary, ready or blocked counts, exclusion counts, mutation scope, and the single next action | subject-level execution plan, mapping gaps, target exclusions, source and target drill-down links, and current run link after queueing | Primary because the compare page already owns the trusted preflight truth and can keep the execution decision tied to that truth | Moves directly from compare to confirmation to queued Monitoring handoff without a second workspace | Replaces off-platform handoff and manual note-taking with one bounded action path |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Canonical cross-tenant compare page | operator-MSP | source and target summary, preflight counts, excluded-subject totals, mutation scope, and current run link if a run exists | subject-level ready plan, mapping gaps, evidence freshness, and target-safe exclusions | raw provider payloads and deep provider diagnostics stay behind existing tenant, inventory, or Monitoring detail surfaces | `Generate promotion preflight` before eligibility, then `Execute promotion` after a current executable preflight exists | raw JSON, provider IDs, worker internals, and low-level payload diffs | the compare page owns readiness truth once; the confirmation modal restates only the mutation scope, and Monitoring owns run progress after queueing |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Canonical cross-tenant compare page | Utility / Workspace Decision | Queued execution decision | Execute promotion | explicit selectors plus focused compare or preflight panels and confirmation modal | forbidden | open-source, open-target, return, and open-operation links stay secondary | none; `Execute promotion` is mutating but not destructive and must still use confirmation | `/admin/cross-tenant-compare` | same page with shareable query state and current run handoff | workspace context plus source and target tenant chips | Cross-tenant compare | whether the ready part of the current source selection can be promoted now and what will be excluded | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether to queue a bounded promotion run against the target tenant | Canonical decision page | Can I safely apply the ready part of this source selection to the target tenant now, and what will be left out? | source and target summary, ready or blocked counts, manual-mapping and blocked totals, mutation scope, confirmation summary, and latest run handoff | subject-level execution plan, mapping gaps, source and target drill-downs, and follow-up hints | compare readiness, execution availability, and run state | Microsoft tenant target only | Generate promotion preflight, Execute promotion, Open operation, Open source tenant, Open target tenant | Execute promotion (requires confirmation) |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes - one bounded promotion execution planner or bridge plus one queued promotion job
|
||||
- **New enum/state/reason family?**: yes - one new canonical operation type and one operational control key, but no new persisted lifecycle family or draft state family
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: compare and preflight prove readiness, but operators still cannot execute the bounded next step or observe the result from the same truth.
|
||||
- **Existing structure is insufficient because**: the current compare slice stops before any `OperationRun`, and the current `RestoreService::executeFromPolicyVersion()` explicitly rejects foreign-tenant versions, so the execution path cannot be implemented as a naive direct call.
|
||||
- **Narrowest correct implementation**: derive a ready-only execution plan from the current compare and preflight truth, queue one target-tenant-scoped `promotion.execute` run, and translate source content into target-safe write inputs through an existing provider-write seam without introducing draft persistence.
|
||||
- **Ownership cost**: maintain one new operation vocabulary entry, one bounded execution bridge, one queued worker, small audit metadata expansion, and focused tests.
|
||||
- **Alternative intentionally rejected**: persisted promotion drafts, scheduled promotions, approval chains, and cross-tenant batch execution are intentionally rejected because they import new truth and operator workflow before the bounded execution seam is proven.
|
||||
- **Release truth**: current-release workflow gap, not a future-state platform ambition
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, migration shims, and legacy workflow preservation remain out of scope unless the implementation discovers a concrete repo-owned compatibility contract that the current spec missed.
|
||||
|
||||
Canonical replacement is preferred over parallel promotion workflows.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature, Browser
|
||||
- **Validation lane(s)**: fast-feedback, confidence, browser
|
||||
- **Why this classification and these lanes are sufficient**: unit coverage proves ready-only execution planning and no-draft behavior, focused feature coverage proves page action, authorization, audit, and Monitoring handoff, and one bounded browser smoke proves the confirmation modal and compare-to-operation handoff on the real Filament surface.
|
||||
- **New or expanded test families**: `tests/Unit/Support/PortfolioCompare/`, `tests/Feature/PortfolioCompare/`, and one new bounded `tests/Browser/PortfolioCompare/` smoke file
|
||||
- **Fixture / helper cost impact**: moderate; reuse the existing portfolio-compare fixtures, current workspace and tenant setup, and current `OperationRun` assertions rather than introducing a new provider or browser fixture domain
|
||||
- **Heavy-family visibility / justification**: one new browser smoke file is justified because the feature adds a confirmation modal and Monitoring handoff on a live Filament page
|
||||
- **Special surface test profile**: standard-native-filament
|
||||
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the compare and run-start contract, but the final compare-page handoff to Monitoring must be proven once in the browser
|
||||
- **Reviewer handoff**: reviewers must confirm the slice stays on one compare page, one run type, zero new draft tables, and shared run UX only
|
||||
- **Budget / baseline / trend impact**: low to moderate; one new browser smoke file and one new operation type only
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- one `Execute promotion` action on the canonical compare page after a current preflight exists
|
||||
- one explicit confirmation modal that names the source tenant, target tenant, ready counts, excluded counts, and mutation scope
|
||||
- one queued `promotion.execute` `OperationRun`
|
||||
- one bounded execution planner or bridge that translates ready subjects into target-safe write inputs
|
||||
- one truthful Monitoring handoff and run-link continuity
|
||||
- start and terminal audit metadata for the promotion execution path
|
||||
- compare-page continuity after queueing, including preserved source, target, and return-state context
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- persisted compare snapshots or promotion-draft tables
|
||||
- scheduled or recurring promotion execution
|
||||
- approval workflows or multi-actor sign-off
|
||||
- rollback or undo workflows
|
||||
- multi-target or batch promotion
|
||||
- mapping automation for manual-mapping-required subjects
|
||||
- customer-facing promotion surfaces
|
||||
- cross-workspace execution
|
||||
- multi-provider execution frameworks
|
||||
|
||||
## Assumptions
|
||||
|
||||
- the current compare and preflight contracts expose enough stable subject identity to resolve a bounded ready-only execution plan
|
||||
- the current provider-write seams can accept a target-safe execution payload once the source content is normalized through a bounded bridge
|
||||
- the existing Monitoring viewer can represent the new run type without a dedicated promotion detail resource
|
||||
- current queue workers already provide the runtime model needed for one more `OperationRun`-backed promotion job
|
||||
|
||||
## Risks
|
||||
|
||||
- some subjects marked `ready` by preflight may still fail at execution time because the current target write seam discovers provider constraints later than the read-only preflight can
|
||||
- the promotion bridge may be tempted to persist a new draft or snapshot entity; that must be rejected unless a separate follow-up spec is created
|
||||
- run-summary truth can drift if the implementation invents non-canonical summary keys instead of staying on the current `OperationSummaryKeys` set
|
||||
- a future implementation could try to broaden the slice into approval, rollback, or batch execution; that is out of scope for this spec
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
- manual mapping workflow for `manual_mapping_required` subjects
|
||||
- portfolio-level batch promotion across many targets
|
||||
- approval or sign-off gating before execution
|
||||
- rollback or replay workflow after a promotion run completes
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Queue one bounded promotion from the current preflight (Priority: P1)
|
||||
|
||||
As a workspace operator, I want to confirm and queue one bounded promotion from the current compare and preflight context so I can apply only the ready subjects to the target tenant without manual off-platform execution.
|
||||
|
||||
**Why this priority**: This is the core value gap left by Spec 043. Without queueable execution, compare remains advisory only.
|
||||
|
||||
**Independent Test**: Open the compare page with an executable preflight, confirm the action, and verify that one `promotion.execute` run is queued with ready subjects only.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the current preflight contains a mix of `ready`, `blocked`, and `manual_mapping_required` subjects, **When** the operator confirms `Execute promotion`, **Then** only the `ready` subjects enter the queued run context and the excluded subjects remain visible on the compare page.
|
||||
2. **Given** the current selection and preflight produce no `ready` subjects, **When** the operator tries to execute promotion, **Then** the action is blocked and no `OperationRun` is created.
|
||||
3. **Given** an active run already exists for the same source, target, subject scope, and executable plan, **When** the operator tries to execute again, **Then** the shared start UX dedupes or reuses the current run instead of creating a second active run.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Follow the queued promotion through Monitoring with truthful result summary (Priority: P1)
|
||||
|
||||
As a workspace operator, I want a canonical run link and truthful result summary after I queue a promotion so I can monitor completion and know what happened without leaving the existing Monitoring path.
|
||||
|
||||
**Why this priority**: Execution is unsafe if the operator cannot observe start, progress, and terminal outcome on the existing shared Monitoring path.
|
||||
|
||||
**Independent Test**: Queue a promotion run from the compare page and verify that the start result, Monitoring link, and run-summary truth all stay on the shared `OperationRun` contract.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a promotion run is successfully queued, **When** the compare page receives the start result, **Then** the operator sees the shared queued feedback and one canonical `Open operation` link.
|
||||
2. **Given** the promotion run reaches a terminal state, **When** the operator opens the Monitoring detail, **Then** the run summary clearly distinguishes processed, succeeded, failed, and skipped work using the shared summary-key vocabulary.
|
||||
3. **Given** the run start is blocked by operational control, legitimacy, or target-scoped gating, **When** the operator attempts execution, **Then** they receive the shared blocked feedback and no target mutation begins.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve portfolio context and execution safety boundaries (Priority: P2)
|
||||
|
||||
As a workspace operator, I want the execution path to preserve my compare and return-state context while still enforcing target-safe capability and control boundaries so the workflow stays usable and safe inside the portfolio review loop.
|
||||
|
||||
**Why this priority**: The workflow loses value if the operator must rebuild compare context after queueing or if the page hides the reasons why execution is unavailable.
|
||||
|
||||
**Independent Test**: Launch compare from the tenant registry, queue or attempt to queue promotion, and verify that source, target, return-state, and safety boundaries all remain intact.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the operator launched compare from a registry or portfolio context, **When** they queue a promotion, **Then** the compare page preserves the source tenant, target tenant, governed-subject filters, and return-state continuity.
|
||||
2. **Given** the operator can view compare but lacks target-tenant manage or workspace baseline-manage access, **When** they reach an otherwise executable compare state, **Then** execution stays visible only as a disabled safety-gated affordance and any forced execution attempt returns `403`.
|
||||
3. **Given** the source or target tenant becomes inaccessible or stale between preflight and confirmation, **When** the operator attempts execution, **Then** the request is rejected and no `OperationRun` is created.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- source and target tenant are the same tenant: reject as invalid input and do not queue a run
|
||||
- preflight is stale because the compare selection changed after it was generated: require regeneration and do not queue a run
|
||||
- no ready subjects remain after current truth is re-evaluated at execution time: fail safe and do not queue or start target mutation
|
||||
- the existing provider-write seam cannot resolve a target-safe payload from the source subject: mark the item failed or skipped inside the run; do not invent a draft state family
|
||||
- operational control or legitimacy gate blocks `promotion.execute`: surface the shared blocked message and keep the compare state intact
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The canonical `/admin/cross-tenant-compare` page must remain the only operator entrypoint for v1 promotion execution.
|
||||
- **FR-002**: Promotion execution must require a current compare preview and a current promotion preflight before queueing any target mutation.
|
||||
- **FR-003**: The page must expose exactly one dominant next action at a time: `Generate promotion preflight` before readiness exists, then `Execute promotion` once the current preflight is executable.
|
||||
- **FR-004**: `Execute promotion` must require explicit confirmation and must name the source tenant, target tenant, count of ready subjects, count of excluded subjects, and the target-only mutation scope.
|
||||
- **FR-005**: Only `ready` subjects from the current preflight may enter the queued execution plan; `blocked` and `manual_mapping_required` subjects must stay excluded from the run and visible to the operator.
|
||||
- **FR-006**: The execution path must create or reuse exactly one canonical `OperationRun` for the current executable plan, using one new canonical operation type for promotion execution.
|
||||
- **FR-007**: The feature must keep execution truth on existing `OperationRun` state, summary counts, and audit metadata only. No persisted promotion-draft, compare-snapshot, or approval entity may be added.
|
||||
- **FR-008**: The run-start path must reuse the shared `OperationRun` start UX contract for queued, deduped, blocked, and Monitoring-link behaviors.
|
||||
- **FR-009**: The implementation must not pretend that a source-tenant `PolicyVersion` belongs to the target tenant. Any bridge from source content to target mutation must preserve tenant-owned truth while still reusing existing provider-write semantics.
|
||||
- **FR-010**: Run summary counts must stay on canonical summary keys only. Promotion-specific meaning must be expressed through page or Monitoring copy, not a new summary-key family.
|
||||
- **FR-011**: The feature must record start and terminal audit events for promotion execution with source-tenant, target-tenant, selection, and outcome context.
|
||||
- **FR-012**: Compare and launch context must remain preserved after queueing, including source tenant, target tenant, governed-subject filters, and return-state payload.
|
||||
- **FR-013**: Execution authorization must require workspace baseline-manage and target-tenant manage in addition to the existing compare-view permissions.
|
||||
- **FR-014**: Filament must remain v5 on Livewire v4, and Laravel or Filament service-provider registration must remain unchanged in `apps/platform/bootstrap/providers.php`.
|
||||
- **FR-015**: No new panel, no new asset registration, no new Laravel or Filament service-provider registration work, and no new globally searchable resource may be introduced for this slice.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Cross-tenant compare selection**: existing derived scope of source tenant, target tenant, and governed-subject filters; remains read-only input truth.
|
||||
- **Promotion preflight**: existing derived readiness artifact grouping subjects into `ready`, `blocked`, and `manual_mapping_required`; becomes the only allowed execution source.
|
||||
- **Promotion execution plan**: new bounded in-memory or `OperationRun.context` representation of the ready-only mutation plan; not a persisted standalone entity.
|
||||
- **Promotion execution run**: one canonical `OperationRun` using the new operation type and existing Monitoring viewer.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An authorized operator can move from current compare preflight to a queued `promotion.execute` run without leaving the canonical compare page.
|
||||
- **SC-002**: The compare page never queues blocked or manual-mapping-required subjects for target mutation.
|
||||
- **SC-003**: The queued run can be opened through the shared Monitoring path and exposes truthful processed, succeeded, failed, and skipped counts.
|
||||
- **SC-004**: The implementation ships without a persisted promotion-draft table, second promotion surface, or second queue family.
|
||||
184
specs/264-cross-tenant-promotion-execution/tasks.md
Normal file
184
specs/264-cross-tenant-promotion-execution/tasks.md
Normal file
@ -0,0 +1,184 @@
|
||||
---
|
||||
description: "Task list for Cross-Tenant Promotion Execution v1"
|
||||
---
|
||||
|
||||
# Tasks: Cross-Tenant Promotion Execution v1
|
||||
|
||||
**Input**: Design documents from `specs/264-cross-tenant-promotion-execution/`
|
||||
**Prerequisites**: `specs/264-cross-tenant-promotion-execution/spec.md`, `specs/264-cross-tenant-promotion-execution/plan.md`, `specs/264-cross-tenant-promotion-execution/checklists/requirements.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest plus one bounded Browser smoke). Keep proof bounded to `PortfolioCompare` unit and feature families plus one new `Browser/PortfolioCompare` smoke file only.
|
||||
**Operations**: Introduce one canonical `promotion.execute` `OperationRun` type and reuse the shared `OperationRun` start UX, Monitoring links, and current provider-write seams. No promotion-draft table, no compare-snapshot table, no second queue family, and no second promotion dashboard are allowed.
|
||||
**RBAC**: Non-members and out-of-scope tenants remain `404`. Compare-view permissions stay inherited from Spec 043. Execution adds target-tenant manage plus workspace baseline-manage enforcement, with disabled affordance guidance and server-side `403` on forced execution. No new capability family may be introduced.
|
||||
**Shared Pattern Reuse**: Reuse `CrossTenantComparePage`, current launch and return context, `CrossTenantComparePreviewBuilder`, `CrossTenantPromotionPreflight`, `OperationRunService`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `OperationRunLinks`, `OpsUxBrowserEvents`, `WorkspaceAuditLogger`, `AuditActionId`, and current provider-write seams. Do not create persisted promotion drafts or a local run-start UX.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Laravel or Filament service-provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed.
|
||||
**Organization**: Tasks are grouped by user story so the execution contract, Monitoring continuity, and safety boundaries remain independently testable and implementable. This package is an explicit delta follow-up over Spec 043 and current code.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment stays `fast-feedback`, `confidence`, plus one bounded `browser` smoke and remains the narrowest sufficient proof.
|
||||
- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/`, `apps/platform/tests/Feature/PortfolioCompare/`, and one new `apps/platform/tests/Browser/PortfolioCompare/` smoke file only.
|
||||
- [x] Existing portfolio-compare fixtures and current `OperationRun` assertions are reused; no new heavy provider or browser fixture domain is introduced.
|
||||
- [x] Planned validation commands stay consistent across spec, plan, and tasks.
|
||||
- [x] The declared surface test profile remains `standard-native-filament` with explicit real-browser confirmation coverage.
|
||||
- [x] Any drift toward persisted drafts, approvals, rollback, or batch promotion is handled as `reject-or-split` rather than hidden inside this feature.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Confirm the current compare, preflight, run UX, and provider-write seams before any implementation change.
|
||||
|
||||
- [x] T001 Review `specs/264-cross-tenant-promotion-execution/spec.md`, `specs/264-cross-tenant-promotion-execution/plan.md`, `specs/264-cross-tenant-promotion-execution/checklists/requirements.md`, `specs/043-cross-tenant-compare-and-promotion/spec.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and `docs/product/implementation-ledger.md` together so the slice stays on the current execution gap only.
|
||||
- [x] T002 [P] Confirm the current compare-page and launch-context seams in `apps/platform/app/Filament/Pages/CrossTenantComparePage.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
||||
- [x] T003 [P] Confirm the current compare-preview and preflight seams in `apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php`, `apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php`, and the current PortfolioCompare test coverage.
|
||||
- [x] T004 [P] Confirm the current shared run-start, Monitoring-link, and operation-vocabulary seams in `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`, `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php`, and `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` only to determine whether app-level operation-registry wiring is required by the chosen shared start-result seam.
|
||||
- [x] T005 [P] Confirm the current target-write seam and its constraints in `apps/platform/app/Services/Intune/RestoreService.php`, `apps/platform/app/Jobs/BulkPolicyVersionRestoreJob.php`, and `apps/platform/app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php`, especially the current rule that foreign-tenant policy versions cannot be executed directly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Lock the bounded execution contract before surface-level implementation begins.
|
||||
|
||||
**Critical**: No user-story work should begin until this phase is complete.
|
||||
|
||||
- [x] T006 [P] Add `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php` to require ready-only execution planning, blocked and manual exclusion, stable run-identity inputs, and no-ready rejection.
|
||||
- [x] T007 [P] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php` to require `404` for out-of-scope tenants, disabled execution affordance for compare-only actors, and `403` for forced execution without target-manage or workspace-manage access.
|
||||
- [x] T008 [P] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php` to require one canonical `promotion.execute` run type, shared start-result statuses, and active-run dedupe for the same executable plan.
|
||||
- [x] T009 Implement the foundational execution contract in `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`, `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php`, `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` only if the shared start-result seam requires app-level operation-registry wiring, `apps/platform/app/Support/Audit/AuditActionId.php`, and the bounded `PortfolioCompare` execution planner or bridge chosen for v1 so the feature has one canonical operation vocabulary and no draft persistence. No `ProviderOperationRegistry` change was required because the shared start-result seam worked through the existing presenter/link contract.
|
||||
|
||||
**Checkpoint**: The canonical run vocabulary, control wiring, audit ids, and ready-only execution plan are locked before page and job work begins.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Queue one bounded promotion from the current preflight (Priority: P1)
|
||||
|
||||
**Goal**: An authorized operator can confirm and queue a target mutation for ready subjects only from the current compare and preflight context.
|
||||
|
||||
**Independent Test**: Open the compare page, generate a preflight with ready work, confirm `Execute promotion`, and verify that one `promotion.execute` run is queued with ready subjects only.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T010 [P] [US1] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php` to assert that preflight is required, only ready subjects enter the queued run context, blocked and manual subjects stay excluded, and no-ready execution is blocked.
|
||||
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php` only where needed to prove the compare page keeps one dominant next action at a time and does not regress the current same-tenant or stale-selection safeguards once execution exists.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T012 [US1] Add one bounded `PortfolioCompare` execution planner or bridge plus one execution service in `apps/platform/app/Support/PortfolioCompare/` and or `apps/platform/app/Services/PortfolioCompare/` that translates the current ready subjects into target-safe mutation inputs without creating a persisted promotion draft.
|
||||
- [x] T013 [US1] Update `apps/platform/app/Filament/Pages/CrossTenantComparePage.php` so the page exposes `Execute promotion` with explicit confirmation, preserves one-primary-action discipline, and keeps source, target, governed-subject filters, and return-state context intact after queueing.
|
||||
- [x] T014 [US1] Add one queued promotion execution job under `apps/platform/app/Jobs/Operations/` that consumes `OperationRun.context`, performs target mutation for ready subjects only, and records canonical summary counts (`total`, `processed`, `succeeded`, `failed`, `skipped`, `created`, `updated`) instead of inventing a new summary-key family.
|
||||
|
||||
**Checkpoint**: The canonical compare page can queue one bounded target mutation without persisting a draft or mutating blocked subjects.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Follow the queued promotion through Monitoring with truthful run UX (Priority: P1)
|
||||
|
||||
**Goal**: The operator receives shared queued feedback, one canonical Monitoring link, and truthful run summary after starting a promotion.
|
||||
|
||||
**Independent Test**: Queue a promotion run from the compare page and verify that the start result, Monitoring link, and run-summary truth all stay on the shared `OperationRun` contract.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T015 [P] [US2] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php` to assert queued, deduped, blocked, and Monitoring-link outcomes plus canonical run-context fields for the new operation type.
|
||||
- [x] T016 [P] [US2] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php` to assert queued and terminal audit metadata and the absence of any persisted promotion-draft or compare-snapshot writes.
|
||||
- [x] T017 [P] [US2] Add `apps/platform/tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php` to prove the live compare page can move from generated preflight to confirmation to queued `Open operation` handoff without breaking the existing action hierarchy.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T018 [US2] Wire the shared start-result UX through `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Services/Providers/ProviderOperationStartResultPresenter.php`, and `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php` so compare-page execution uses canonical queued, deduped, blocked, and run-link messaging. No additional `OperationRunService` or `OperationUxPresenter` changes were required beyond compare-page reuse of the shared presenter, link, and browser-event seams.
|
||||
- [x] T019 [US2] Extend `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and any minimal Monitoring label surface only as needed so `promotion.execute` opens the current Monitoring viewer without a promotion-specific detail screen. Existing `OperationRunLinks::tenantlessView()` continuity already satisfied this path without further runtime changes.
|
||||
- [x] T020 [US2] Extend `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and `apps/platform/app/Support/Audit/AuditActionId.php` only as needed so promotion execution records start and terminal audit truth with source-tenant, target-tenant, selection, and outcome metadata.
|
||||
|
||||
**Checkpoint**: The execution path starts, dedupes, blocks, links, and audits on the shared `OperationRun` seams only.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve portfolio context and safety boundaries (Priority: P2)
|
||||
|
||||
**Goal**: Execution preserves compare and return-state context while enforcing target-safe control and capability boundaries.
|
||||
|
||||
**Independent Test**: Launch compare from the tenant registry, queue or attempt to queue a promotion, and verify that source, target, return-state, and safety boundaries all remain intact.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T021 [P] [US3] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php` to prove queued promotion keeps source-tenant, target-tenant, governed-subject, and return-state continuity. The stable proof now uses the mounted compare-page instance on the exact-two registry launch path, which avoids the flaky secondary Livewire snapshot hop while still exercising the same launched page state and queued promotion contract.
|
||||
- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php` only where needed to prove stale-preflight, operational-control, and inaccessible-tenant execution attempts never create a run.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T023 [US3] Preserve compare-page launch and return context after queueing, and add any secondary `Open operation` continuity only where it does not compete with the primary action. No additional runtime change was required in the bounded slice beyond the existing compare-page state handling and shared operation link seams; T021 now provides the explicit queued launch-context continuity proof.
|
||||
- [x] T024 [US3] Integrate operational-control and legitimacy blocking for `promotion.execute` so blocked, stale, or inaccessible execution attempts fail safely without widening into approvals, rollback, or batch controls.
|
||||
|
||||
**Checkpoint**: The execution path stays usable inside the portfolio workflow and still fails closed at every safety boundary.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: Validate the bounded slice and stop without widening scope.
|
||||
|
||||
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php`.
|
||||
- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
||||
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php`.
|
||||
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [x] T029 [P] Review touched code to confirm Filament stays on Livewire v4, Laravel or Filament service-provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource contract changes appear, and the mutating compare-page action uses explicit confirmation.
|
||||
- [x] T030 [P] Review touched code to confirm the feature introduces no promotion-draft persistence, no compare-snapshot persistence, no second queue family, no second promotion surface, no approval workflow, and no rollback workflow.
|
||||
- [x] T031 [P] Record the final guardrail, smoke, and scope-boundary outcomes in the active feature close-out without reopening batch promotion, approvals, rollback, or multi-provider follow-up work.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the bounded execution path.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should land with US1 so the start-result UX and Monitoring truth do not drift from the queued path.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and hardens context and safety behavior after the execution path exists.
|
||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and delivers the core execution value.
|
||||
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the operator gets truthful Monitoring handoff.
|
||||
- **US3 (P2)**: independently testable after Phase 2 and hardens the bounded execution path.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Keep implementation inside the compare page, bounded `PortfolioCompare` execution seam, shared `OperationRun` UX, and current provider-write paths named above.
|
||||
- Re-run the narrowest relevant validation command after each story checkpoint before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2 together**. The feature is only useful when the compare page can both queue promotion and hand the operator into truthful Monitoring continuity.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1 and US2 together on the current compare page and shared run UX.
|
||||
3. Add US3 to keep return-state and safety boundaries intact.
|
||||
4. Finish with the focused validation and guardrail review tasks in Phase 6.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the bounded execution bridge first.
|
||||
2. Parallelize failing tests within each story before runtime edits.
|
||||
3. Serialize merges around `CrossTenantComparePage`, operation-catalog wiring, and shared Monitoring links so operator vocabulary stays coherent.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- persisted promotion drafts or compare snapshots
|
||||
- approval workflow before execution
|
||||
- rollback or replay workflow after execution
|
||||
- batch or multi-target promotion
|
||||
- automated manual-mapping resolution
|
||||
- customer-facing promotion surfaces
|
||||
- multi-provider execution framework
|
||||
Loading…
Reference in New Issue
Block a user