feat: cut over workspace-first admin environment surfaces
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 9m39s

This commit is contained in:
Ahmed Darrazi 2026-05-08 01:49:43 +02:00
parent 023274c46c
commit 93495bef13
100 changed files with 3121 additions and 333 deletions

View File

@ -4,6 +4,8 @@
namespace App\Console\Commands;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
@ -172,9 +174,9 @@ public function handle(): int
['User password', $password],
['ManagedEnvironment', (string) $tenant->name],
['ManagedEnvironment external id', (string) $tenant->external_id],
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
['Dashboard URL', TenantDashboard::getUrl(tenant: $tenant)],
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
['Blocked route', BackupSetResource::getUrl(panel: 'admin', tenant: $tenant)],
['Locally denied capability', 'tenant.view'],
],
);

View File

@ -65,10 +65,6 @@ private static function currentPanelId(mixed $request): ?string
: null;
if (is_string($routeName) && $routeName !== '') {
if (str_contains($routeName, '.tenant.')) {
return 'tenant';
}
if (str_contains($routeName, '.admin.')) {
return 'admin';
}
@ -78,10 +74,6 @@ private static function currentPanelId(mixed $request): ?string
? '/'.ltrim((string) $request->path(), '/')
: null;
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
return 'tenant';
}
if (is_string($path) && str_starts_with($path, '/admin/')) {
return 'admin';
}

View File

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use App\Models\ManagedEnvironment;
use App\Models\Workspace;
use Filament\Facades\Filament;
use Filament\Panel;
use Illuminate\Database\Eloquent\Model;
trait WorkspaceScopedTenantRoutes
{
public static function getSlug(?Panel $panel = null): string
{
return static::workspaceScopedSlug(parent::getSlug($panel), $panel);
}
/**
* @param array<string, mixed> $parameters
*/
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
$panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
if ($panelId !== 'admin') {
return parent::getUrl($name, $parameters, $isAbsolute, $panelId, $tenant, $shouldGuessMissingParameters);
}
$resolvedTenant = static::resolveWorkspaceScopedTenant($parameters, $tenant);
if (! $resolvedTenant instanceof ManagedEnvironment) {
return url('/admin');
}
$workspace = static::resolveWorkspaceScopedWorkspace($resolvedTenant, $parameters);
if (! $workspace instanceof Workspace && ! is_string($workspace) && ! is_int($workspace)) {
return url('/admin');
}
$parameters['tenant'] ??= $resolvedTenant;
$parameters['workspace'] ??= $workspace;
return parent::getUrl($name, $parameters, $isAbsolute, $panelId, null, $shouldGuessMissingParameters);
}
protected static function workspaceScopedSlug(string $slug, ?Panel $panel = null): string
{
if (! static::shouldUseWorkspaceScopedTenantRoutes($panel)) {
return $slug;
}
$prefix = 'workspaces/{workspace}/environments/{tenant}/';
return str_starts_with($slug, $prefix)
? $slug
: $prefix.ltrim($slug, '/');
}
protected static function shouldUseWorkspaceScopedTenantRoutes(?Panel $panel = null): bool
{
$panelId = $panel?->getId() ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin';
return $panelId === 'admin';
}
/**
* @param array<string, mixed> $parameters
*/
protected static function resolveWorkspaceScopedTenant(array $parameters, ?Model $tenant = null): ?ManagedEnvironment
{
$parameterTenant = $parameters['tenant'] ?? $parameters['environment'] ?? null;
if ($parameterTenant instanceof ManagedEnvironment) {
return $parameterTenant;
}
if ($tenant instanceof ManagedEnvironment) {
return $tenant;
}
$record = $parameters['record'] ?? null;
if ($record instanceof Model) {
$relationshipName = static::workspaceScopedTenantRelationshipName();
if (method_exists($record, $relationshipName)) {
$recordTenant = $record->getRelationValue($relationshipName);
if (! $recordTenant instanceof ManagedEnvironment) {
$recordTenant = $record->{$relationshipName}()->first();
}
if ($recordTenant instanceof ManagedEnvironment) {
return $recordTenant;
}
}
}
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
$resolvedTenant = static::resolveTenantContextForCurrentPanel();
if ($resolvedTenant instanceof ManagedEnvironment) {
return $resolvedTenant;
}
}
if (method_exists(static::class, 'panelTenantContext')) {
$resolvedTenant = static::panelTenantContext();
if ($resolvedTenant instanceof ManagedEnvironment) {
return $resolvedTenant;
}
}
return null;
}
/**
* @param array<string, mixed> $parameters
*/
protected static function resolveWorkspaceScopedWorkspace(ManagedEnvironment $tenant, array $parameters): Workspace|string|int|null
{
$workspace = $parameters['workspace'] ?? null;
if ($workspace instanceof Workspace || is_string($workspace) || is_int($workspace)) {
return $workspace;
}
$tenantWorkspace = $tenant->workspace;
if ($tenantWorkspace instanceof Workspace) {
return $tenantWorkspace;
}
return $tenant->workspace()->first();
}
protected static function workspaceScopedTenantRelationshipName(): string
{
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
? static::$tenantOwnershipRelationshipName
: null;
return is_string($relationshipName) && $relationshipName !== ''
? $relationshipName
: 'tenant';
}
}

View File

@ -447,7 +447,6 @@ public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?st
return BaselineCompareLanding::getUrl(
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
panel: 'tenant',
tenant: $tenant,
);
}

View File

@ -126,7 +126,7 @@ public function selectTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
public function tenantLifecyclePresentation(ManagedEnvironment $tenant): TenantLifecyclePresentation

View File

@ -565,7 +565,7 @@ private function findingDetailUrl(Finding $record): string
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}

View File

@ -694,7 +694,7 @@ private function findingDetailUrl(Finding $record): string
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}

View File

@ -280,7 +280,7 @@ public function emptyState(): array
'action_name' => 'open_tenant_findings_empty',
'action_label' => 'Open tenant findings',
'action_kind' => 'url',
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant),
'action_url' => FindingResource::getUrl('index', tenant: $activeTenant),
];
}
@ -636,7 +636,7 @@ private function findingDetailUrl(Finding $record): string
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
$url = FindingResource::getUrl('view', ['record' => $record], tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}

View File

@ -681,7 +681,7 @@ public function decisionUrl(FindingException $record): ?string
}
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant),
FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $tenant),
$this->navigationContext()->toQuery(),
);
}

View File

@ -523,7 +523,7 @@ public function basisRunSummary(): array
'badgeColor' => null,
'runUrl' => null,
'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
@ -539,7 +539,7 @@ public function basisRunSummary(): array
'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}

View File

@ -443,7 +443,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'tenant')
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
];
}

View File

@ -243,7 +243,7 @@ protected function getHeaderActions(): array
return null;
}
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
return FindingExceptionResource::getUrl('index', tenant: $tenant);
});
$selectedContextActions = [
@ -490,7 +490,7 @@ public function selectedExceptionUrl(): ?string
}
return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
FindingExceptionResource::getUrl('view', ['record' => $record], tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
}
@ -504,7 +504,7 @@ public function selectedFindingUrl(): ?string
}
return $this->appendQuery(
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
}

View File

@ -204,7 +204,7 @@ protected function getHeaderActions(): array
->label('Back to '.$activeTenant->name)
->icon('heroicon-o-arrow-left')
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
}
if ($activeTenant instanceof ManagedEnvironment) {
@ -218,7 +218,7 @@ protected function getHeaderActions(): array
$this->removeTableFilter('managed_environment_id');
$this->redirect('/admin/operations');
$this->redirect(OperationRunLinks::index(allTenants: true));
});
}
@ -432,6 +432,7 @@ private function shouldForceWorkspaceWideTenantScope(): bool
private function operationsUrl(array $overrides = []): string
{
$parameters = array_merge(
['workspace' => app(WorkspaceContext::class)->currentWorkspace(request())],
$this->navigationContext()?->toQuery() ?? [],
[
'tenant_scope' => $this->shouldForceWorkspaceWideTenantScope() ? 'all' : null,

View File

@ -126,7 +126,7 @@ protected function getHeaderActions(): array
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
->url(\App\Filament\Pages\TenantDashboard::getUrl(tenant: $activeTenant));
} else {
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')

View File

@ -404,7 +404,7 @@ private function latestReviewUrl(ManagedEnvironment $tenant): ?string
static fn (mixed $value): bool => $value !== null && $value !== '',
);
return $this->appendQuery(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'), $query);
return $this->appendQuery(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant), $query);
}
private function latestPublishedAt(ManagedEnvironment $tenant): ?\Illuminate\Support\Carbon

View File

@ -112,7 +112,7 @@ public function table(Table $table): Table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant))
->columns([
TextColumn::make('tenant.name')->label('ManagedEnvironment')->searchable(),
TextColumn::make('status')

View File

@ -11,6 +11,7 @@
use App\Models\SupportRequest;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
@ -90,7 +91,27 @@ public function getSubheading(): string | Htmlable | null
*/
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
$resolvedTenant = $tenant instanceof ManagedEnvironment
? $tenant
: (($parameters['tenant'] ?? $parameters['environment'] ?? null) instanceof ManagedEnvironment
? ($parameters['tenant'] ?? $parameters['environment'])
: null);
if (! $resolvedTenant instanceof ManagedEnvironment) {
return url('/admin');
}
$workspace = $parameters['workspace'] ?? null;
if (! $workspace instanceof Workspace) {
$workspace = $resolvedTenant->workspace()->first();
}
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$resolvedTenant->getRouteKey());
}
/**

View File

@ -38,7 +38,7 @@ class TenantRequiredPermissions extends Page implements HasTable
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'tenants/{tenant}/required-permissions';
protected static ?string $slug = 'workspaces/{workspace}/environments/{tenant}/required-permissions';
protected static ?string $title = 'Required permissions';

View File

@ -5142,7 +5142,7 @@ public function completeOnboarding(): void
resourceId: (string) $tenant->getKey(),
);
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
private function verificationRun(): ?OperationRun

View File

@ -81,7 +81,7 @@ public function getTenants(): Collection
public function goToChooseTenant(): void
{
$this->redirect(ChooseTenant::getUrl());
$this->redirect(route('admin.workspace.managed-tenants.index', ['workspace' => $this->workspace]));
}
public function openTenant(int $tenantId): void
@ -106,6 +106,8 @@ public function openTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
$this->redirect(
\App\Filament\Pages\TenantDashboard::getUrl(tenant: $tenant)
);
}
}

View File

@ -5,6 +5,7 @@
use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
use App\Jobs\RunBackupScheduleJob;
@ -68,6 +69,7 @@ class BackupScheduleResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = BackupSchedule::class;
@ -79,10 +81,6 @@ class BackupScheduleResource extends Resource
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}

View File

@ -4,6 +4,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Jobs\BulkBackupSetDeleteJob;
@ -62,6 +63,7 @@ class BackupSetResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = BackupSet::class;
@ -73,10 +75,6 @@ class BackupSetResource extends Resource
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}

View File

@ -6,6 +6,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource;
@ -64,6 +65,7 @@ class EvidenceSnapshotResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = EvidenceSnapshot::class;
@ -780,7 +782,7 @@ private static function stringifySummaryValue(mixed $value): string
*/
public static function executeGeneration(array $data): void
{
$tenant = Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {

View File

@ -6,6 +6,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
@ -53,6 +54,7 @@ class FindingExceptionResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = FindingException::class;
@ -70,10 +72,6 @@ class FindingExceptionResource extends Resource
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
@ -270,7 +268,7 @@ public static function relatedContextEntries(FindingException $record): array
label: 'Finding',
value: static::findingSummary($record),
secondaryValue: 'Return to the linked finding detail.',
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], tenant: $record->tenant),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Open finding',

View File

@ -4,6 +4,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\FindingResource\Pages;
use App\Filament\Support\NormalizedDiffSurface;
use App\Models\Finding;
@ -66,6 +67,7 @@ class FindingResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = Finding::class;
@ -77,10 +79,6 @@ class FindingResource extends Resource
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
@ -2082,7 +2080,7 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
}
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
return FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant);
}
/**

View File

@ -5,6 +5,7 @@
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem;
use App\Models\ManagedEnvironment;
@ -23,8 +24,10 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Closure;
use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry;
use Filament\Panel;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
@ -33,12 +36,14 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Route;
use UnitEnum;
class InventoryItemResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = InventoryItem::class;
@ -54,11 +59,39 @@ class InventoryItemResource extends Resource
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
return parent::shouldRegisterNavigation();
}
public static function getRouteBaseName(?Panel $panel = null): string
{
$panel ??= Filament::getCurrentOrDefaultPanel();
if ($panel->getId() !== 'admin') {
return parent::getRouteBaseName($panel);
}
return parent::shouldRegisterNavigation();
return $panel->generateRouteName(
(string) str(static::getSlug($panel))
->replace('/', '.')
->prepend('resources.'),
);
}
public static function registerRoutes(Panel $panel, ?Closure $registerPageRoutes = null): void
{
if ($panel->getId() !== 'admin') {
parent::registerRoutes($panel, $registerPageRoutes);
return;
}
$registerPageRoutes ??= function () use ($panel): void {
foreach (static::getPages() as $name => $page) {
$page->registerRoute($panel)?->name($name);
}
};
Route::name('resources.')->group(fn () => static::routes($panel, $registerPageRoutes));
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration

View File

@ -1618,7 +1618,7 @@ public static function restoreContinuation(OperationRun $record): ?array
'follow_up_required' => $attention->followUpRequired,
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
'link_url' => $canOpenRestore
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant)
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant)
: null,
'link_available' => $canOpenRestore,
];

View File

@ -5,6 +5,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Support\NormalizedSettingsSurface;
@ -61,6 +62,7 @@ class PolicyResource extends Resource
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = Policy::class;
@ -84,10 +86,6 @@ public static function getPluralModelLabel(): string
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}

View File

@ -5,6 +5,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Filament\Support\NormalizedDiffSurface;
use App\Filament\Support\NormalizedSettingsSurface;
@ -69,6 +70,7 @@ class PolicyVersionResource extends Resource
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = PolicyVersion::class;
@ -82,10 +84,6 @@ class PolicyVersionResource extends Resource
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}

View File

@ -6,6 +6,7 @@
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Resources\RestoreRunResource\Pages;
use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob;
@ -88,6 +89,7 @@ class RestoreRunResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = RestoreRun::class;
@ -95,10 +97,6 @@ class RestoreRunResource extends Resource
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
@ -50,6 +51,7 @@
class ReviewPackResource extends Resource
{
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static ?string $model = ReviewPack::class;
@ -67,7 +69,7 @@ class ReviewPackResource extends Resource
public static function canViewAny(): bool
{
$tenant = ManagedEnvironment::current();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
@ -83,7 +85,7 @@ public static function canViewAny(): bool
public static function canView(Model $record): bool
{
$tenant = ManagedEnvironment::current();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
@ -413,7 +415,7 @@ public static function reviewPackGenerationFormSchema(): array
public static function getEloquentQuery(): Builder
{
$tenant = ManagedEnvironment::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
@ -501,7 +503,7 @@ private static function compressedOutcome(ReviewPack $record, bool $fresh = fals
*/
public static function executeGeneration(array $data): void
{
$tenant = Filament::getTenant();
$tenant = static::currentTenantContext();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
@ -574,7 +576,7 @@ public static function executeGeneration(array $data): void
*/
public static function reviewPackGenerationDecision(?ManagedEnvironment $tenant = null): array
{
$tenant ??= ManagedEnvironment::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
$tenant ??= static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return [];
@ -585,7 +587,7 @@ public static function reviewPackGenerationDecision(?ManagedEnvironment $tenant
public static function currentTenantContext(): ?ManagedEnvironment
{
$tenant = ManagedEnvironment::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
return $tenant instanceof ManagedEnvironment ? $tenant : null;
}

View File

@ -6,6 +6,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\StoredReportResource\Pages;
use App\Models\StoredReport;
@ -43,6 +44,7 @@ class StoredReportResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
/**
* @var array<string, string>
@ -568,7 +570,7 @@ public static function currentReportUrlFor(StoredReport $report): ?string
return null;
}
return static::getUrl('view', ['record' => $current], panel: 'tenant', tenant: $tenant);
return static::getUrl('view', ['record' => $current], tenant: $tenant);
}
public static function scopeCurrentRecords(Builder $query): Builder
@ -663,6 +665,6 @@ private static function tenantOverviewUrl(): string
return '#';
}
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
return TenantDashboard::getUrl(tenant: $tenant);
}
}

View File

@ -1344,14 +1344,13 @@ public static function tenantDashboardOpenUrl(ManagedEnvironment $record, array
$arrivalState = static::portfolioArrivalStateForTenant($record, $triageState);
if ($arrivalState === null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $record);
return TenantDashboard::getUrl(tenant: $record);
}
return TenantDashboard::getUrl(
parameters: [
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState),
],
panel: 'tenant',
tenant: $record,
);
}

View File

@ -6,6 +6,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedTenantRoutes;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
@ -48,6 +49,7 @@
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Panel;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
@ -63,6 +65,7 @@ class TenantReviewResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use WorkspaceScopedTenantRoutes;
protected static bool $isDiscovered = false;
@ -84,7 +87,12 @@ class TenantReviewResource extends Resource
public static function shouldRegisterNavigation(): bool
{
return Filament::getCurrentPanel()?->getId() === 'tenant';
return parent::shouldRegisterNavigation();
}
public static function getSlug(?Panel $panel = null): string
{
return static::workspaceScopedSlug(parent::getSlug($panel), $panel);
}
public static function getNavigationGroup(): string
@ -401,7 +409,7 @@ public static function makeCreateReviewAction(
*/
public static function executeCreateReview(array $data): void
{
$tenant = Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
@ -476,7 +484,7 @@ public static function executeCreateReview(array $data): void
*/
public static function reviewPackGenerationDecision(?ManagedEnvironment $tenant = null): array
{
$tenant ??= Filament::getTenant();
$tenant ??= static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return [];
@ -605,7 +613,7 @@ public static function tenantScopedUrl(
?ManagedEnvironment $tenant = null,
?string $panel = null,
): string {
$panelId = $panel ?? 'tenant';
$panelId = 'admin';
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
}
@ -615,7 +623,7 @@ public static function tenantScopedUrl(
*/
private static function evidenceSnapshotOptions(): array
{
$tenant = Filament::getTenant();
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return [];

View File

@ -53,7 +53,7 @@ protected function getViewData(): array
return $empty;
}
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
$tenantLandingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$operationsFollowUpCount = (int) OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->dashboardNeedsFollowUp()
@ -179,7 +179,7 @@ private function findingsUrl(ManagedEnvironment $tenant, TenantGovernanceAggrega
default => [],
};
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
return FindingResource::getUrl('index', $parameters, tenant: $tenant);
}
private function canOpenFindings(ManagedEnvironment $tenant): bool

View File

@ -164,7 +164,7 @@ protected function getViewData(): array
'badge' => 'Baseline',
'badgeColor' => $compareAssessment->tone,
'actionLabel' => 'Open Baseline Compare',
'actionUrl' => BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
'actionUrl' => BaselineCompareLanding::getUrl(tenant: $tenant),
];
}
@ -248,7 +248,7 @@ protected function getViewData(): array
private function findingsAction(ManagedEnvironment $tenant, string $label, array $parameters): array
{
$url = $this->canOpenFindings($tenant)
? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant)
? FindingResource::getUrl('index', $parameters, tenant: $tenant)
: null;
return [
@ -435,7 +435,7 @@ private function backupHealthActionPayload(ManagedEnvironment $tenant, ?BackupHe
'actionLabel' => $label ?? $target->label,
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
],
@ -443,7 +443,7 @@ private function backupHealthActionPayload(ManagedEnvironment $tenant, ?BackupHe
'actionLabel' => $label ?? $target->label,
'actionUrl' => BackupScheduleResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
],
@ -467,7 +467,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant,
'actionLabel' => 'Open backup sets',
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => 'The latest backup detail is no longer available.',
];
@ -481,7 +481,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant,
'actionUrl' => BackupSetResource::getUrl('view', [
'record' => $target->recordId,
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
];
@ -490,7 +490,7 @@ private function backupHealthBackupSetActionPayload(ManagedEnvironment $tenant,
'actionLabel' => 'Open backup sets',
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => 'The latest backup detail is no longer available.',
];
@ -564,7 +564,7 @@ private function recoveryActionPayload(ManagedEnvironment $tenant, array $recove
'actionUrl' => RestoreRunResource::getUrl('view', [
'record' => (int) $latestRun->getKey(),
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
];
@ -591,6 +591,6 @@ private function restoreRunListUrl(ManagedEnvironment $tenant, string $reason):
{
return RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant);
], tenant: $tenant);
}
}

View File

@ -123,13 +123,13 @@ private function resolveBackupHealthAction(ManagedEnvironment $tenant, ?BackupHe
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
'actionUrl' => BackupScheduleResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target),
@ -146,7 +146,7 @@ private function resolveBackupSetAction(ManagedEnvironment $tenant, BackupHealth
return [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => 'The latest backup detail is no longer available.',
];
}
@ -158,14 +158,14 @@ private function resolveBackupSetAction(ManagedEnvironment $tenant, BackupHealth
'actionUrl' => BackupSetResource::getUrl('view', [
'record' => $target->recordId,
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
];
} catch (ModelNotFoundException) {
return [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => 'The latest backup detail is no longer available.',
];
}
@ -200,7 +200,7 @@ private function resolveRecoveryAction(ManagedEnvironment $tenant, array $recove
'actionUrl' => RestoreRunResource::getUrl('view', [
'record' => (int) $latestRun->getKey(),
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'helperText' => null,
];
} catch (ModelNotFoundException) {
@ -233,6 +233,6 @@ private function restoreRunListUrl(ManagedEnvironment $tenant, string $reason):
{
return RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $reason,
], panel: 'tenant', tenant: $tenant);
], tenant: $tenant);
}
}

View File

@ -159,7 +159,7 @@ protected function getViewData(): array
'canManage' => $canManage,
'canView' => $canView,
'viewReportUrl' => $canView
? StoredReportResource::getUrl('view', ['record' => $report], panel: 'tenant', tenant: $tenant)
? StoredReportResource::getUrl('view', ['record' => $report], tenant: $tenant)
: null,
];
}

View File

@ -36,10 +36,10 @@ protected function getViewData(): array
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
: null;
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$nextActionUrl = match ($aggregate->nextActionTarget) {
'run' => $runUrl,
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', tenant: $tenant),
'landing' => $landingUrl,
default => null,
};

View File

@ -4,6 +4,7 @@
namespace App\Http\Controllers;
use App\Support\OperationRunLinks;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -26,7 +27,7 @@ public function __invoke(Request $request): RedirectResponse
$previousPath = (string) (parse_url((string) $previousUrl, PHP_URL_PATH) ?? '');
if ($previousHost !== null && $previousHost !== $request->getHost()) {
return redirect()->route('admin.operations.index');
return redirect()->to(OperationRunLinks::index());
}
if ($this->isTenantScopedEvidencePath($previousPath)) {
@ -44,7 +45,7 @@ public function __invoke(Request $request): RedirectResponse
}
if ($previousPath === '' || $previousPath === '/admin/clear-tenant-context') {
return redirect()->route('admin.operations.index');
return redirect()->to(OperationRunLinks::index());
}
return redirect()->to((string) $previousUrl);

View File

@ -67,7 +67,7 @@ public function __invoke(Request $request): RedirectResponse
abort(404);
}
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
return redirect()->to(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, ManagedEnvironment $tenant): void

View File

@ -50,11 +50,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
// ManagedEnvironment-scoped routes are handled separately.
if (str_starts_with($path, '/admin/t/')) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
@ -194,16 +189,12 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
$refererPath = '/'.ltrim((string) $refererPath, '/');
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
return true;
}
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $refererPath) === 1) {
return true;
}
}
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
return false;
}
private function isLivewireUpdatePath(string $path): bool

View File

@ -3,6 +3,7 @@
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\CrossTenantComparePage;
@ -28,6 +29,7 @@
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\User;
use App\Models\Workspace;
@ -36,6 +38,7 @@
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\Filament\PanelThemeAsset;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
@ -139,7 +142,7 @@ public function panel(Panel $panel): Panel
->exists();
}),
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => route('admin.operations.index'))
->url(fn (): string => OperationRunLinks::index())
->icon('heroicon-o-queue-list')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10),
@ -176,10 +179,12 @@ public function panel(Panel $panel): Panel
WorkspaceResource::class,
BaselineProfileResource::class,
BaselineSnapshotResource::class,
TenantReviewResource::class,
])
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->pages([
BaselineCompareLanding::class,
InventoryCoverage::class,
TenantRequiredPermissions::class,
WorkspaceSettings::class,

View File

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

View File

@ -766,7 +766,7 @@ private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNav
+ ($finding->reopened_at !== null ? 0 : 1),
'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(),
'destination_url' => $this->appendQuery(
FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant),
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
@ -908,7 +908,7 @@ private function reviewEntry(
: null,
]));
$destinationUrl = $latestPublishedReview !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant')
? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant)
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
return [

View File

@ -4,6 +4,7 @@
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\Workspace;
use App\Services\Providers\AdminConsentUrlFactory;
final class RequiredPermissionsLinks
@ -15,7 +16,17 @@ final class RequiredPermissionsLinks
*/
public static function requiredPermissions(ManagedEnvironment $tenant, array $filters = []): string
{
$base = sprintf('/admin/tenants/%s/required-permissions', urlencode((string) $tenant->external_id));
$workspace = $tenant->workspace()->first();
if (! $workspace instanceof Workspace) {
return url('/admin');
}
$base = url(sprintf(
'/admin/workspaces/%s/environments/%s/required-permissions',
urlencode((string) ($workspace->slug ?? $workspace->getKey())),
urlencode((string) $tenant->getRouteKey()),
));
if ($filters === []) {
return $base;

View File

@ -32,13 +32,11 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
$path = '/'.ltrim($request->path(), '/');
if ($tenant->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) {
if ($tenant->isRemovedFromWorkspace()) {
abort(404);
}
if ($tenant->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) {
if ($tenant->workspace?->isClosed()) {
abort(404);
}

View File

@ -11,6 +11,7 @@
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
@ -76,19 +77,13 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if ($path === '/admin/operations') {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if ($path === '/admin/operations/'.$request->route('run')) {
if (preg_match('#^/admin/workspaces/[^/]+/operations(?:/[^/]+)?$#', $path) === 1) {
$this->configureNavigationForRequest($panel);
return $next($request);
@ -102,6 +97,12 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
$workspace = $workspaceContext->currentWorkspace($request);
if ($workspace !== null) {
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]);
}
return redirect()->route('filament.admin.pages.choose-tenant');
}
@ -109,19 +110,19 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->isRemovedFromWorkspace() && str_starts_with($path, '/admin/t/')) {
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->isRemovedFromWorkspace() && str_starts_with($path, '/admin/workspaces/')) {
abort(404);
}
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->workspace?->isClosed() && str_starts_with($path, '/admin/t/')) {
if ($resolvedContext->hasTenant() && $resolvedContext->tenant?->workspace?->isClosed() && str_starts_with($path, '/admin/workspaces/')) {
abort(404);
}
if (
$resolvedContext->hasTenant()
&& (
$panel?->getId() === 'tenant'
|| (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound)
! $this->isWorkspaceScopedPageWithTenant($path)
&& $resolvedContext->pageCategory === TenantPageCategory::TenantBound
)
) {
Filament::setTenant($resolvedContext->tenant, true);
@ -130,9 +131,7 @@ public function handle(Request $request, Closure $next): Response
}
if (
str_starts_with($path, '/admin/w/')
|| str_starts_with($path, '/admin/workspaces')
|| str_starts_with($path, '/admin/operations')
str_starts_with($path, '/admin/workspaces/')
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)
) {
$this->configureNavigationForRequest($panel);
@ -195,7 +194,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
)
->item(
NavigationItem::make('Operations')
->url(fn (): string => route('admin.operations.index'))
->url(fn (): string => OperationRunLinks::index())
->icon('heroicon-o-queue-list')
->group('Monitoring')
->sort(10),
@ -243,7 +242,7 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
private function isWorkspaceScopedPageWithTenant(string $path): bool
{
return preg_match('#^/admin/tenants/[^/]+/required-permissions$#', $path) === 1;
return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/required-permissions$#', $path) === 1;
}
private function isLivewireUpdatePath(string $path): bool

View File

@ -30,6 +30,7 @@
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
@ -249,7 +250,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->exists()
? ['label' => 'Open backup set', 'url' => BackupSetResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open backup set', 'url' => BackupSetResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)]
: null,
'restore_run' => $tenant instanceof ManagedEnvironment
&& $this->capabilityResolver->isMember($user, $tenant)
@ -258,7 +259,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->exists()
? ['label' => 'Open restore run', 'url' => RestoreRunResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open restore run', 'url' => RestoreRunResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)]
: null,
'finding' => $tenant instanceof ManagedEnvironment
&& $this->capabilityResolver->isMember($user, $tenant)
@ -267,7 +268,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->exists()
? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open finding', 'url' => FindingResource::getUrl('view', ['record' => $resourceId], tenant: $tenant)]
: null,
'finding_exception' => $tenant instanceof ManagedEnvironment
&& $this->capabilityResolver->isMember($user, $tenant)
@ -276,7 +277,7 @@ public function auditTargetLink(AuditLog $record): ?array
->whereKey($resourceId)
->where('managed_environment_id', (int) $tenant->getKey())
->first()) instanceof FindingException
? ['label' => 'Open finding exception', 'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], panel: 'tenant', tenant: $tenant)]
? ['label' => 'Open finding exception', 'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], tenant: $tenant)]
: null,
default => null,
};
@ -1095,7 +1096,7 @@ private function contextForOperationRun(OperationRun $run): CanonicalNavigationC
private function activeTenantId(): ?int
{
$tenant = Filament::getTenant();
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null;
}

View File

@ -48,7 +48,7 @@ public function returnAffordance(?Request $request = null): ?array
if ($activeTenant instanceof ManagedEnvironment) {
return [
'label' => 'Back to '.$activeTenant->name,
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
'url' => TenantDashboard::getUrl(tenant: $activeTenant),
];
}

View File

@ -19,7 +19,9 @@
use App\Models\ReviewPack;
use App\Models\ManagedEnvironment;
use App\Models\TenantReview;
use App\Models\Workspace;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
final class OperationRunLinks
{
@ -83,8 +85,16 @@ public static function index(
?string $problemClass = null,
?string $operationType = null,
): string {
$workspace = self::resolveWorkspace($tenant);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
$parameters = $context?->toQuery() ?? [];
$parameters['workspace'] = $workspace;
if ($tenant instanceof ManagedEnvironment) {
$parameters['managed_environment_id'] = (int) $tenant->getKey();
} elseif ($allTenants) {
@ -118,8 +128,14 @@ public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigatio
{
$runId = $run instanceof OperationRun ? (int) $run->getKey() : (int) $run;
$workspace = self::resolveWorkspace($run);
if (! $workspace instanceof Workspace) {
return url('/admin');
}
return route('admin.operations.view', array_merge(
['run' => $runId],
['workspace' => $workspace, 'run' => $runId],
$context?->toQuery() ?? [],
));
}
@ -153,15 +169,15 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
}
if ($canonicalType === 'inventory.sync') {
$links['Inventory'] = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Inventory'] = InventoryItemResource::getUrl('index', tenant: $tenant);
}
if ($canonicalType === 'policy.sync') {
$links['Policies'] = PolicyResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
$policyId = $context['policy_id'] ?? null;
if (is_numeric($policyId)) {
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], panel: 'tenant', tenant: $tenant);
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
}
}
@ -170,7 +186,7 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
}
if ($canonicalType === 'baseline.compare') {
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
$links['Drift'] = BaselineCompareLanding::getUrl(tenant: $tenant);
}
if ($canonicalType === 'baseline.capture') {
@ -182,24 +198,24 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
}
if ($canonicalType === 'backup_set.update') {
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
$backupSetId = $context['backup_set_id'] ?? null;
if (is_numeric($backupSetId)) {
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], panel: 'tenant', tenant: $tenant);
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
}
}
if (in_array($canonicalType, ['backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge'], true)) {
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
}
if ($canonicalType === 'restore.execute') {
$links['Restore Runs'] = RestoreRunResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
$restoreRunId = $context['restore_run_id'] ?? null;
if (is_numeric($restoreRunId)) {
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], panel: 'tenant', tenant: $tenant);
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
}
}
@ -238,4 +254,28 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
}
private static function resolveWorkspace(ManagedEnvironment|OperationRun|int|null $subject = null): ?Workspace
{
if ($subject instanceof ManagedEnvironment) {
return $subject->workspace()->first();
}
if ($subject instanceof OperationRun) {
return Workspace::query()->whereKey((int) $subject->workspace_id)->first();
}
if (is_int($subject) && $subject > 0) {
$run = OperationRun::query()
->select(['id', 'workspace_id'])
->whereKey($subject)
->first();
if ($run instanceof OperationRun) {
return Workspace::query()->whereKey((int) $run->workspace_id)->first();
}
}
return app(WorkspaceContext::class)->currentWorkspace(request());
}
}

View File

@ -102,7 +102,6 @@ public static function findingDatabaseNotificationMessage(Finding $finding, Mana
actionUrl: FindingResource::getUrl(
'view',
['record' => $finding],
panel: 'tenant',
tenant: $tenant,
),
actionTarget: 'finding_detail',

View File

@ -326,7 +326,7 @@ private function backupNextStepTarget(ManagedEnvironment $tenant, string $concer
'label' => $label,
'url' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $reason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => null,
];
@ -403,7 +403,7 @@ private function recoveryNextStepTarget(
'url' => RestoreRunResource::getUrl('view', [
'record' => $latestRunId,
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => null,
];
@ -414,7 +414,7 @@ private function recoveryNextStepTarget(
'label' => 'Open restore history',
'url' => RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => 'The latest restore detail is no longer available.',
];
@ -425,7 +425,7 @@ private function recoveryNextStepTarget(
'label' => 'Open restore history',
'url' => RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
], tenant: $tenant),
'disabled' => false,
'helperText' => null,
];

View File

@ -58,7 +58,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
secondaryLabel: 'Backup set #'.$backupSet->getKey(),
linkTarget: new ReferenceLinkTarget(
targetKind: ReferenceClass::BackupSet->value,
url: BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'tenant', tenant: $backupSet->tenant),
url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant),
actionLabel: 'View backup set',
contextBadge: 'ManagedEnvironment',
),

View File

@ -657,7 +657,7 @@ private function findingsSection(Collection $findings, ?ManagedEnvironment $tena
label: sprintf('%s finding #%d', ucfirst(str_replace('_', ' ', (string) $finding->severity)), (int) $finding->getKey()),
actionLabel: 'Open finding',
url: $tenant instanceof ManagedEnvironment
? FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)
? FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)
: null,
freshnessAt: $finding->last_seen_at,
))
@ -776,7 +776,7 @@ private function reviewPackSection(?ReviewPack $pack, ?ManagedEnvironment $tenan
label: 'Review pack #'.$pack->getKey(),
actionLabel: 'Open review pack',
url: $tenant instanceof ManagedEnvironment
? ReviewPackResource::getUrl('view', ['record' => $pack], panel: 'tenant', tenant: $tenant)
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant)
: null,
freshnessAt: $pack->generated_at,
),
@ -905,7 +905,7 @@ private function tenantReference(ManagedEnvironment $tenant): array
'record_id' => (string) $tenant->getKey(),
'label' => $tenant->name,
'action_label' => 'Open tenant',
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
'url' => TenantDashboard::getUrl(tenant: $tenant),
'availability' => 'available',
'freshness_note' => null,
'access_reason' => null,

View File

@ -1135,7 +1135,7 @@ private function tenantFindingsAction(ManagedEnvironment $tenant, ?User $user, s
return $this->actionPayload(
label: $label,
url: $canOpen ? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant) : null,
url: $canOpen ? FindingResource::getUrl('index', $parameters, tenant: $tenant) : null,
helperText: $canOpen ? null : $this->overviewText('helper_findings_requires_permissions'),
);
}
@ -1149,7 +1149,7 @@ private function riskExceptionsAction(ManagedEnvironment $tenant, ?User $user, s
return $this->actionPayload(
label: $label,
url: $canOpen ? FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant) : null,
url: $canOpen ? FindingExceptionResource::getUrl('index', tenant: $tenant) : null,
helperText: $canOpen ? null : $this->overviewText('helper_risk_exceptions_requires_permissions'),
);
}
@ -1201,8 +1201,8 @@ private function evidenceAction(ManagedEnvironment $tenant, ?User $user, string
if ($canOpen) {
$url = $snapshot instanceof EvidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'tenant', tenant: $tenant)
: EvidenceSnapshotResource::getUrl('index', panel: 'tenant', tenant: $tenant);
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant)
: EvidenceSnapshotResource::getUrl('index', tenant: $tenant);
}
return $this->actionPayload(
@ -1233,7 +1233,7 @@ private function customerWorkspaceAction(ManagedEnvironment $tenant, ?User $user
if ($canOpenWorkspace) {
$url = $reviewPack instanceof ReviewPack && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)
? ReviewPackResource::getUrl('view', ['record' => $reviewPack], panel: 'tenant', tenant: $tenant)
? ReviewPackResource::getUrl('view', ['record' => $reviewPack], tenant: $tenant)
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
}
@ -1445,7 +1445,7 @@ private function baselineCompareAction(ManagedEnvironment $tenant, ?User $user,
return $this->actionPayload(
label: $label,
url: $canOpen ? BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant) : null,
url: $canOpen ? BaselineCompareLanding::getUrl(tenant: $tenant) : null,
helperText: $canOpen ? null : $this->overviewText('helper_baseline_compare_requires_permissions'),
);
}
@ -1473,10 +1473,10 @@ private function backupHealthAction(ManagedEnvironment $tenant, ?User $user, str
$url = match ($target->surface) {
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $target->recordId !== null
? BackupSetResource::getUrl('view', ['record' => $target->recordId], panel: 'tenant', tenant: $tenant)
: BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant),
? BackupSetResource::getUrl('view', ['record' => $target->recordId], tenant: $tenant)
: BackupSetResource::getUrl('index', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => BackupSetResource::getUrl('index', tenant: $tenant),
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => BackupScheduleResource::getUrl('index', tenant: $tenant),
default => null,
};

View File

@ -32,7 +32,7 @@ public static function fromPath(string $path): self
return self::WorkspaceChooserException;
}
if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) {
if (preg_match('#^/admin/workspaces/[^/]+/operations/[^/]+$#', $normalizedPath) === 1) {
return self::CanonicalWorkspaceRecordViewer;
}
@ -47,10 +47,7 @@ public static function fromPath(string $path): self
return self::OnboardingWorkflow;
}
if (
preg_match('#^/admin/t/[^/]+(?:/|$)#', $normalizedPath) === 1
|| preg_match('#^/admin/tenants/[^/]+(?:/|$)#', $normalizedPath) === 1
) {
if (preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+(?:/|$)#', $normalizedPath) === 1) {
return self::TenantBound;
}

View File

@ -142,7 +142,7 @@ private function isExternalUrl(string $url): bool
private function isInternalDiagnosticPath(string $path): bool
{
return (bool) preg_match(
'/^\/admin\/(?:tenants\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/',
'/^\/admin\/(?:workspaces\/[^\/]+\/environments\/[^\/]+\/required-permissions|tenants\/[^\/]+\/provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?|provider-connections(?:\/create|\/[^\/]+(?:\/edit)?)?)$/',
$path,
);
}

View File

@ -1575,7 +1575,7 @@ private function tenantDashboardTarget(
return $this->destination(
kind: 'tenant_dashboard',
url: $this->appendArrivalToken(
TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
TenantDashboard::getUrl(tenant: $tenant),
$arrivalState,
),
label: $label,
@ -1640,7 +1640,7 @@ private function findingsTarget(ManagedEnvironment $tenant, User $user, array $f
if ($this->canOpenFindings($user, $tenant)) {
return $this->destination(
kind: 'tenant_findings',
url: FindingResource::getUrl('index', $filters, panel: 'tenant', tenant: $tenant),
url: FindingResource::getUrl('index', $filters, tenant: $tenant),
label: $label,
tenant: $tenant,
filters: $filters,
@ -1674,7 +1674,7 @@ private function baselineCompareTarget(ManagedEnvironment $tenant, User $user, s
return $this->destination(
kind: 'baseline_compare_landing',
url: BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
url: BaselineCompareLanding::getUrl(tenant: $tenant),
label: $label,
tenant: $tenant,
);

View File

@ -4,7 +4,6 @@
namespace App\Support\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard;
use App\Models\ManagedEnvironment;
@ -45,20 +44,18 @@ public function resolve(Workspace $workspace, User $user, ?string $intendedUrl =
$tenantCount = (int) $selectableTenants->count();
if ($tenantCount === 0) {
return route('admin.workspace.managed-tenants.index', [
'workspace' => $workspace->slug ?? $workspace->getKey(),
]);
return $this->environmentChooserUrl($workspace);
}
if ($tenantCount === 1) {
$tenant = $selectableTenants->first();
if ($tenant !== null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
return TenantDashboard::getUrl(tenant: $tenant);
}
}
return ChooseTenant::getUrl();
return $this->environmentChooserUrl($workspace);
}
/**
@ -108,7 +105,7 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace
$query->where('slug', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
$query->orWhere((new ManagedEnvironment)->getQualifiedKeyName(), (int) $identifier);
}
})
->first();
@ -117,4 +114,9 @@ private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
&& $user->canAccessTenant($tenant);
}
private function environmentChooserUrl(Workspace $workspace): string
{
return url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments');
}
}

View File

@ -4,6 +4,5 @@
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\Filament\TenantPanelProvider::class,
App\Providers\Filament\SystemPanelProvider::class,
];

View File

@ -1,5 +1,6 @@
<?php
use App\Filament\Pages\TenantDashboard;
use App\Filament\Pages\WorkspaceOverview;
use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
@ -52,7 +53,15 @@
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->get('/admin', WorkspaceOverview::class)
->get('/admin', function (Request $request) {
$workspace = app(WorkspaceContext::class)->currentWorkspace($request);
if ($workspace instanceof Workspace) {
return redirect()->route('admin.workspace.home', ['workspace' => $workspace]);
}
return redirect()->route('filament.admin.pages.choose-workspace');
})
->name('admin.home');
Route::get('/admin/rbac/start', [RbacDelegatedAuthController::class, 'start'])
@ -132,7 +141,7 @@
$resolveSmokeRedirect = static function (?string $redirect, ?ManagedEnvironment $tenant = null): string {
$fallback = $tenant instanceof ManagedEnvironment && ! $tenant->trashed()
? '/admin/t/'.$tenant->slug
? TenantDashboard::getUrl(tenant: $tenant)
: '/admin';
$redirect = trim((string) $redirect);
@ -378,9 +387,9 @@
};
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member'])
->prefix('/admin/w/{workspace}')
->prefix('/admin/workspaces/{workspace}')
->group(function (): void {
Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')]))
Route::get('/', WorkspaceOverview::class)
->name('admin.workspace.home');
Route::get('/ping', fn () => response()->noContent())->name('admin.workspace.ping');
@ -417,7 +426,7 @@
FilamentAuthenticate::class,
'ensure-workspace-selected',
])
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
->get('/admin/workspaces/{workspace}/operations', \App\Filament\Pages\Monitoring\Operations::class)
->name('admin.operations.index');
Route::middleware([
@ -490,9 +499,9 @@
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-workspace-member',
])
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
->get('/admin/workspaces/{workspace}/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
->name('admin.operations.view');
Route::middleware([
@ -504,9 +513,22 @@
FilamentAuthenticate::class,
'ensure-workspace-member',
])
->get('/admin/w/{workspace}/managed-tenants', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
->get('/admin/workspaces/{workspace}/environments', \App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
->name('admin.workspace.managed-tenants.index');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-member',
'ensure-filament-tenant-selected',
])
->get('/admin/workspaces/{workspace}/environments/{tenant:slug}', \App\Filament\Pages\TenantDashboard::class)
->name('admin.workspace.environments.show');
Route::middleware(['signed'])
->get('/admin/review-packs/{reviewPack}/download', ReviewPackDownloadController::class)
->name('admin.review-packs.download');

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
it('smokes the workspace-first admin flow from workspace selection to environment dashboard to operations hub', function (): void {
$workspace = Workspace::factory()->create([
'name' => 'Spec 280 Workspace',
]);
$otherWorkspace = Workspace::factory()->create([
'name' => 'Spec 280 Other Workspace',
]);
$tenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec 280 Production',
'slug' => 'spec-280-production',
]);
$secondaryTenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec 280 Secondary',
'slug' => 'spec-280-secondary',
]);
$otherWorkspaceTenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $otherWorkspace->getKey(),
'name' => 'Spec 280 Other Workspace ManagedEnvironment',
'slug' => 'spec-280-other-workspace',
]);
$user = User::factory()->create();
foreach ([$workspace, $otherWorkspace] as $memberWorkspace) {
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $memberWorkspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
}
foreach ([$tenant, $secondaryTenant, $otherWorkspaceTenant] as $memberTenant) {
$user->tenants()->syncWithoutDetaching([
(int) $memberTenant->getKey() => ['role' => 'owner'],
]);
}
ProviderConnection::factory()->platform()->consentGranted()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$this->actingAs($user);
$workspaceChooser = visit('/admin')
->waitForText('Spec 280 Workspace')
->assertSee('Spec 280 Other Workspace')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$environmentChooser = visit(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
->waitForText('Spec 280 Production')
->assertSee('Spec 280 Secondary')
->assertDontSee('Spec 280 Other Workspace ManagedEnvironment')
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$dashboard = $environmentChooser
->click('[wire\\:key="tenant-'.$tenant->getKey().'"]')
->waitForText('Spec 280 Production')
->waitForText('Show all operations')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$dashboard
->click('Show all operations')
->waitForText('Monitoring landing')
->assertSee('Open run detail')
->assertSee('Spec 280 Production')
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/operations')", true)
->assertScript("window.location.search.includes('managed_environment_id={$tenant->getKey()}')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\StoredReportResource;
use App\Models\Finding;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
it('smokes governance artifact admin routes for one managed environment', function (): void {
$workspace = Workspace::factory()->create([
'name' => 'Spec 282 Workspace',
]);
$tenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec 282 Production',
'slug' => 'spec-282-production',
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$user->tenants()->syncWithoutDetaching([
(int) $tenant->getKey() => ['role' => 'owner'],
]);
Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$report = StoredReport::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
->latest('id')
->firstOrFail();
ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$this->actingAs($user);
visit(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
->waitForText('Spec 282 Production')
->click('[wire\\:key="tenant-'.$tenant->getKey().'"]')
->waitForText('Spec 282 Production')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(FindingResource::getUrl('index', tenant: $tenant, panel: 'admin'))
->waitForText('Findings')
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments/{$tenant->getRouteKey()}/findings')", true)
->assertScript("!window.location.pathname.includes('/admin/t/')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(ReviewPackResource::getUrl('index', tenant: $tenant, panel: 'admin'))
->waitForText('Review Packs')
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments/{$tenant->getRouteKey()}/review-packs')", true)
->assertScript("!window.location.pathname.includes('/admin/t/')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin'))
->waitForText('Outcome summary')
->assertSee('Evidence snapshot #'.$snapshot->getKey())
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments/{$tenant->getRouteKey()}/evidence/{$snapshot->getRouteKey()}')", true)
->assertScript("!window.location.pathname.includes('/admin/t/')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(StoredReportResource::getUrl('index', tenant: $tenant, panel: 'admin'))
->waitForText('Stored reports')
->assertSee('Permission posture report')
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments/{$tenant->getRouteKey()}/stored-reports')", true)
->assertScript("!window.location.pathname.includes('/admin/t/')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Permission posture report')
->waitForText('Permission posture summary')
->assertSee('Stored report')
->assertSee('Raw payload')
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments/{$tenant->getRouteKey()}/stored-reports/{$report->getRouteKey()}')", true)
->assertScript("!window.location.pathname.includes('/admin/t/')", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\ManagedEnvironment;
use App\Models\StoredReport;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('registers governance resource navigation on the admin panel', function (): void {
Filament::setCurrentPanel('admin');
expect(FindingResource::shouldRegisterNavigation())->toBeTrue()
->and(FindingExceptionResource::shouldRegisterNavigation())->toBeTrue()
->and(InventoryItemResource::shouldRegisterNavigation())->toBeTrue()
->and(PolicyResource::shouldRegisterNavigation())->toBeTrue()
->and(PolicyVersionResource::shouldRegisterNavigation())->toBeTrue()
->and(BackupScheduleResource::shouldRegisterNavigation())->toBeTrue()
->and(BackupSetResource::shouldRegisterNavigation())->toBeTrue()
->and(RestoreRunResource::shouldRegisterNavigation())->toBeTrue()
->and(TenantReviewResource::shouldRegisterNavigation())->toBeTrue();
});
it('builds workspace-first admin urls for governance resources', function (): void {
Filament::setCurrentPanel('admin');
$workspace = Workspace::factory()->create([
'name' => 'Spec 282 Workspace',
'slug' => 'spec-282-workspace',
]);
$tenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec 282 Production',
'slug' => 'spec-282-production',
]);
$expectedPrefix = '/admin/workspaces/'.$workspace->getRouteKey().'/environments/'.$tenant->getRouteKey().'/';
$searchableResources = [];
foreach ([
InventoryItemResource::class,
PolicyResource::class,
PolicyVersionResource::class,
BackupScheduleResource::class,
BackupSetResource::class,
RestoreRunResource::class,
FindingResource::class,
FindingExceptionResource::class,
EvidenceSnapshotResource::class,
TenantReviewResource::class,
ReviewPackResource::class,
StoredReportResource::class,
] as $resourceClass) {
$path = parse_url($resourceClass::getUrl('index', panel: 'admin', tenant: $tenant), PHP_URL_PATH);
expect($path)
->toBeString()
->toStartWith($expectedPrefix)
->not->toContain('/admin/t/');
}
});
it('returns 404 for governance artifact routes when the workspace and environment pair is mismatched', function (): void {
Filament::setCurrentPanel('admin');
$workspace = Workspace::factory()->create([
'name' => 'Spec 282 Primary Workspace',
'slug' => 'spec-282-primary-workspace',
]);
$otherWorkspace = Workspace::factory()->create([
'name' => 'Spec 282 Secondary Workspace',
'slug' => 'spec-282-secondary-workspace',
]);
$tenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec 282 Production',
'slug' => 'spec-282-production',
]);
$user = User::factory()->create();
foreach ([$workspace, $otherWorkspace] as $memberWorkspace) {
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $memberWorkspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
}
$user->tenants()->syncWithoutDetaching([
(int) $tenant->getKey() => ['role' => 'owner'],
]);
$report = StoredReport::factory()
->permissionPosture()
->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $otherWorkspace->getKey(),
])
->get(FindingResource::getUrl('index', ['workspace' => $otherWorkspace], panel: 'admin', tenant: $tenant))
->assertNotFound();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $otherWorkspace->getKey(),
])
->get(StoredReportResource::getUrl('view', ['workspace' => $otherWorkspace, 'record' => $report], panel: 'admin', tenant: $tenant))
->assertNotFound();
});
it('keeps touched searchable governance resources on truthful destinations', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
$searchableResources = [];
foreach ([
InventoryItemResource::class,
PolicyResource::class,
PolicyVersionResource::class,
BackupScheduleResource::class,
BackupSetResource::class,
RestoreRunResource::class,
FindingResource::class,
FindingExceptionResource::class,
EvidenceSnapshotResource::class,
TenantReviewResource::class,
ReviewPackResource::class,
StoredReportResource::class,
] as $resourceClass) {
if (! $resourceClass::canGloballySearch()) {
continue;
}
$searchableResources[] = $resourceClass;
}
expect($searchableResources)->toBeArray();
foreach ($searchableResources as $resourceClass) {
expect($resourceClass::hasPage('view') || $resourceClass::hasPage('edit'))
->toBeTrue($resourceClass.' must keep a truthful global-search destination on the admin panel.');
}
});

View File

@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AuditLog;
use App\Models\BackupSet;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\ReviewPack;
use App\Models\User;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
function setGovernanceArtifactAdminContext(ManagedEnvironment $tenant): void
{
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
}
function governanceArtifactAuditRecord(ManagedEnvironment $tenant, string $resourceType, int|string $resourceId): AuditLog
{
return AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'actor_email' => 'governance@example.com',
'actor_name' => 'Governance Operator',
'action' => 'governance.updated',
'status' => 'success',
'resource_type' => $resourceType,
'resource_id' => (string) $resourceId,
'summary' => 'Governance resource updated',
'metadata' => [],
'recorded_at' => now(),
]);
}
it('keeps evidence snapshot truth drillthroughs on workspace-first operation routes', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$snapshot = seedTenantReviewEvidence($tenant);
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::EvidenceSnapshotGenerate->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$snapshot->forceFill([
'operation_run_id' => (int) $run->getKey(),
])->save();
$this->actingAs($user);
setGovernanceArtifactAdminContext($tenant);
$envelope = app(ArtifactTruthPresenter::class)->forEvidenceSnapshotFresh($snapshot->fresh());
expect($envelope->nextActionUrl)->toBe(OperationRunLinks::tenantlessView($run))
->and(parse_url((string) $envelope->nextActionUrl, PHP_URL_PATH))
->toBe('/admin/workspaces/'.$tenant->workspace->getRouteKey().'/operations/'.$run->getRouteKey())
->not->toContain('/admin/t/');
});
it('builds workspace-first related record links for governance operations', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$backupSetRun = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BackupSetUpdate->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$restoreRun = RestoreRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$restoreOperation = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::RestoreExecute->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'restore_run_id' => (int) $restoreRun->getKey(),
],
]);
$snapshot = seedTenantReviewEvidence($tenant);
$snapshotRun = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::EvidenceSnapshotGenerate->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$snapshot->forceFill([
'operation_run_id' => (int) $snapshotRun->getKey(),
])->save();
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$reviewRun = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::TenantReviewCompose->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$review->forceFill([
'operation_run_id' => (int) $reviewRun->getKey(),
])->save();
$packRun = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::ReviewPackGenerate->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $packRun->getKey(),
'expires_at' => now()->addDay(),
]);
$this->actingAs($user);
setGovernanceArtifactAdminContext($tenant);
$backupLinks = OperationRunLinks::related($backupSetRun, $tenant);
$restoreLinks = OperationRunLinks::related($restoreOperation, $tenant);
$snapshotLinks = OperationRunLinks::related($snapshotRun, $tenant);
$reviewLinks = OperationRunLinks::related($reviewRun, $tenant);
$packLinks = OperationRunLinks::related($packRun, $tenant);
expect($backupLinks)->toMatchArray([
OperationRunLinks::collectionLabel() => OperationRunLinks::index($tenant),
'Backup Sets' => BackupSetResource::getUrl('index', tenant: $tenant),
'Backup Set' => BackupSetResource::getUrl('view', ['record' => (int) $backupSet->getKey()], tenant: $tenant),
]);
expect($restoreLinks)->toMatchArray([
OperationRunLinks::collectionLabel() => OperationRunLinks::index($tenant),
'Restore Runs' => RestoreRunResource::getUrl('index', tenant: $tenant),
'Restore Run' => RestoreRunResource::getUrl('view', ['record' => (int) $restoreRun->getKey()], tenant: $tenant),
]);
expect($snapshotLinks)->toMatchArray([
OperationRunLinks::collectionLabel() => OperationRunLinks::index($tenant),
'Evidence Snapshot' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant),
]);
expect($reviewLinks)->toMatchArray([
OperationRunLinks::collectionLabel() => OperationRunLinks::index($tenant),
'ManagedEnvironment Review' => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant),
]);
expect($packLinks)->toMatchArray([
OperationRunLinks::collectionLabel() => OperationRunLinks::index($tenant),
'Review Pack' => ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant),
]);
foreach ([$backupLinks, $restoreLinks, $snapshotLinks, $reviewLinks, $packLinks] as $links) {
foreach ($links as $url) {
expect(parse_url($url, PHP_URL_PATH))
->toBeString()
->not->toContain('/admin/t/');
}
}
});
it('builds workspace-first audit target links for governance artifact records', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$restoreRun = RestoreRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$finding = Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$findingException = FindingException::query()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Temporary exception request',
'requested_at' => now(),
'review_due_at' => now()->addWeek(),
'evidence_summary' => ['reference_count' => 0],
]);
$decision = $findingException->decisions()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => 'Temporary exception request',
'metadata' => [],
'decided_at' => now(),
]);
$findingException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$this->actingAs($user);
setGovernanceArtifactAdminContext($tenant);
$resolver = app(RelatedNavigationResolver::class);
$cases = [
[
'audit' => governanceArtifactAuditRecord($tenant, 'backup_set', (int) $backupSet->getKey()),
'label' => 'Open backup set',
'url' => BackupSetResource::getUrl('view', ['record' => (int) $backupSet->getKey()], tenant: $tenant),
],
[
'audit' => governanceArtifactAuditRecord($tenant, 'restore_run', (int) $restoreRun->getKey()),
'label' => 'Open restore run',
'url' => RestoreRunResource::getUrl('view', ['record' => (int) $restoreRun->getKey()], tenant: $tenant),
],
[
'audit' => governanceArtifactAuditRecord($tenant, 'finding', (int) $finding->getKey()),
'label' => 'Open finding',
'url' => FindingResource::getUrl('view', ['record' => (int) $finding->getKey()], tenant: $tenant),
],
[
'audit' => governanceArtifactAuditRecord($tenant, 'finding_exception', (int) $findingException->getKey()),
'label' => 'Open finding exception',
'url' => FindingExceptionResource::getUrl('view', ['record' => $findingException], tenant: $tenant),
],
];
foreach ($cases as $case) {
$link = $resolver->auditTargetLink($case['audit']);
expect($link)->toMatchArray([
'label' => $case['label'],
'url' => $case['url'],
]);
expect(parse_url($link['url'], PHP_URL_PATH))
->toBeString()
->not->toContain('/admin/t/');
}
});

View File

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\TenantReview;
use App\Support\Workspaces\WorkspaceContext;
it('resolves review pack access from the remembered admin environment context', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$otherTenant = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner', setUiContext: false);
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$otherPack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $otherTenant->getKey(),
'workspace_id' => (int) $otherTenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
expect(ReviewPackResource::canViewAny())->toBeTrue()
->and(ReviewPackResource::canView($pack))->toBeTrue()
->and(ReviewPackResource::canView($otherPack))->toBeFalse();
});
it('starts review pack generation from the remembered admin environment context', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
seedTenantReviewEvidence($tenant);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
ReviewPackResource::executeGeneration([
'include_pii' => true,
'include_operations' => true,
]);
$pack = ReviewPack::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->latest('id')
->first();
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and($pack?->status)->toBeString();
});
it('starts tenant review creation from the remembered admin environment context', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$snapshot = seedTenantReviewEvidence($tenant);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
TenantReviewResource::executeCreateReview([
'evidence_snapshot_id' => (string) $snapshot->getKey(),
]);
$review = TenantReview::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->latest('id')
->first();
expect($review)->toBeInstanceOf(TenantReview::class)
->and((int) $review?->evidence_snapshot_id)->toBe((int) $snapshot->getKey());
});
it('starts evidence snapshot generation from the remembered admin environment context', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
EvidenceSnapshotResource::executeGeneration([
'allow_stale' => false,
]);
$snapshot = EvidenceSnapshot::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('workspace_id', (int) $tenant->workspace_id)
->latest('id')
->first();
expect($snapshot)->toBeInstanceOf(EvidenceSnapshot::class)
->and((int) $snapshot?->managed_environment_id)->toBe((int) $tenant->getKey());
});
it('resolves stored report access from the remembered admin environment context', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$otherTenant = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner', setUiContext: false);
$report = StoredReport::factory()
->permissionPosture()
->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$otherReport = StoredReport::factory()
->permissionPosture()
->create([
'managed_environment_id' => (int) $otherTenant->getKey(),
'workspace_id' => (int) $otherTenant->workspace_id,
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
expect(StoredReportResource::canViewAny())->toBeTrue()
->and(StoredReportResource::canView($report))->toBeTrue()
->and(StoredReportResource::canView($otherReport))->toBeFalse();
});

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantReviewResource;
use App\Models\ManagedEnvironment;
use App\Models\Workspace;
use Tests\Support\OpsUx\SourceFileScanner;
/**
* @return list<string>
*/
function governanceArtifactLegacyTenantGuardedFiles(): array
{
$root = SourceFileScanner::projectRoot();
return [
$root.'/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php',
$root.'/app/Filament/Pages/Reviews/ReviewRegister.php',
$root.'/app/Filament/Resources/BackupScheduleResource.php',
$root.'/app/Filament/Resources/BackupSetResource.php',
$root.'/app/Filament/Resources/EvidenceSnapshotResource.php',
$root.'/app/Filament/Resources/FindingExceptionResource.php',
$root.'/app/Filament/Resources/FindingResource.php',
$root.'/app/Filament/Resources/InventoryItemResource.php',
$root.'/app/Filament/Resources/PolicyResource.php',
$root.'/app/Filament/Resources/PolicyVersionResource.php',
$root.'/app/Filament/Resources/RestoreRunResource.php',
$root.'/app/Filament/Resources/ReviewPackResource.php',
$root.'/app/Filament/Resources/StoredReportResource.php',
$root.'/app/Filament/Resources/TenantReviewResource.php',
$root.'/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php',
$root.'/app/Support/Navigation/RelatedNavigationResolver.php',
$root.'/app/Support/OperationRunLinks.php',
$root.'/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php',
$root.'/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php',
];
}
/**
* @return list<array{pattern: string, reason: string}>
*/
function governanceArtifactLegacyTenantForbiddenPatterns(): array
{
return [
[
'pattern' => "/panel:\\s*'tenant'/",
'reason' => 'Touched governance artifact surfaces must not emit tenant-panel URLs directly.',
],
[
'pattern' => '/\\/admin\\/t\\//',
'reason' => 'Touched governance artifact surfaces must not hardcode legacy /admin/t route language.',
],
[
'pattern' => "/TenantReviewResource::tenantScopedUrl\\([^\\n]*,\\s*'tenant'\\)/",
'reason' => 'Touched review drillthrough call-sites must not carry a stale tenant-panel hint.',
],
[
'pattern' => '/\\bManagedEnvironment::current\\s*\\(/',
'reason' => 'Touched governance artifact surfaces must not rely on tenant-panel-only current-environment fallbacks.',
],
[
'pattern' => '/\\bFilament::getTenant\\s*\\(/',
'reason' => 'Touched governance artifact surfaces must resolve admin context through the shared panel resolver, not raw Filament tenant reads.',
],
[
'pattern' => "/getCurrentPanel\\(\\)\\?->getId\\(\\)\\s*===\\s*'admin'/",
'reason' => 'Touched governance artifact resources must not stay hidden behind admin-only registration guards.',
],
];
}
it('keeps touched governance artifact sources free of tenant-panel route language and fallback guards', function (): void {
$violations = [];
foreach (governanceArtifactLegacyTenantGuardedFiles() as $path) {
$source = SourceFileScanner::read($path);
$lines = preg_split('/\R/', $source) ?: [];
foreach ($lines as $index => $line) {
foreach (governanceArtifactLegacyTenantForbiddenPatterns() as $pattern) {
if (preg_match($pattern['pattern'], $line) !== 1) {
continue;
}
$violations[] = [
'file' => SourceFileScanner::relativePath($path),
'line' => $index + 1,
'snippet' => SourceFileScanner::snippet($source, $index + 1),
'reason' => $pattern['reason'],
];
}
}
}
expect($violations)->toBeEmpty();
})->group('surface-guard');
it('keeps tenant review scoped urls on workspace-first admin routes even when a legacy tenant hint is supplied', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', setUiContext: false);
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->firstOrFail();
setAdminPanelContext();
$path = parse_url(
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
PHP_URL_PATH,
);
expect($path)
->toBe('/admin/workspaces/'.$workspace->getRouteKey().'/environments/'.$tenant->getRouteKey().'/reviews/'.$review->getRouteKey())
->not->toContain('/admin/t/');
})->group('surface-guard');

View File

@ -63,8 +63,6 @@
}
});
it('keeps managed environment as the panel tenant model', function (): void {
$panel = Filament\Facades\Filament::getPanel('tenant');
expect($panel->getTenantModel())->toBe(ManagedEnvironment::class);
it('does not keep a registered tenant panel after the workspace-first cutover', function (): void {
expect(Filament\Facades\Filament::getPanel('tenant'))->toBeNull();
});

View File

@ -42,7 +42,7 @@
expect($tenants->pluck('id')->all())->toBe([(int) $environment->getKey()]);
});
it('persists managed-environment context and redirects into the temporary tenant shell', function (): void {
it('persists managed-environment context and redirects into the workspace-first environment shell', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$this->actingAs($user)->withSession([
@ -52,20 +52,20 @@
Livewire::actingAs($user)
->test(ChooseTenant::class)
->call('selectTenant', (int) $environment->getKey())
->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $environment));
->assertRedirect(TenantDashboard::getUrl(tenant: $environment));
expect(session(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY))->toBe([
(string) $environment->workspace_id => (int) $environment->getKey(),
]);
});
it('keeps route builders on managed-environment slug for the temporary shell', function (): void {
it('keeps route builders on managed-environment slug for the workspace-first shell', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id,
]);
expect(TenantDashboard::getUrl(panel: 'tenant', tenant: $environment))
->toContain('/admin/t/'.$environment->slug);
expect(TenantDashboard::getUrl(tenant: $environment))
->toContain('/admin/workspaces/'.$environment->workspace->slug.'/environments/'.$environment->slug);
});

View File

@ -6,6 +6,7 @@
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -28,7 +29,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertSuccessful()
->assertSee('Policy sync');
});
@ -60,12 +61,12 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertSuccessful();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
->assertSuccessful();
});
@ -100,7 +101,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get('/admin/operations')
->get(route('admin.operations.index', ['workspace' => $tenantA->workspace]))
->assertSee('Policy sync')
->assertSee('Inventory sync');
});
@ -123,13 +124,13 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertSuccessful()
->assertSee('Policy sync');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
->assertSuccessful()
->assertSee(\App\Support\OperationRunLinks::identifier($run));
});
@ -140,7 +141,7 @@
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(route('admin.operations.view', ['workspace' => $run->workspace, 'run' => (int) $run->getKey()]))
->assertNotFound();
});
@ -177,7 +178,7 @@
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]));
$response->assertSuccessful()->assertSee('Provider connection preflight');

View File

@ -47,10 +47,10 @@
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful();
expect(session()->get(WorkspaceContext::SESSION_KEY))->toBeNull();
expect(session()->get(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
});
it('returns 404 for non-members when viewing an operation run without a selected workspace', function (): void {
@ -68,7 +68,7 @@
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertNotFound();
});
@ -93,7 +93,7 @@
]);
$response = $this->actingAs($user)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
->get(OperationRunLinks::tenantlessView((int) $run->getKey()));
$response->assertSuccessful();
@ -117,7 +117,7 @@
$updateResponse = $this->actingAs($user)
->withHeaders([
'X-Livewire' => 'true',
'referer' => route('admin.operations.view', ['run' => (int) $run->getKey()]),
'referer' => OperationRunLinks::tenantlessView((int) $run->getKey()),
])
->postJson($updateUri, [
'components' => [[
@ -170,7 +170,7 @@
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertForbidden();
});
@ -205,7 +205,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Blocked by prerequisite')
->assertSee('Blocked reason')
@ -254,7 +254,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Automatically reconciled')
->assertSee('Infrastructure ended the run')
@ -264,7 +264,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful();
$pageText = trim((string) preg_replace('/\s+/', ' ', strip_tags((string) $response->getContent())));
@ -310,7 +310,7 @@
(string) $workspace->getKey() => (int) $tenantB->getKey(),
],
])
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee(OperationRunLinks::identifier($run))
->assertSee('Back to Operations');
@ -337,7 +337,7 @@
(string) $selectedTenant->workspace_id => (int) $selectedTenant->getKey(),
],
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Workspace-level operation')
->assertSee('This canonical workspace view is not tied to the current tenant context');
@ -379,7 +379,7 @@
(string) $runTenant->workspace_id => (int) $rememberedTenant->getKey(),
],
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('All tenants')
->assertSee('Canonical workspace view')
@ -422,7 +422,7 @@
(string) $rememberedTenant->workspace_id => (int) $rememberedTenant->getKey(),
],
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Current tenant context differs from this operation')
->assertSee('Operation tenant: '.$runTenant->name.'.')
@ -466,7 +466,7 @@
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee($entraTenantId)
->assertSee('permission_denied')
@ -583,7 +583,7 @@
]);
$this->actingAs($user)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Monitoring detail')
->assertSee('Navigation lane')
@ -618,7 +618,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Monitoring detail')
->assertSee('Follow-up lane')
@ -667,7 +667,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Monitoring detail')
->assertSee('Open keeps secondary drilldowns grouped under one control: View baseline profile, View snapshot.');
@ -699,7 +699,7 @@
expect($expectedInterval)->not->toBeNull();
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee("wire:poll.{$expectedInterval}", escape: false);
})->with([
@ -735,7 +735,7 @@
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertDontSee("opsUxIsTabHidden', document.hidden", escape: false)
->assertDontSee('visibilitychange.window', escape: false)
@ -764,7 +764,7 @@
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertDontSee('wire:poll.1s', escape: false)
->assertDontSee('wire:poll.5s', escape: false)
@ -808,7 +808,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
->assertSee('Inventory sync coverage')
->assertSee('Execution outcome stays separate from the per-type results below.')

View File

@ -6,13 +6,14 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Workspaces\WorkspaceContext;
it('returns 200 for tenant-entitled readonly members on the canonical required permissions route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertOk();
});
@ -33,7 +34,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertNotFound();
});
@ -48,7 +49,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertNotFound();
});
@ -58,6 +59,9 @@
ManagedEnvironment::query()->whereKey((int) $tenant->getKey())->update(['is_current' => true]);
$this->actingAs($user)
->get('/admin/tenants/invalid-tenant-id/required-permissions')
->get(sprintf(
'/admin/workspaces/%s/environments/invalid-tenant-id/required-permissions',
$tenant->workspace->slug,
))
->assertNotFound();
});

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Support\Links\RequiredPermissionsLinks;
it('renders guidance, admin consent link, re-run verification, and copy actions on the required permissions page', function (): void {
$tenant = ManagedEnvironment::factory()->create([
@ -13,7 +14,7 @@
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertSuccessful()
->assertSee('Guidance')
->assertSee('Who can fix this?', false)

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Support\Links\RequiredPermissionsLinks;
use Illuminate\Support\Facades\Queue;
it('renders the canonical required permissions page without Graph, outbound HTTP, or queue dispatches', function (): void {
@ -13,7 +14,7 @@
assertNoOutboundHttp(function () use ($user, $tenant): void {
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertSuccessful();
});

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Support\Links\RequiredPermissionsLinks;
it('renders the no-data state with a canonical start verification link when no stored permission data exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -10,7 +11,7 @@
$expectedUrl = TenantResource::getUrl('view', ['record' => $tenant]);
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertSuccessful()
->assertSee('No data available')
->assertSee($expectedUrl, false)

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Support\Links\RequiredPermissionsLinks;
it('renders re-run verification and next-step links using canonical manage surfaces only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -10,7 +11,7 @@
$expectedUrl = TenantResource::getUrl('view', ['record' => $tenant]);
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertSuccessful()
->assertSee('Re-run verification')
->assertSee($expectedUrl, false)
@ -21,7 +22,7 @@
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertSuccessful()
->assertSeeInOrder(['Summary', 'Issues', 'Passed', 'Technical details'])
->assertSee('data-testid="technical-details"', false)

View File

@ -1,6 +1,7 @@
<?php
use App\Models\TenantPermission;
use App\Support\Links\RequiredPermissionsLinks;
it('renders required permissions overview with missing-first ordering and feature cards', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -26,7 +27,7 @@
]);
$this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all")
->get(RequiredPermissionsLinks::requiredPermissions($tenant, ['status' => 'all']))
->assertSuccessful()
->assertSee('Blocked', false)
->assertSeeInOrder([$missingKey, $grantedKey], false);

View File

@ -2,10 +2,12 @@
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Workspaces\WorkspaceContext;
/*
@ -24,7 +26,7 @@
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$response = $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
->get(RequiredPermissionsLinks::requiredPermissions($tenant));
$response->assertOk();
@ -37,7 +39,7 @@
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$response = $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
->get(RequiredPermissionsLinks::requiredPermissions($tenant));
$response->assertOk();
@ -50,7 +52,7 @@
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$response = $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
->get(RequiredPermissionsLinks::requiredPermissions($tenant));
$response->assertOk();
@ -75,7 +77,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertNotFound();
});
@ -97,7 +99,7 @@
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
])
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($tenant))
->assertNotFound();
});
@ -106,22 +108,23 @@
| T002 Regression: tenant-scoped pages still show tenant sidebar
|--------------------------------------------------------------------------
|
| Verifies that the middleware change does NOT affect tenant-scoped pages.
| Pages under /admin/t/{tenant}/ must continue to show tenant sidebar.
| Verifies that the middleware change does NOT affect environment-scoped pages.
| Workspace-first environment pages must continue to show tenant sidebar.
|
*/
it('still renders tenant sidebar on tenant-scoped pages (regression guard)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
// Use the tenant dashboard — a known tenant-scoped URL
// Use the managed-environment dashboard — a known environment-scoped URL
$response = $this->actingAs($user)
->get("/admin/t/{$tenant->external_id}");
->get(TenantDashboard::getUrl(tenant: $tenant));
$response->assertOk();
// ManagedEnvironment-scoped nav groups MUST be present on tenant pages (Inventory group)
$response->assertSee('Inventory', false);
// Environment-scoped affordances must still be present on tenant pages.
$response->assertSee('Switch tenant', false)
->assertSee('Clear tenant scope', false);
});
/*
@ -140,7 +143,7 @@
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$response = $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
->get(RequiredPermissionsLinks::requiredPermissions($tenant));
$response->assertOk();
@ -183,7 +186,7 @@
$currentTenant->makeCurrent();
$this->actingAs($user)
->get("/admin/tenants/{$routedTenant->external_id}/required-permissions")
->get(RequiredPermissionsLinks::requiredPermissions($routedTenant))
->assertOk()
->assertSee($routedTenant->getFilamentName(), false)
->assertSee('Required permissions', false);

View File

@ -2,7 +2,6 @@
use App\Providers\Filament\AdminPanelProvider;
use App\Providers\Filament\SystemPanelProvider;
use App\Providers\Filament\TenantPanelProvider;
it('keeps the platform health and admin login routes reachable', function () {
$this->get('/up')->assertSuccessful();
@ -14,6 +13,6 @@
expect($providers)
->toContain(AdminPanelProvider::class)
->toContain(TenantPanelProvider::class)
->not->toContain('App\\Providers\\Filament\\TenantPanelProvider')
->toContain(SystemPanelProvider::class);
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\ManagedEnvironment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -110,13 +111,13 @@
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
])
->from("/admin/tenants/{$tenant->external_id}")
->from(TenantDashboard::getUrl(tenant: $tenant))
->post(route('admin.clear-tenant-context'))
->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $tenant->workspace]));
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.index'))
])->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertSuccessful()
->assertSee('All tenants');
});

View File

@ -91,7 +91,7 @@
Livewire::actingAs($user)
->test(ChooseWorkspace::class)
->call('selectWorkspace', $workspace->getKey())
->assertRedirect('/admin/choose-tenant');
->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace->slug ?? $workspace->getKey()]));
});
it('prefers the stored intended url after selecting a workspace', function (): void {

View File

@ -7,6 +7,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Filament\Pages\TenantDashboard;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -137,7 +138,7 @@
$response->assertRedirect();
$location = $response->headers->get('Location');
expect($location)->toContain('/admin/t/');
expect($location)->toBe(TenantDashboard::getUrl(tenant: $tenant));
});
// --- T008: it_allows_request_when_session_workspace_is_valid ---

View File

@ -10,14 +10,14 @@
uses(RefreshDatabase::class);
it('shows the routed workspace and tenant truth on tenant-panel entry without relying on session workspace state', function (): void {
it('shows the routed workspace and tenant truth on workspace-first environment entry without relying on session workspace state', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create(['name' => 'ManagedEnvironment Panel Entry']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
session()->forget(WorkspaceContext::SESSION_KEY);
$this->actingAs($user)
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk()
->assertSee($tenant->workspace()->firstOrFail()->name)
->assertSee('ManagedEnvironment Panel Entry')
@ -36,7 +36,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
->get(route('admin.operations.index', ['workspace' => $workspaceTenant->workspace, 'tenant' => $foreignTenant->external_id]))
->assertOk()
->assertSee('No tenant selected')
->assertDontSee('ManagedEnvironment scope: Rejected Foreign ManagedEnvironment');

View File

@ -28,7 +28,7 @@
// 1. Load the page
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/w/'.$workspace->slug.'/managed-tenants');
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]));
$response->assertSuccessful();
@ -48,7 +48,7 @@
$snapshot = json_decode($snapshotJson, true);
expect($snapshot)->toBeArray();
expect($snapshot['memo']['path'] ?? null)->toBe('admin/w/test-ws/managed-tenants');
expect($snapshot['memo']['path'] ?? null)->toBe('admin/workspaces/'.$workspace->getKey().'/environments');
// 3. POST a Livewire update request
$updatePayload = [

View File

@ -66,7 +66,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceEmpty->getKey()])
->get('/admin/w/'.$workspaceEmpty->slug.'/managed-tenants')
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspaceEmpty]))
->assertSuccessful()
->assertDontSee('/admin/t/'.$tenantInOther->external_id, false);
});
@ -96,7 +96,7 @@
->assertSuccessful();
});
it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void {
it('uses the routed tenant workspace even when the current workspace session mismatches', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create(['slug' => 'ws-a']);
@ -127,5 +127,6 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceB->getKey()])
->get(TenantDashboard::getUrl(tenant: $tenantInA))
->assertNotFound();
->assertSuccessful()
->assertSessionHas(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
});

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Pages\Workspaces\ManagedTenantsLanding;
use App\Filament\Resources\TenantResource;
use App\Models\ManagedEnvironment;
@ -70,7 +71,7 @@
$component
->call('goToChooseTenant')
->assertRedirect(ChooseTenant::getUrl());
->assertRedirect(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]));
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
@ -78,7 +79,7 @@
Livewire::actingAs($user)
->test(ManagedTenantsLanding::class, ['workspace' => $workspace])
->call('openTenant', $tenant->getKey())
->assertRedirect(TenantResource::getUrl('view', ['record' => $tenant]));
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
});
it('rejects opening a tenant from the landing when the actor lacks tenant entitlement', function (): void {

View File

@ -28,7 +28,7 @@
$response->assertRedirect();
$location = $response->headers->get('Location');
expect($location)->toContain('managed-tenants');
expect($location)->toBe(url('/admin/workspaces/'.$workspace->slug.'/environments'));
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
});

View File

@ -6,6 +6,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -34,7 +35,7 @@
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/operations')
->get(route('admin.operations.index', ['workspace' => $workspace]))
->assertOk();
$response->assertSee('Switch workspace')
@ -54,4 +55,5 @@
expect($labels)->not->toContain('Workspaces');
expect($manageWorkspaces)->not->toBeNull();
expect($manageWorkspaces->getUrl())->toBe(route('filament.admin.resources.workspaces.index'));
expect(OperationRunLinks::index())->toBe(route('admin.operations.index', ['workspace' => $workspace]));
});

View File

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -30,9 +31,7 @@
$url = $this->resolver->resolve($workspace, $user);
$expectedRoute = route('admin.workspace.managed-tenants.index', [
'workspace' => $workspace->slug ?? $workspace->getKey(),
]);
$expectedRoute = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments');
expect($url)->toBe($expectedRoute);
});
@ -58,7 +57,7 @@
$url = $this->resolver->resolve($workspace, $user);
$expectedUrl = TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
$expectedUrl = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$tenant->getRouteKey());
expect($url)->toBe($expectedUrl);
});
@ -90,7 +89,7 @@
$url = $this->resolver->resolve($workspace, $user);
expect($url)->toBe(ChooseTenant::getUrl());
expect($url)->toBe(url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments'));
});
it('falls back to chooser page when workspace ID is invalid', function (): void {
@ -113,9 +112,7 @@
$url = $this->resolver->resolveFromId((int) $workspace->getKey(), $user);
$expectedRoute = route('admin.workspace.managed-tenants.index', [
'workspace' => $workspace->slug ?? $workspace->getKey(),
]);
$expectedRoute = url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments');
expect($url)->toBe($expectedRoute);
});
@ -139,7 +136,7 @@
$tenant->getKey() => ['role' => 'owner'],
]);
$intendedUrl = route('admin.operations.index', ['tenant' => $tenant->external_id]);
$intendedUrl = OperationRunLinks::index($tenant);
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
@ -169,9 +166,9 @@
'status' => 'active',
]);
$intendedUrl = route('admin.operations.index', ['tenant' => $foreignTenant->external_id]);
$intendedUrl = OperationRunLinks::index($foreignTenant);
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
expect($url)->toBe(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
expect($url)->toBe(url('/admin/workspaces/'.($workspace->slug ?? $workspace->getKey()).'/environments/'.$tenant->getRouteKey()));
});

View File

@ -59,7 +59,7 @@ ## Test Governance
## Notes
- Reviewed against `.specify/memory/constitution.md`, the Filament v5 documentation results captured for panel configuration, global search, and page/resource testing, `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Tenants/TenantPageCategory.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/routes/web.php`, and `apps/platform/bootstrap/providers.php` on 2026-05-07.
- No application implementation was performed while preparing this package.
- Prep began as an implementation-ready package only; the runtime cutover, focused feature repairs, and browser smoke were completed afterward on 2026-05-07.
## Review Outcome
@ -67,4 +67,14 @@ ## Review Outcome
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Reason**: The package closes the temporary `/admin/t` shell using the existing workspace and environment surfaces, converges on one workspace-first route language, and leaves the deferred provider/artifact/RBAC/copy/quality-gate work explicitly to Specs `281`-`287`.
- **Workflow result**: Ready for implementation planning or execution as the second reserved cutover slice.
- **Workflow result**: Ready for implementation planning or execution as the second reserved cutover slice.
## Implementation Close-out
- Livewire v4 compliance remains intact under Filament v5; no Livewire v3 API or second panel runtime was introduced.
- Provider registration stays in `apps/platform/bootstrap/providers.php`, and `TenantPanelProvider::class` is no longer registered there.
- The shipped runtime uses the surviving admin panel only: `/admin`, `/admin/workspaces/{workspace}`, `/admin/workspaces/{workspace}/environments/{tenant}`, `/admin/workspaces/{workspace}/operations`, and `/admin/workspaces/{workspace}/operations/{run}` are the canonical public operator routes.
- No compatibility routes, aliases, redirects, or dual-panel fallback shipped in application runtime. Focused grep across `apps/platform/app`, `apps/platform/routes`, and `apps/platform/bootstrap` found no remaining `/admin/t/`, `/admin/tenants/`, `/admin/w/`, or `panel: 'tenant'` runtime references.
- Validation passed on 2026-05-07 with `./vendor/bin/sail artisan test --compact tests/Feature/WorkspaceFoundation tests/Feature/Workspaces tests/Feature/ManagedEnvironment tests/Feature/RequiredPermissions tests/Feature/Operations tests/Feature/MonitoringOperationsTest.php` and `./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`.
- The prep-era proof list drifted from current repo filenames; final bounded validation used the current workspace, managed-environment, required-permissions, operations, and browser-smoke families instead of the missing placeholder file names.
- Specs `281` through `287` remain explicitly deferred.

View File

@ -7,7 +7,7 @@ # Tasks: Filament Workspace Tenancy & Environment Routing Cutover
**Input**: Design documents from `specs/280-workspace-tenancy-environment-routing/`
**Prerequisites**: `specs/280-workspace-tenancy-environment-routing/spec.md`, `specs/280-workspace-tenancy-environment-routing/plan.md`, `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md`, `specs/280-workspace-tenancy-environment-routing/research.md`, `specs/280-workspace-tenancy-environment-routing/data-model.md`, `specs/280-workspace-tenancy-environment-routing/quickstart.md`, `specs/280-workspace-tenancy-environment-routing/contracts/workspace-tenancy-environment-routing.logical.openapi.yaml`
**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php`, `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php`, `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php`, `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php`, `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php`, and `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`.
**Tests**: REQUIRED (Pest). The prep-era placeholder filenames drifted before implementation; final bounded proof used `apps/platform/tests/Feature/WorkspaceFoundation`, `apps/platform/tests/Feature/Workspaces`, `apps/platform/tests/Feature/ManagedEnvironment`, `apps/platform/tests/Feature/RequiredPermissions`, `apps/platform/tests/Feature/Operations`, `apps/platform/tests/Feature/MonitoringOperationsTest.php`, and `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`.
**Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Filament/Pages/Monitoring/Operations.php` for workspace-first operations route ownership.
**RBAC**: Workspace membership remains the first `404` boundary, managed-environment access remains the second `404` boundary, and in-scope capability denials stay `403`.
**Shared Pattern Reuse**: Reuse `WorkspaceOverviewBuilder`, `TenantDashboardSummaryBuilder`, `ManagedTenantsLanding`, `ChooseTenant`, `WorkspaceRedirectResolver`, `OperationRunLinks`, and `RelatedNavigationResolver`. Do not add compatibility routes, dual-panel fallbacks, or replacement dashboards.
@ -18,6 +18,8 @@ # Tasks: Filament Workspace Tenancy & Environment Routing Cutover
**Workflow Outcome**: `keep`
**Test-governance Outcome**: `keep`
**Implementation Status**: Completed on 2026-05-07. The prep package was executed afterward; the completed runtime, validation, and close-out are recorded below and in `checklists/requirements.md`.
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane.
@ -27,16 +29,22 @@ ## Test Governance Checklist
- [x] `standard-native-filament`, `global-context-shell`, and `monitoring-state-page` expectations stay explicit for touched surfaces.
- [x] Any attempt to absorb Specs `281` through `287` resolves as `split` or `reject-or-split`, not hidden scope.
## Implementation Close-out Note
- [x] The runtime cutover shipped on 2026-05-07 using the surviving admin panel only.
- [x] The prep-era test filenames below were satisfied by equivalent current coverage in the workspace, managed-environment, required-permissions, operations, and browser-smoke suites recorded in `checklists/requirements.md`.
- [x] Final validation used `./vendor/bin/sail artisan test --compact tests/Feature/WorkspaceFoundation tests/Feature/Workspaces tests/Feature/ManagedEnvironment tests/Feature/RequiredPermissions tests/Feature/Operations tests/Feature/MonitoringOperationsTest.php`, `./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php`, and `./vendor/bin/sail bin pint --dirty --format agent`.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded cutover inventory, the proving files, and the explicit no-compatibility posture before runtime edits begin.
- [ ] T001 Review `specs/280-workspace-tenancy-environment-routing/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` together so implementation stays on Spec 280 only.
- [ ] T002 [P] Confirm the current panel-provider and registration seams in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, and `apps/platform/bootstrap/providers.php` before changing operator tenancy.
- [ ] T003 [P] Confirm the current entry, chooser, and route-language seams in `apps/platform/routes/web.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`.
- [ ] T004 [P] Confirm the current context-classification seams in `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php`.
- [ ] T005 [P] Confirm the current dashboard and operations link owners in `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`.
- [ ] T006 [P] Confirm the touched global-search and deferred-scope surfaces in `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` so Specs `281` through `287` remain explicitly out of scope.
- [x] T001 Review `specs/280-workspace-tenancy-environment-routing/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/workspace-tenancy-environment-routing.logical.openapi.yaml` together so implementation stays on Spec 280 only.
- [x] T002 [P] Confirm the current panel-provider and registration seams in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, and `apps/platform/bootstrap/providers.php` before changing operator tenancy.
- [x] T003 [P] Confirm the current entry, chooser, and route-language seams in `apps/platform/routes/web.php`, `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`.
- [x] T004 [P] Confirm the current context-classification seams in `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php`.
- [x] T005 [P] Confirm the current dashboard and operations link owners in `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`.
- [x] T006 [P] Confirm the touched global-search and deferred-scope surfaces in `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` so Specs `281` through `287` remain explicitly out of scope.
---
@ -46,14 +54,14 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**Critical**: No user-story work should begin until this phase is complete.
- [ ] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` for `Workspace` as the only Filament tenant, `/admin` entry ownership, `TenantPanelProvider` retirement from public operator routing, and provider registration expectations in `apps/platform/bootstrap/providers.php`.
- [ ] T008 [P] Add failing coverage in `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` for `/admin/workspaces/{workspace}/environments`, `/admin/workspaces/{workspace}/environments/{environment}`, stale cross-workspace environment clearing, archived-environment exclusion, and wrong-workspace `404` behavior.
- [ ] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` for `/admin/workspaces/{workspace}/operations`, `managed_environment_id` filtering, `Show all environments` widening, workspace-safe run detail routes, and hostile filter `404` behavior.
- [ ] T010 [P] Add failing coverage in `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` for workspace-dashboard versus environment-dashboard signal ownership and `Workspace -> Managed Environment -> page` breadcrumb/context ordering.
- [ ] T011 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, `/admin/operations/{run}`, `panel: 'tenant'`, `TenantPanelProvider::class` registration in `apps/platform/bootstrap/providers.php`, compatibility redirects, aliases, dual-panel fallbacks, and the searchable-destination rule for touched resources.
- [ ] T012 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php` for workspace selection, workspace-scoped environment choice, managed-environment dashboard entry, and workspace-operations drillthrough on the surviving admin panel.
- [ ] T013 Establish the one-panel workspace-first route skeleton in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, and `apps/platform/routes/web.php` with no compatibility aliases, redirect shims, or dual-panel fallback.
- [ ] T014 Update `apps/platform/app/Filament/Pages/ChooseWorkspace.php` and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` so `/admin` resolves only to workspace selection or `/admin/workspaces/{workspace}` before story-specific environment routing work begins.
- [x] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` for `Workspace` as the only Filament tenant, `/admin` entry ownership, `TenantPanelProvider` retirement from public operator routing, and provider registration expectations in `apps/platform/bootstrap/providers.php`.
- [x] T008 [P] Add failing coverage in `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` for `/admin/workspaces/{workspace}/environments`, `/admin/workspaces/{workspace}/environments/{environment}`, stale cross-workspace environment clearing, archived-environment exclusion, and wrong-workspace `404` behavior.
- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` for `/admin/workspaces/{workspace}/operations`, `managed_environment_id` filtering, `Show all environments` widening, workspace-safe run detail routes, and hostile filter `404` behavior.
- [x] T010 [P] Add failing coverage in `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` for workspace-dashboard versus environment-dashboard signal ownership and `Workspace -> Managed Environment -> page` breadcrumb/context ordering.
- [x] T011 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, `/admin/operations`, `/admin/operations/{run}`, `panel: 'tenant'`, `TenantPanelProvider::class` registration in `apps/platform/bootstrap/providers.php`, compatibility redirects, aliases, dual-panel fallbacks, and the searchable-destination rule for touched resources.
- [x] T012 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php` for workspace selection, workspace-scoped environment choice, managed-environment dashboard entry, and workspace-operations drillthrough on the surviving admin panel.
- [x] T013 Establish the one-panel workspace-first route skeleton in `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, and `apps/platform/routes/web.php` with no compatibility aliases, redirect shims, or dual-panel fallback.
- [x] T014 Update `apps/platform/app/Filament/Pages/ChooseWorkspace.php` and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` so `/admin` resolves only to workspace selection or `/admin/workspaces/{workspace}` before story-specific environment routing work begins.
**Checkpoint**: The proving files exist, `/admin` entry ownership is workspace-first, and the implementation has a single admin-panel route skeleton to extend.
@ -67,14 +75,14 @@ ## Phase 3: User Story 1 - Enter an environment without leaving the workspace ad
### Tests for User Story 1
- [ ] T015 [P] [US1] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` after T013-T014 to prove the public chooser and environment entry stay on the `admin` panel and direct `/admin/t/{environment}` requests return `404`.
- [ ] T016 [P] [US1] Extend `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` after T013-T014 to prove chooser submission, managed-environment dashboard resolution, and wrong-workspace route binding remain `404`.
- [x] T015 [P] [US1] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` after T013-T014 to prove the public chooser and environment entry stay on the `admin` panel and direct `/admin/t/{environment}` requests return `404`.
- [x] T016 [P] [US1] Extend `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` after T013-T014 to prove chooser submission, managed-environment dashboard resolution, and wrong-workspace route binding remain `404`.
### Implementation for User Story 1
- [ ] T017 [US1] Rework `apps/platform/app/Filament/Pages/ChooseTenant.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` so `/admin/workspaces/{workspace}/environments` is the only public environment chooser and stale cross-workspace remembered environment context is cleared before resolution.
- [ ] T018 [US1] Move managed-environment dashboard and required-permissions route ownership in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and `apps/platform/routes/web.php` to `/admin/workspaces/{workspace}/environments/{environment}` with no `/admin/tenants/{environment}` compatibility reader.
- [ ] T019 [US1] Update workspace-to-environment URL generation in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, and any touched environment page classes under `apps/platform/app/Filament/Pages/` so no entry flow emits `panel: 'tenant'` or `/admin/t` destinations.
- [x] T017 [US1] Rework `apps/platform/app/Filament/Pages/ChooseTenant.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` so `/admin/workspaces/{workspace}/environments` is the only public environment chooser and stale cross-workspace remembered environment context is cleared before resolution.
- [x] T018 [US1] Move managed-environment dashboard and required-permissions route ownership in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and `apps/platform/routes/web.php` to `/admin/workspaces/{workspace}/environments/{environment}` with no `/admin/tenants/{environment}` compatibility reader.
- [x] T019 [US1] Update workspace-to-environment URL generation in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, and any touched environment page classes under `apps/platform/app/Filament/Pages/` so no entry flow emits `panel: 'tenant'` or `/admin/t` destinations.
**Checkpoint**: Workspace selection, environment chooser entry, and managed-environment dashboard routing all stay inside one workspace-first admin panel.
@ -88,13 +96,13 @@ ## Phase 4: User Story 2 - Move from environment work into workspace operations
### Tests for User Story 2
- [ ] T020 [P] [US2] Extend `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` to prove environment dashboards and touched environment pages open `/admin/workspaces/{workspace}/operations` with explicit `managed_environment_id`, preserve run-detail ownership under `/admin/workspaces/{workspace}/operations/{run}`, widen scope only through explicit user action, and keep `/admin/operations` plus `/admin/operations/{run}` unavailable.
- [x] T020 [P] [US2] Extend `apps/platform/tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php` to prove environment dashboards and touched environment pages open `/admin/workspaces/{workspace}/operations` with explicit `managed_environment_id`, preserve run-detail ownership under `/admin/workspaces/{workspace}/operations/{run}`, widen scope only through explicit user action, and keep `/admin/operations` plus `/admin/operations/{run}` unavailable.
### Implementation for User Story 2
- [ ] T021 [US2] Retarget `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` so operations collection/detail links emit only workspace-first routes with explicit environment filter and return-context data.
- [ ] T022 [US2] Update `apps/platform/app/Filament/Pages/Monitoring/Operations.php` so workspace collection/detail ownership, `managed_environment_id` hydration, `Show all environments` behavior, and hostile filter handling match the new workspace-first route contract.
- [ ] T023 [US2] Update operations entry actions in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and any touched environment-scoped page classes under `apps/platform/app/Filament/Pages/` so they delegate through the shared workspace operations builders instead of local tenant-panel URLs.
- [x] T021 [US2] Retarget `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` so operations collection/detail links emit only workspace-first routes with explicit environment filter and return-context data.
- [x] T022 [US2] Update `apps/platform/app/Filament/Pages/Monitoring/Operations.php` so workspace collection/detail ownership, `managed_environment_id` hydration, `Show all environments` behavior, and hostile filter handling match the new workspace-first route contract.
- [x] T023 [US2] Update operations entry actions in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, and any touched environment-scoped page classes under `apps/platform/app/Filament/Pages/` so they delegate through the shared workspace operations builders instead of local tenant-panel URLs.
**Checkpoint**: Operations links, run-detail links, and return context are all workspace-canonical while preserving explicit environment scope.
@ -108,13 +116,13 @@ ## Phase 5: User Story 3 - Read workspace-wide and environment-scoped signals on
### Tests for User Story 3
- [ ] T024 [P] [US3] Extend `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` to prove workspace-wide dashboard signals remain on `WorkspaceOverview`, environment-scoped signals remain on `TenantDashboard`, and breadcrumb/context ordering becomes `Workspace -> Managed Environment -> page`.
- [x] T024 [P] [US3] Extend `apps/platform/tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php` to prove workspace-wide dashboard signals remain on `WorkspaceOverview`, environment-scoped signals remain on `TenantDashboard`, and breadcrumb/context ordering becomes `Workspace -> Managed Environment -> page`.
### Implementation for User Story 3
- [ ] T025 [US3] Rebind `apps/platform/app/Filament/Pages/WorkspaceOverview.php` and `apps/platform/app/Filament/Pages/TenantDashboard.php` to the canonical `/admin/workspaces/{workspace}` and `/admin/workspaces/{workspace}/environments/{environment}` routes while preserving `WorkspaceOverviewBuilder` and `TenantDashboardSummaryBuilder` ownership.
- [ ] T026 [US3] Update `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php` so workspace-first environment routes are the only active environment-bound language and remembered cross-workspace environment context cannot leak.
- [ ] T027 [US3] Update context bars, breadcrumbs, and chooser/dashboard CTA links in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, and `apps/platform/app/Filament/Pages/TenantDashboard.php` so the new route ownership reads `Workspace -> Managed Environment -> domain page` everywhere this slice touches.
- [x] T025 [US3] Rebind `apps/platform/app/Filament/Pages/WorkspaceOverview.php` and `apps/platform/app/Filament/Pages/TenantDashboard.php` to the canonical `/admin/workspaces/{workspace}` and `/admin/workspaces/{workspace}/environments/{environment}` routes while preserving `WorkspaceOverviewBuilder` and `TenantDashboardSummaryBuilder` ownership.
- [x] T026 [US3] Update `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, and `apps/platform/app/Support/Tenants/TenantPageCategory.php` so workspace-first environment routes are the only active environment-bound language and remembered cross-workspace environment context cannot leak.
- [x] T027 [US3] Update context bars, breadcrumbs, and chooser/dashboard CTA links in `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, and `apps/platform/app/Filament/Pages/TenantDashboard.php` so the new route ownership reads `Workspace -> Managed Environment -> domain page` everywhere this slice touches.
**Checkpoint**: Workspace dashboard, managed-environment dashboard, and current-context shells all present the correct scope and breadcrumb truth.
@ -128,13 +136,13 @@ ## Phase 6: User Story 4 - Keep search and authorization truthful after the rout
### Tests for User Story 4
- [ ] T028 [P] [US4] Extend `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` to prove no compatibility routes, aliases, redirects, or dual-panel fallbacks survive for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, or `/admin/operations` plus `/admin/operations/{run}`.
- [ ] T029 [P] [US4] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` and `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` to cover `WorkspaceResource` and `TenantResource` global-search destinations plus `404` versus `403` behavior for direct workspace/environment URLs.
- [x] T028 [P] [US4] Extend `apps/platform/tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php` to prove no compatibility routes, aliases, redirects, or dual-panel fallbacks survive for `/admin/t`, `/admin/tenants/{environment}/required-permissions`, `/admin/w/{workspace}/managed-tenants`, or `/admin/operations` plus `/admin/operations/{run}`.
- [x] T029 [P] [US4] Extend `apps/platform/tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php` and `apps/platform/tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php` to cover `WorkspaceResource` and `TenantResource` global-search destinations plus `404` versus `403` behavior for direct workspace/environment URLs.
### Implementation for User Story 4
- [ ] T030 [US4] Update `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php` and `apps/platform/app/Filament/Resources/TenantResource.php` so each touched resource keeps a valid view/edit destination under workspace-first routing or disables global search in the same slice.
- [ ] T031 [US4] Remove remaining legacy-route ownership and panel-language fallbacks from `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/routes/web.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, and any touched helpers under `apps/platform/tests/` so Specs `281` through `287` remain deferred instead of absorbed.
- [x] T030 [US4] Update `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php` and `apps/platform/app/Filament/Resources/TenantResource.php` so each touched resource keeps a valid view/edit destination under workspace-first routing or disables global search in the same slice.
- [x] T031 [US4] Remove remaining legacy-route ownership and panel-language fallbacks from `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/routes/web.php`, `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, and any touched helpers under `apps/platform/tests/` so Specs `281` through `287` remain deferred instead of absorbed.
**Checkpoint**: Search, direct URLs, and no-legacy route guards all reflect the final workspace-first contract with no hidden fallback path.
@ -144,13 +152,13 @@ ## Phase 7: Polish & Cross-Cutting Validation
**Purpose**: Run the exact bounded proof set, perform the final Filament review, and close the cutover without reopening deferred specs.
- [ ] T032 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Workspace/WorkspaceFilamentTenancyCutoverTest.php tests/Feature/ManagedEnvironment/WorkspaceFirstEnvironmentRoutingTest.php tests/Feature/Monitoring/WorkspaceOperationsEnvironmentContextTest.php tests/Feature/Navigation/WorkspaceEnvironmentBreadcrumbsTest.php tests/Feature/Guards/LegacyAdminTenantRouteRemovalGuardTest.php)`.
- [ ] T033 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php)`.
- [ ] T034 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`.
- [ ] T035 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and confirm only intentional removal-guard output remains.
- [ ] T036 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php"` and confirm only intentional removal-guard output remains.
- [ ] T037 [P] Review `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and touched Filament pages/actions to confirm Filament v5 / Livewire v4 compliance, provider registration stays in `apps/platform/bootstrap/providers.php`, the global-search destination rule is satisfied, touched destructive actions still preserve `->requiresConfirmation()` plus authorization, and no asset strategy or deploy-step change was introduced.
- [ ] T038 [P] Record the implementation close-out in `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` or the active PR notes confirming no compatibility routes, aliases, redirects, or dual-panel fallback shipped and Specs `281` through `287` remain explicitly deferred.
- [x] T032 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/WorkspaceFoundation tests/Feature/Workspaces tests/Feature/ManagedEnvironment tests/Feature/RequiredPermissions tests/Feature/Operations tests/Feature/MonitoringOperationsTest.php)`.
- [x] T033 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec280WorkspaceTenancyEnvironmentRoutingSmokeTest.php)`.
- [x] T034 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`.
- [x] T035 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/t/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/tenants/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/w/' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"`, and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings '/admin/operations' "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and confirm only intentional removal-guard output remains.
- [x] T036 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && rg -n --fixed-strings "panel: 'tenant'" "$REPO_ROOT/apps/platform/app" "$REPO_ROOT/apps/platform/tests" "$REPO_ROOT/apps/platform/routes" "$REPO_ROOT/apps/platform/bootstrap"` and `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && rg -n --fixed-strings 'TenantPanelProvider::class' "$REPO_ROOT/apps/platform/bootstrap/providers.php"` and confirm only intentional removal-guard output remains.
- [x] T037 [P] Review `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/bootstrap/providers.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and touched Filament pages/actions to confirm Filament v5 / Livewire v4 compliance, provider registration stays in `apps/platform/bootstrap/providers.php`, the global-search destination rule is satisfied, touched destructive actions still preserve `->requiresConfirmation()` plus authorization, and no asset strategy or deploy-step change was introduced.
- [x] T038 [P] Record the implementation close-out in `specs/280-workspace-tenancy-environment-routing/checklists/requirements.md` or the active PR notes confirming no compatibility routes, aliases, redirects, or dual-panel fallback shipped and Specs `281` through `287` remain explicitly deferred.
---

View File

@ -0,0 +1,72 @@
# Specification Quality Checklist: Governance Artifact Retargeting to ManagedEnvironment
**Purpose**: Validate package completeness, boundedness, and readiness before implementation
**Created**: 2026-05-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The package stays on reserved slot `282` and does not silently absorb Specs `267`, `277`, or `283` through `287`.
- [x] The stale candidate implication of further schema retarget work is explicitly corrected to current repo truth.
- [x] The package stays focused on governance artifact surface ownership, route truth, and context resolution rather than reading like a lifecycle or naming rewrite.
- [x] No new schema, lifecycle contract, provider registry, taxonomy, or RBAC redesign is pulled into scope.
- [x] `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, and the logical contract describe the same bounded slice.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain in `spec.md`, `plan.md`, `research.md`, `data-model.md`, or `quickstart.md`.
- [x] Requirements remain testable and bounded to current resource registration, route ownership, environment-context resolution, and drillthrough links.
- [x] Scope boundaries, assumptions, risks, and deferred adjacent candidates remain explicit.
- [x] The candidate deviation, smallest viable slice, and acceptance scenarios are explicit.
## Repo Truth Anchoring
- [x] The package reflects that the touched governance artifact models already persist `managed_environment_id` plus `workspace_id`.
- [x] The package reflects that several touched resources still hide from the admin panel or still depend on tenant-panel route or context assumptions.
- [x] The package reflects that read-only artifact surfaces still need admin-panel environment-context hardening.
- [x] The package reflects that `OperationRunLinks` already owns the shared operations link contract reused by this slice.
- [x] The package reflects that `ResolvesPanelTenantContext` is the central seam for admin-panel environment resolution.
## Feature Readiness
- [x] Filament v5 and Livewire v4 expectations remain explicit across the package.
- [x] Provider registration location remains explicit as `apps/platform/bootstrap/providers.php`.
- [x] Global-search posture for touched resources remains explicit.
- [x] Destructive action confirmation and authorization expectations remain explicit for touched artifact mutations.
- [x] The unchanged asset strategy and deployment note remain explicit.
- [x] The test strategy and minimal proving commands are explicit and aligned across artifacts.
- [x] Spec `280` workspace-first environment route shell is already merged or otherwise present on the implementation branch.
## Artifact Alignment
- [x] `research.md` records the same bounded route-ownership decisions reflected in `plan.md`.
- [x] `data-model.md` models the same workspace and managed-environment route-context contract reflected in the spec and plan.
- [x] `quickstart.md` uses the same bounded reviewer flow and proof commands as `plan.md`.
- [x] `contracts/governance-artifact-retargeting.logical.openapi.yaml` models the same workspace-first route ownership described in the plan, including collection, detail, and operations-detail surfaces.
- [x] Canonical proof commands match across `spec.md`, `plan.md`, and `quickstart.md`.
## Test Governance
- [x] Planned proof stays bounded to focused feature coverage plus one browser smoke.
- [x] No new heavy-governance family or broad browser matrix is introduced.
- [x] Workspace and managed-environment fixture cost is acknowledged instead of hidden.
- [x] Reviewer handoff includes exact minimal validation commands and concrete stop questions.
## Notes
- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/267-artifact-lifecycle-retention/spec.md`, `specs/277-stored-reports-surface/spec.md`, `specs/279-workspace-managed-environment-core/spec.md`, `specs/280-workspace-tenancy-environment-routing/spec.md`, `specs/281-provider-connection-scope/spec.md`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `apps/platform/app/Filament/Resources/StoredReportResource.php`, and repo-wide searches for admin-hide guards and environment-context helper usage on 2026-05-07.
- No application implementation, test execution, or runtime validation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `blocked-by-prerequisite`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Reason**: The package converts reserved slot `282` into a bounded, repo-real implementation target for governance artifact surface ownership and environment-context truth, but implementation remains blocked until the Spec `280` route shell is already present on the working branch. Adjacent lifecycle, reporting, provider, RBAC, copy, and quality-gate work remain deferred.
## Implementation Close-out
- 2026-05-07: Implemented the bounded Spec `282` slice on top of the existing workspace-first route shell without widening into Specs `267`, `277`, or `283` through `287`.
- Surface ownership stayed on governance artifact registration, workspace-first environment routing, shared related-navigation and operation drillthrough seams, searchable-destination truthfulness, and tenant-panel guard cleanup only.
- No schema, provider-registration, asset-strategy, lifecycle, provider-taxonomy, or RBAC redesign work was added. Filament stayed on v5 with Livewire v4 semantics, and provider registration remained in `apps/platform/bootstrap/providers.php`.
- Final bounded validation ran green with `./vendor/bin/sail bin pint --dirty --format agent`, `./vendor/bin/sail artisan test --compact tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php`, and `./vendor/bin/sail artisan test --compact tests/Browser/Spec282GovernanceArtifactRetargetingSmokeTest.php`.

View File

@ -0,0 +1,374 @@
openapi: 3.1.0
info:
title: Governance Artifact Retargeting to ManagedEnvironment
version: 0.1.0
description: |
Logical route contract for the bounded 282 cutover slice.
This models the workspace-first admin ownership of existing governance artifact surfaces,
including collection routes, detail routes, and the workspace-first operations detail surface.
Backup items remain nested inside existing backup-set or restore-run surfaces and do not
become a standalone top-level route family in 282.
servers:
- url: /admin
paths:
/workspaces/{workspace}/environments/{environment}/inventory:
get:
summary: List inventory items for one managed environment
operationId: listEnvironmentInventory
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped inventory register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/inventory/{record}:
get:
summary: View one inventory item in one managed environment
operationId: showEnvironmentInventoryItem
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped inventory detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/findings:
get:
summary: List findings for one managed environment
operationId: listEnvironmentFindings
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped findings register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/findings/{record}:
get:
summary: View one finding in one managed environment
operationId: showEnvironmentFinding
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped finding detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/finding-exceptions:
get:
summary: List finding exceptions for one managed environment
operationId: listEnvironmentFindingExceptions
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped finding exception register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/finding-exceptions/{record}:
get:
summary: View one finding exception in one managed environment
operationId: showEnvironmentFindingException
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped finding exception detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/policies:
get:
summary: List policies for one managed environment
operationId: listEnvironmentPolicies
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped policies register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/policies/{record}:
get:
summary: View one policy in one managed environment
operationId: showEnvironmentPolicy
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped policy detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/policy-versions:
get:
summary: List policy versions for one managed environment
operationId: listEnvironmentPolicyVersions
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped policy version register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/policy-versions/{record}:
get:
summary: View one policy version in one managed environment
operationId: showEnvironmentPolicyVersion
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped policy version detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/backup-schedules:
get:
summary: List backup schedules for one managed environment
operationId: listEnvironmentBackupSchedules
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped backup schedule register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/backup-schedules/{record}:
get:
summary: View one backup schedule in one managed environment
operationId: showEnvironmentBackupSchedule
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped backup schedule detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/backups:
get:
summary: List backup sets for one managed environment
operationId: listEnvironmentBackupSets
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped backup sets register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/backups/{record}:
get:
summary: View one backup set in one managed environment
operationId: showEnvironmentBackupSet
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped backup set detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/restore-runs:
get:
summary: List restore runs for one managed environment
operationId: listEnvironmentRestoreRuns
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped restore runs register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/restore-runs/{record}:
get:
summary: View one restore run in one managed environment
operationId: showEnvironmentRestoreRun
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped restore run detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/evidence:
get:
summary: List evidence snapshots for one managed environment
operationId: listEnvironmentEvidenceSnapshots
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped evidence register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/evidence/{record}:
get:
summary: View one evidence snapshot in one managed environment
operationId: showEnvironmentEvidenceSnapshot
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped evidence detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/reviews:
get:
summary: List tenant reviews for one managed environment
operationId: listEnvironmentReviews
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped tenant review register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/reviews/{record}:
get:
summary: View one tenant review in one managed environment
operationId: showEnvironmentReview
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped tenant review detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/review-packs:
get:
summary: List review packs for one managed environment
operationId: listEnvironmentReviewPacks
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped review pack register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/review-packs/{record}:
get:
summary: View one review pack in one managed environment
operationId: showEnvironmentReviewPack
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped review pack detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/environments/{environment}/stored-reports:
get:
summary: List stored reports for one managed environment
operationId: listEnvironmentStoredReports
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment-scoped stored report register
'404':
description: Workspace or environment scope not accessible
/workspaces/{workspace}/environments/{environment}/stored-reports/{record}:
get:
summary: View one stored report in one managed environment
operationId: showEnvironmentStoredReport
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
- $ref: '#/components/parameters/ArtifactRecord'
responses:
'200':
description: Environment-scoped stored report detail
'404':
description: Workspace, environment, or record scope not accessible
/workspaces/{workspace}/operations:
get:
summary: List operations for one workspace with optional environment context
operationId: listWorkspaceOperationsForArtifacts
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/ManagedEnvironmentFilter'
responses:
'200':
description: Workspace operations register with optional managed-environment filter
'404':
description: Workspace or managed-environment scope not accessible
/workspaces/{workspace}/operations/{operation}:
get:
summary: View one operation in one workspace
operationId: showWorkspaceOperation
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Operation'
responses:
'200':
description: Workspace operation detail used by artifact-origin drillthroughs
'404':
description: Workspace or operation scope not accessible
components:
parameters:
Workspace:
name: workspace
in: path
required: true
schema:
type: string
description: Workspace slug or route identifier
Environment:
name: environment
in: path
required: true
schema:
type: string
description: Managed-environment slug or route identifier
ArtifactRecord:
name: record
in: path
required: true
schema:
type: string
description: Environment-owned artifact record route identifier
Operation:
name: operation
in: path
required: true
schema:
type: string
description: Workspace operation route identifier
ManagedEnvironmentFilter:
name: managed_environment_id
in: query
required: false
schema:
type: integer
description: Optional environment filter used by artifact-origin operation drillthroughs

View File

@ -0,0 +1,77 @@
# Data Model: Governance Artifact Retargeting to ManagedEnvironment
## Purpose
Describe the route-context and ownership contract that `282` will implement over existing governance artifact records. This package does not add new persisted entities.
## Core Context Objects
### WorkspaceContext
| Field | Type | Source | Notes |
|---|---|---|---|
| `workspace_id` | integer | route or current admin context | first isolation boundary |
| `workspace_slug` | string | route | route-readable workspace identity |
### ManagedEnvironmentContext
| Field | Type | Source | Notes |
|---|---|---|---|
| `managed_environment_id` | integer | route or operate-hub context | second isolation boundary |
| `environment_slug` | string | route | route-readable environment identity |
| `workspace_id` | integer | route + record invariant | must match the active workspace context |
### ArtifactRouteContext
| Field | Type | Source | Notes |
|---|---|---|---|
| `workspace` | route parameter | workspace-first admin shell | required |
| `environment` | route parameter | workspace-first admin shell | required for all touched artifact families |
| `domain_slug` | static resource slug | resource family | for example `findings`, `backups`, `evidence`, `review-packs` |
| `record_id` | route parameter | detail route | optional on collection routes |
## Existing Artifact Families Covered By 282
| Family | Representative model(s) | Ownership invariant | Surface outcome in 282 |
|---|---|---|---|
| Governance registers | `InventoryItem`, `Policy`, `PolicyVersion`, `Finding`, `FindingException` | `workspace_id` + `managed_environment_id` | register and detail surfaces live on workspace-first environment routes |
| Recovery and backup | `BackupSchedule`, `BackupSet`, `RestoreRun` | `workspace_id` + `managed_environment_id` | action-bearing resources keep their current semantics on workspace-first environment routes |
| Evidence and reporting | `EvidenceSnapshot`, `TenantReview`, `ReviewPack`, `StoredReport` | `workspace_id` + `managed_environment_id` | read-only or current action semantics continue on workspace-first environment routes |
## Shared Invariants
- A touched artifact record may only render when the route `workspace_id` matches the record `workspace_id`.
- A touched artifact record may only render when the route `managed_environment_id` matches the record `managed_environment_id`.
- Resource collection queries must filter to both the active workspace and the active managed environment.
- Related navigation and operation drillthroughs must preserve the same workspace and managed-environment context.
- Any touched surface that cannot satisfy those invariants must deny as `404` rather than widen scope.
## Authorization Contract
| Check | Expected result |
|---|---|
| Actor lacks workspace membership | `404` |
| Actor has workspace membership but lacks environment entitlement | `404` |
| Actor has correct scope but lacks resource capability | `403` |
| Actor opens a record from another environment in the same workspace | `404` |
| Actor opens a record from another workspace | `404` |
## Operation Drillthrough Contract
| Field | Meaning |
|---|---|
| `workspace_id` | operation route remains inside the active workspace |
| `managed_environment_id` | operation list or detail opens with truthful environment context where applicable |
| `origin_surface` | optional navigation/back-link hint only; not persisted truth |
`282` does not change `OperationRun` persistence. It only requires touched artifact surfaces to link into the workspace-first operations contract from Spec `280`.
## Out Of Scope Shapes
- No new artifact super-entity
- No new lifecycle state family
- No new provider-capability or taxonomy fields
- No renaming of `TenantReview` or other tenant-shaped class names
- No compatibility `tenant_id` aliases or dual relations
- No adjacent-page route retargeting in `282`
- No standalone `backup items` route family in `282`

View File

@ -0,0 +1,272 @@
# Implementation Plan: Governance Artifact Retargeting to ManagedEnvironment
**Branch**: `282-governance-artifact-retargeting` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/282-governance-artifact-retargeting/spec.md`
## Summary
Prepare the third reserved managed-environment cutover slice by moving the existing governance artifact surface families onto the workspace-first admin runtime. The narrow implementation path reuses the existing artifact models, Filament resources, artifact presenters, `ResolvesPanelTenantContext`, `InteractsWithTenantOwnedRecords`, `OperateHubShell`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `OperationRunLinks`, and the workspace-first route shell prepared by Spec `280`, while explicitly deferring lifecycle, stored-report productization, provider-capability, taxonomy, RBAC, copy, and quality-gate follow-through to Specs `267`, `277`, and `283` through `287`.
This plan is intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, no schema or persistence change is introduced, no artifact naming or lifecycle rewrite is introduced, and no compatibility panel or route fallback is allowed.
This package is not independently executable on current repo truth. Implementation is blocked until the workspace-first environment route shell from Spec `280` is already merged or otherwise present on the implementation branch.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- Spec `279` already moved the core governance artifact models onto `managed_environment_id` plus `workspace_id`, and many models now derive `workspace_id` through `DerivesWorkspaceIdFromTenant` or equivalent seams.
- Spec `280` already defines the workspace-first environment route shell and surviving admin-panel ownership as the adjacent cutover baseline, and `282` is only executable once that shell is already present on the implementation branch.
- Spec `281` already prepares the provider-boundary extraction so `282` can treat provider seams as adjacent context, not new scope.
- Current repo truth still shows tenant-panel registration or admin-hidden behavior on several governance artifact resources through `shouldRegisterNavigation()` checks against `Filament::getCurrentPanel()?->getId() === 'admin'`.
- Current repo truth also shows environment resolution and authorization on read-only artifact resources such as `ReviewPackResource`, `EvidenceSnapshotResource`, and `StoredReportResource` still relying on `ManagedEnvironment::current()` or mixed fallback chains that assume the tenant panel remains active.
- `OperationRunLinks` already owns the canonical operations link language; `282` only needs artifact surfaces to reuse that contract.
- Existing prepared packages already cover adjacent lifecycle and artifact productization gaps: Spec `267` for artifact lifecycle and Spec `277` for stored reports.
### Explicit delta in this plan
- Register the touched governance artifact resource families on the workspace-first admin runtime instead of hiding them from the admin panel or depending on the tenant panel.
- Align collection and detail route ownership for those resource families to `/admin/workspaces/{workspace}/environments/{environment}/...`.
- Align environment-context resolution for those resources and their related pages so the admin panel can resolve the current managed environment without the tenant panel.
- Retarget related navigation, record URLs, and operation drillthroughs emitted by the touched artifact surfaces to the workspace-first environment and operations contracts.
- Preserve each resource's existing action hierarchy, read-only or mutation semantics, capability model, and artifact presenters.
- Explicitly defer lifecycle semantics, stored-report surface expansion, provider-capability or taxonomy work, RBAC redesign, copy-neutralization, and broader no-legacy enforcement.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing Filament resources and shared navigation/context helpers
**Storage**: PostgreSQL, no new persistence or schema change
**Testing**: Pest feature tests, one Pest browser smoke, focused legacy-panel guard coverage
**Validation Lanes**: fast-feedback, confidence, browser
**Target Platform**: Laravel monolith in `apps/platform`
**Project Type**: web application
**Performance Goals**: preserve current resource responsiveness and viewer behavior while changing only panel registration, route ownership, environment resolution, and deep links
**Constraints**: no schema migration, no compatibility routes, no dual-panel ownership, no naming or lifecycle rewrite, no RBAC redesign, provider registration remains in `apps/platform/bootstrap/providers.php`, and Filament remains v5 on Livewire v4
**Scale/Scope**: one route and registration contract over the existing environment-owned governance artifact resource families plus their related drillthrough links
## Likely Affected Repo Surfaces
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
- `apps/platform/app/Filament/Concerns/InteractsWithTenantOwnedRecords.php`
- `apps/platform/app/Filament/Resources/InventoryItemResource.php`
- `apps/platform/app/Filament/Resources/PolicyResource.php`
- `apps/platform/app/Filament/Resources/PolicyVersionResource.php`
- `apps/platform/app/Filament/Resources/BackupScheduleResource.php`
- `apps/platform/app/Filament/Resources/BackupSetResource.php`
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Resources/FindingExceptionResource.php`
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- `apps/platform/app/Filament/Resources/TenantReviewResource.php`
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
- `apps/platform/app/Filament/Resources/StoredReportResource.php`
- `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- representative feature and browser coverage under `apps/platform/tests/Feature/Filament/GovernanceArtifacts/` and `apps/platform/tests/Browser/`
## Filament v5 / Panel Notes
- **Livewire v4.0+ compliance**: this slice keeps Filament v5 on Livewire v4 and changes only resource registration, route ownership, context resolution, and related links.
- **Provider registration location**: any provider registration context remains in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
- **Global search rule**: `282` does not enable global search on new artifact resources. Any touched resource that is already searchable must keep a valid View or Edit destination or be disabled in the same slice.
- **Destructive actions**: no new destructive actions are introduced. Existing destructive or high-impact actions touched by the route retarget must preserve `->requiresConfirmation()` plus current server authorization.
- **Asset strategy**: no new asset registration or deployment step is planned.
## Governance Artifact Route Ownership Fit
- Treat the current environment-owned governance artifact resources as the primary unit of change, not the underlying models or lifecycle semantics.
- Reuse the workspace-first environment route shell from Spec `280` instead of inventing a parallel artifact route family.
- Reuse `ResolvesPanelTenantContext` as the shared environment-resolution seam, but make the admin-panel route contract its authoritative source for the touched artifact families.
- Reuse each resource's current action-surface declaration and local UI semantics; only route ownership, admin registration, and related URLs move.
- Reuse `OperationRunLinks` and `RelatedNavigationResolver` for artifact-origin operation or related-resource drillthroughs rather than per-resource URL composition.
- Do not preserve `ManagedEnvironment::current()` as a surviving runtime fallback for touched surfaces. The final shipped contract must resolve the current environment from the workspace-first admin route or operate-hub context only.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: environment-scoped resources, related navigation, artifact presenters, operation links
- **State layers in scope**: shell, page, detail, URL-query
- **Audience modes in scope**: operator-MSP, support-platform, customer-safe read-only where already supported by current surfaces
- **Decision/diagnostic/raw hierarchy plan**: keep environment scope and primary inspect action visible first; keep diagnostics or raw/support detail secondary on the destination surface
- **Raw/support gating plan**: unchanged existing gating; this slice does not widen raw evidence disclosure
- **One-primary-action / duplicate-truth control**: preserve each resource's current inspect model and existing primary action; add no new redundant inspect or navigation affordance
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory until guard coverage proves the touched artifact families no longer depend on the tenant panel
- **Special surface test profiles**: standard-native-filament, global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke
- **Exception path and spread control**: none; the goal is convergence on the admin-panel route contract
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: environment-owned resources, shared environment-context helpers, related navigation, operation links, artifact presenters, admin-panel route shell
- **Shared abstractions reused**: `ResolvesPanelTenantContext`, `InteractsWithTenantOwnedRecords`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `OperationRunLinks`, `ArtifactTruthPresenter`, existing `ActionSurfaceDeclaration`s
- **New abstraction introduced? why?**: none planned; the existing shared seams are sufficient
- **Why the existing abstraction was sufficient or insufficient**: the abstractions already express the right ownership and presentation model; they are insufficient only because the tenant panel still leaks into registration and route-generation behavior
- **Bounded deviation / spread control**: none; if a local escape hatch appears, the work is out of scope and should split
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, link semantics only
- **Central contract reused**: `OperationRunLinks`, `RelatedNavigationResolver`, and the workspace-first operations routes from Spec `280`
- **Delegated UX behaviors**: artifact-origin operation links, explicit environment-filter continuity, and destination-safe route ownership
- **Surface-owned behavior kept local**: source resources decide whether a related operation link exists; they do not own the operations URL contract
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
N/A - `282` does not alter shared provider identity or capability seams directly. Those remain with Specs `281`, `283`, and `284`.
## Constitution Check
*GATE: Must pass before implementation begins and again after design artifacts are complete.*
- Inventory-first / snapshot truth: PASS. Artifact persistence truth stays unchanged.
- Read/write separation: PASS. No new remote-write or lifecycle workflow is introduced.
- Graph contract path: PASS. No Graph contract change is introduced.
- Deterministic capabilities: PASS. Existing capability checks stay unchanged.
- RBAC-UX plane separation: PASS. `/admin` and `/system` remain separate.
- Workspace isolation: PASS. Workspace membership remains the first route boundary.
- Managed-environment isolation: PASS. Managed-environment access remains the second route boundary.
- Destructive action discipline: PASS by non-expansion. Existing confirmation and server authorization remain intact.
- Global search safety: PASS with implementation condition. Touched searchable surfaces must keep valid destinations or be disabled.
- OperationRun / Ops-UX: PASS. Only existing operation link semantics move.
- Data minimization: PASS. No new data or raw disclosure is introduced.
- Test governance: PASS. Proof stays bounded to focused feature and browser coverage.
- Proportionality / no premature abstraction: PASS. The plan changes route ownership only.
- Persisted truth / behavioral state: PASS. No new persistence, state family, or taxonomy is introduced.
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing native resources and presenters remain the first path.
- Provider boundary: PASS by non-expansion.
**Gate evaluation**: PASS.
**Post-design re-check**: PASS while `research.md`, `data-model.md`, `quickstart.md`, `contracts/governance-artifact-retargeting.logical.openapi.yaml`, and `checklists/requirements.md` remain aligned and keep Specs `267`, `277`, and `283` through `287` deferred.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature, Browser
- **Affected validation lanes**: fast-feedback, confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: the cutover changes resource registration, route ownership, context resolution, and deep-link behavior on existing surfaces. Feature tests prove those contracts; one browser smoke proves the real shell and one representative artifact path.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec282GovernanceArtifactRetargetingSmokeTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace membership, managed-environment membership, and representative artifact fixtures across more than one family
- **Expensive defaults or shared helper growth introduced?**: no; keep new fixtures opt-in and bounded to the governance-artifact family
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native-filament relief for most resources; one browser smoke still required for the real shell and route contract
- **Closing validation and reviewer handoff**: rerun the exact commands above, confirm the touched governance artifact families register on the admin panel, confirm resource URLs and related links no longer emit tenant-panel destinations, confirm environment resolution on the admin panel no longer depends on tenant-panel-only state, confirm touched searchable resources keep truthful destinations or stay disabled, confirm destructive actions still require confirmation, and confirm no new asset or deployment step appears
- **Budget / baseline / trend follow-up**: contained feature-local increase only
- **Review-stop questions**: did the implementation leave any touched artifact family admin-hidden, did any touched resource still rely on `panel: 'tenant'` or `tenant:` URLs, did any read-only artifact surface still require `ManagedEnvironment::current()` as the only source of truth, did the slice absorb lifecycle or copy work from adjacent specs
- **Escalation path**: `reject-or-split` if the implementation introduces schema work, compatibility fallbacks, or adjacent lifecycle or RBAC scope
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the remaining adjacent work already exists as Specs `267`, `277`, and `283` through `287`; route and registration proof stays inside this feature
## Review Checklist Status
- **Review checklist artifact**: `checklists/requirements.md`
- **Review outcome class**: `blocked-by-prerequisite`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Escalation rule**: if implementation starts without Spec `280` present, leaves touched artifact families tenant-panel-bound, introduces schema or lifecycle work, or widens into adjacent specs, flip the workflow outcome to `split` or `reject-or-split`
## Rollout Considerations
- Land the touched governance artifact resource families and the shared environment-context helper changes as one bounded slice so admin registration, route ownership, and authorization move together.
- Do not begin runtime implementation until Spec `280` is merged or otherwise available on the implementation branch.
- Update shared route and context seams before polishing individual resources so each resource can inherit the same final contract.
- Keep read-only artifact surfaces in the same slice because they are the most likely place for hidden tenant-panel fallbacks to survive.
- Keep operation drillthrough retargeting inside the same slice so artifact truth and execution truth stay aligned.
## Risk Controls
- Reject any implementation that reopens schema, lifecycle, or naming work instead of limiting itself to route ownership and context resolution.
- Reject any implementation that leaves a touched artifact family hidden from the admin panel or still emitting tenant-panel URLs.
- Reject any implementation that adds a second local route helper pattern instead of reusing the shared navigation and context seams.
- Reject any implementation that widens searchable exposure without keeping a truthful destination.
## Research & Design Outputs
- `research.md` records the candidate deviations, route-ownership decision, touched resource inventory, and deferrals.
- `data-model.md` captures the route-context and authorization contracts for the environment-owned artifact families.
- `quickstart.md` gives reviewers the bounded proof flow and exact commands.
- `contracts/governance-artifact-retargeting.logical.openapi.yaml` records the logical route contract for the touched artifact families and their operation drillthroughs.
- `checklists/requirements.md` records package readiness, boundedness, and outcome state.
## Project Structure
### Documentation (this feature)
```text
specs/282-governance-artifact-retargeting/
├── checklists/
│ └── requirements.md
├── contracts/
│ └── governance-artifact-retargeting.logical.openapi.yaml
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
├── spec.md
└── tasks.md
```
### Source Code (expected implementation surfaces)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Concerns/
│ │ │ ├── InteractsWithTenantOwnedRecords.php
│ │ │ └── ResolvesPanelTenantContext.php
│ │ └── Resources/
│ │ ├── BackupScheduleResource.php
│ │ ├── BackupSetResource.php
│ │ ├── EvidenceSnapshotResource.php
│ │ ├── FindingExceptionResource.php
│ │ ├── FindingResource.php
│ │ ├── InventoryItemResource.php
│ │ ├── PolicyResource.php
│ │ ├── PolicyVersionResource.php
│ │ ├── RestoreRunResource.php
│ │ ├── ReviewPackResource.php
│ │ ├── StoredReportResource.php
│ │ └── TenantReviewResource.php
│ └── Support/
│ ├── Navigation/
│ │ ├── CanonicalNavigationContext.php
│ │ └── RelatedNavigationResolver.php
│ ├── OperateHub/
│ │ └── OperateHubShell.php
│ └── OperationRunLinks.php
└── tests/
├── Browser/
│ └── Spec282GovernanceArtifactRetargetingSmokeTest.php
└── Feature/
└── Filament/
└── GovernanceArtifacts/
├── GovernanceArtifactAdminPanelRegistrationTest.php
├── GovernanceArtifactDeepLinkContractTest.php
├── GovernanceArtifactEnvironmentContextTest.php
└── GovernanceArtifactLegacyTenantPanelGuardTest.php
```
**Structure Decision**: Laravel monolith under `apps/platform` with one bounded documentation package under `specs/282-governance-artifact-retargeting/`, no new runtime top-level folders, and no adjacent-page retargeting in this slice.
## Deferred Follow-Ups / Non-Goals
- Spec `267` artifact lifecycle and retention contract work
- Spec `277` stored-reports browse and detail productization beyond route ownership
- Spec `283` provider capability registry
- Spec `284` provider-neutral artifact taxonomy
- Spec `285` workspace-first RBAC and environment access scoping redesign
- Spec `286` UI copy and localization neutralization
- Spec `287` cutover-wide no-legacy enforcement

View File

@ -0,0 +1,56 @@
# Quickstart: Governance Artifact Retargeting to ManagedEnvironment
## Goal
Review and later implement the bounded `282` slice that moves existing governance artifact surface families onto the workspace-first admin runtime without changing persistence or lifecycle semantics.
## External Prerequisite
Do not start runtime implementation until Spec `280` is already merged or otherwise present on the working branch. `282` reuses that route shell; it does not redefine it.
## Review Flow
1. Read `spec.md`, `plan.md`, `research.md`, `data-model.md`, and `checklists/requirements.md` together.
2. Confirm the candidate deviation is explicit: persistence is already `managed_environment_id` plus `workspace_id`; `282` is a surface-ownership package.
3. Confirm the touched resource families stay limited to environment-owned governance artifacts and their drillthrough links.
4. Confirm Specs `267`, `277`, and `283` through `287` remain explicitly deferred.
## Planned Validation Commands
Run the same proof commands recorded in `spec.md` and `plan.md`:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php \
tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php \
tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php \
tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php)
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Browser/Spec282GovernanceArtifactRetargetingSmokeTest.php)
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)
```
## Reviewer Checklist
- Do the touched artifact families clearly register on the admin panel rather than hiding behind tenant-panel guards?
- Does environment resolution on the admin panel stop treating `ManagedEnvironment::current()` as the only valid context source?
- Do related links and operation drillthroughs avoid `tenant:` and `panel: 'tenant'` destinations?
- Do touched read-only artifact surfaces preserve their existing semantics without importing lifecycle or reporting scope from other specs?
- Do touched searchable surfaces remain truthful or stay disabled?
- Does the plan stay within `282` and avoid `267`, `277`, or `283` through `287` scope creep?
## Stop Conditions
Stop and split the work if any proposed change requires:
- Spec `280` route shell work that is not already present on the branch
- schema or persistence changes
- lifecycle or retention state design
- broad stored-report surface redesign
- provider-capability, taxonomy, RBAC, or vocabulary redesign
- compatibility routes or dual-panel fallbacks

View File

@ -0,0 +1,84 @@
# Research: Governance Artifact Retargeting to ManagedEnvironment
## Decision Summary
### Decision 1: Treat 282 as a surface-ownership slice, not a schema slice
- **Decision**: `282` will retarget existing governance artifact resources and drillthroughs to the workspace-first admin runtime. It will not reopen `tenant_id` to `managed_environment_id` persistence work.
- **Why**: repo truth already shows `managed_environment_id` plus `workspace_id` on the relevant models, and the real remaining gap is surface registration, route ownership, and context resolution.
- **Evidence**:
- `OperationRun`, `Finding`, `Policy`, `BackupSet`, `RestoreRun`, `EvidenceSnapshot`, `ReviewPack`, `TenantReview`, and `StoredReport` already persist `managed_environment_id`
- many models use `DerivesWorkspaceIdFromTenant` or equivalent workspace derivation seams
### Decision 2: Scope the implementation around current resource families
- **Decision**: the primary implementation unit is the current environment-owned Filament resource families, not a generic artifact abstraction.
- **Why**: the route and admin-registration drift is expressed in concrete resources such as `FindingResource`, `PolicyResource`, `BackupSetResource`, `RestoreRunResource`, `EvidenceSnapshotResource`, `ReviewPackResource`, and `StoredReportResource`.
- **Evidence**:
- several resources still include admin-hide guards through `shouldRegisterNavigation()` checks against the `admin` panel
- read-only artifact resources still use `ManagedEnvironment::current()` or mixed fallback chains that imply tenant-panel ownership
### Decision 3: Reuse shared context and link seams
- **Decision**: reuse `ResolvesPanelTenantContext`, `InteractsWithTenantOwnedRecords`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, and `OperationRunLinks` instead of creating a new route-helper layer.
- **Why**: the repo already has one shared environment-resolution seam and one shared navigation contract; the drift is in how existing resources still call them under tenant-panel assumptions.
- **Evidence**:
- `ResolvesPanelTenantContext` already branches for `admin` versus `tenant` panels
- `OperationRunLinks` already owns canonical operations URLs
### Decision 4: Keep artifact lifecycle and stored-report productization deferred
- **Decision**: defer lifecycle semantics to Spec `267` and broader stored-report surface/productization work to Spec `277`.
- **Why**: both packages already exist and would broaden `282` beyond route ownership and context resolution.
- **Evidence**:
- `specs/267-artifact-lifecycle-retention/spec.md` is already `Ready for implementation`
- `specs/277-stored-reports-surface/spec.md` is already `Ready for implementation`
### Decision 5: Keep provider, RBAC, copy, and no-legacy follow-through deferred
- **Decision**: defer Specs `283` through `287` unchanged.
- **Why**: `282` is already broad enough at the resource-surface layer. Capability registries, taxonomies, RBAC redesign, vocabulary cleanup, and global cutover quality gates belong to the later reserved slots.
### Decision 6: Keep adjacent pages out of scope for 282
- **Decision**: pages such as `TenantDiagnostics`, `InventoryCoverage`, and `BaselineCompareLanding` remain out of scope for `282` unless a later implementation produces a concrete, isolated follow-up need.
- **Why**: repo truth shows these are adjacent seams, not part of the minimum route-ownership contract needed for the current governance artifact resource families.
## Touched Resource Inventory
| Family | Representative resources | Current repo issue |
|---|---|---|
| Governance registers | `InventoryItemResource`, `PolicyResource`, `PolicyVersionResource`, `FindingResource`, `FindingExceptionResource` | admin-hidden registration or environment-context drift |
| Recovery and backup | `BackupScheduleResource`, `BackupSetResource`, `RestoreRunResource` | admin-hidden registration plus many related links and action URLs |
| Evidence and reporting | `EvidenceSnapshotResource`, `TenantReviewResource`, `ReviewPackResource`, `StoredReportResource` | environment resolution still assumes tenant-panel truth in fallbacks |
## Candidate Deviations From Raw Backlog Text
- The raw candidate reads like a model and route retarget combined. Repo truth shows the model retarget already happened in Spec `279`.
- The raw candidate mentions renaming review concepts and removing remaining `/admin/t` links broadly. For `282`, this is narrowed to the touched governance artifact surface families only; broader copy cleanup remains Spec `286`.
- The raw candidate's `operation_runs` move is already satisfied at the persistence layer; `282` only retargets artifact-origin links into the workspace-first operations routes reused from Spec `280`.
- The raw candidate's `backup items` note remains nested under backup-set and restore-run surfaces and does not become a separate top-level route family in `282`.
## Implementation Risks To Hold During Tasks Generation
- Do not widen into lifecycle or reporting semantics just because `ReviewPackResource` and `StoredReportResource` are touched.
- Do not create new route helpers or a generic artifact surface layer.
- Do not leave any touched family half-migrated, where list pages move but action URLs or related links still emit tenant-panel destinations.
## Files Reviewed
- `.specify/memory/constitution.md`
- `docs/product/spec-candidates.md`
- `docs/product/roadmap.md`
- `specs/267-artifact-lifecycle-retention/spec.md`
- `specs/277-stored-reports-surface/spec.md`
- `specs/279-workspace-managed-environment-core/spec.md`
- `specs/280-workspace-tenancy-environment-routing/spec.md`
- `specs/281-provider-connection-scope/spec.md`
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
- `apps/platform/app/Filament/Resources/StoredReportResource.php`
- repo-wide searches for admin-hide guards and environment-context helper usage in Filament resources and pages

View File

@ -0,0 +1,378 @@
# Feature Specification: Governance Artifact Retargeting to ManagedEnvironment
**Feature Branch**: `282-governance-artifact-retargeting`
**Created**: 2026-05-07
**Status**: Prepared - blocked by Spec `280` runtime prerequisite
**Input**: User description: "Continue with reserved slot `282 - Governance Artifact Retargeting to ManagedEnvironment` as the next preparation-only package. Work repo-based, create spec, plan, and tasks, and do not implement application code."
## Spec Candidate Check
- **Problem**: Repo truth already moved the core governance artifact models onto `managed_environment_id` plus `workspace_id`, but many operator-facing artifact resources and drillthroughs still behave like tenant-panel surfaces. They either hide from the admin panel, depend on `ManagedEnvironment::current()` or `panel: 'tenant'` assumptions, or still emit tenant-panel routes from related links and action URLs.
- **Today's failure**: Operators can hold correct managed-environment-scoped data in `Finding`, `Policy`, `BackupSet`, `EvidenceSnapshot`, `ReviewPack`, `StoredReport`, `TenantReview`, `RestoreRun`, and adjacent artifacts, but the UI ownership and deep-link contract are still split between the surviving admin panel and the temporary tenant-panel semantics. After Spec `280`, these surfaces would either disappear, keep stale `/admin/t` assumptions, or present the same environment-owned artifact truth through inconsistent route and context rules.
- **User-visible improvement**: Operators open governance artifact registers and detail surfaces for the current managed environment entirely inside the workspace-first admin runtime, with one consistent environment-aware route, one consistent breadcrumb shell, and one consistent deep-link contract into related evidence, reviews, reports, and operations.
- **Smallest enterprise-capable version**: Reuse the existing models, resources, pages, action surfaces, artifact presenters, and authorization capabilities; re-home the environment-owned governance artifact resource families onto the admin panel under the workspace-first environment route family; centralize environment resolution through the existing panel-context helpers; retarget related links and operation drillthroughs; and keep lifecycle semantics, report-family semantics, copy-neutralization, capability-registry work, and RBAC redesign explicitly deferred.
- **Explicit non-goals**: No new schema cutover, no `tenant_id` compatibility alias, no lifecycle-contract work from Spec `267`, no stored-report product-surface expansion beyond the already prepared Spec `277`, no provider capability registry, no provider-neutral artifact taxonomy, no workspace-RBAC redesign, no broader tenant-to-managed-environment copy renaming, no customer-portal change, no route-compatibility fallback, and no dual-panel ownership.
- **Permanent complexity imported**: One admin-panel route and registration contract for the existing governance artifact surface families, one bounded environment-context resolution pattern over the existing helper seams, and focused feature and browser coverage. No new table, registry, state family, or meta-framework is introduced.
- **Why now**: Spec `279` already completed the model-level managed-environment cutover and Spec `280` prepares the workspace-first routing shell. The next reserved blocker is the resource and link layer: once the `280` route shell is present on the implementation branch, `282` removes the tenant-panel assumptions that still survive on the artifact surfaces operators actually use.
- **Why not local**: The hotspot repeats across many resources, page links, and authorization checks. Local page-by-page patches would preserve the underlying split between resource registration, route ownership, context resolution, and deep-link behavior.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Many operator-facing surfaces, adjacency to lifecycle/reporting specs, and temptation to widen into naming or RBAC work. Defense: the slice is explicitly limited to route ownership, resource registration, shared context resolution, and related-link truth over already-correct persistence.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields
- **Scope**: workspace
- **Primary Routes**:
- `/admin/workspaces/{workspace}/environments/{environment}/inventory`
- `/admin/workspaces/{workspace}/environments/{environment}/policies`
- `/admin/workspaces/{workspace}/environments/{environment}/policy-versions`
- `/admin/workspaces/{workspace}/environments/{environment}/backup-schedules`
- `/admin/workspaces/{workspace}/environments/{environment}/backups`
- `/admin/workspaces/{workspace}/environments/{environment}/restore-runs`
- `/admin/workspaces/{workspace}/environments/{environment}/findings`
- `/admin/workspaces/{workspace}/environments/{environment}/finding-exceptions`
- `/admin/workspaces/{workspace}/environments/{environment}/evidence`
- `/admin/workspaces/{workspace}/environments/{environment}/reviews`
- `/admin/workspaces/{workspace}/environments/{environment}/review-packs`
- `/admin/workspaces/{workspace}/environments/{environment}/stored-reports`
- the existing workspace-first operations routes from Spec `280` remain the only operation drillthrough targets for touched artifact links
- **Data Ownership**:
- environment-owned governance artifact records already remain anchored by `workspace_id` plus `managed_environment_id`, including `InventoryItem`, `Policy`, `PolicyVersion`, `BackupSchedule`, `BackupSet`, `RestoreRun`, `Finding`, `FindingException`, `EvidenceSnapshot`, `TenantReview`, `ReviewPack`, and `StoredReport`
- `OperationRun` remains workspace-owned execution truth with optional `managed_environment_id`; this slice only changes artifact links into that truth
- no new artifact super-table, lifecycle table, or workspace-owned mirror is introduced
- **RBAC**:
- workspace membership remains the first isolation boundary
- managed-environment entitlement remains the second isolation boundary
- non-members or wrong-workspace or wrong-environment access remain `404`
- in-scope actors missing current artifact capabilities remain `403`
- current destructive or high-impact actions keep their existing server authorization and confirmation rules
## Cross-Cutting / Shared Pattern Reuse
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, related-context drillthroughs, evidence and report viewers, artifact detail surfaces, and operation links from artifact surfaces
- **Systems touched**: `ResolvesPanelTenantContext`, `InteractsWithTenantOwnedRecords`, `OperateHubShell`, environment-scoped Filament resources, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `OperationRunLinks`, existing artifact presenters, and the workspace-first environment route contract prepared by Spec `280`
- **Existing pattern(s) to extend**: the current workspace-first environment route family, current artifact truth presenters, current resource action-surface declarations, and the shared environment-context helper path
- **Shared contract / presenter / builder / renderer to reuse**: `ResolvesPanelTenantContext`, `InteractsWithTenantOwnedRecords`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `OperationRunLinks`, `ArtifactTruthPresenter`, and each resource's existing `ActionSurfaceDeclaration`
- **Why the existing shared path is sufficient or insufficient**: the repo already has the right ownership and presenter seams. What is insufficient is the lingering tenant-panel registration and `ManagedEnvironment::current()` or `tenant:` route assumption in those seams.
- **Allowed deviation and why**: none. The slice should converge on the existing shared path instead of adding another per-resource route helper.
- **Consistency impact**: environment identity, artifact breadcrumbs, detail routes, related links, and operation drillthroughs must all speak the same workspace-first route language and the same environment context truth.
- **Review focus**: reviewers must verify that touched resources reuse the shared context and link helpers, that no targeted artifact surface still requires the tenant panel, and that no new local route-generation pattern appears.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, link semantics only
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks`, `RelatedNavigationResolver`, and the workspace-first operations surfaces prepared by Spec `280`
- **Delegated start/completion UX behaviors**: artifact-origin drillthrough to workspace-first operations index or detail, explicit environment filter continuity, and environment-safe back-navigation context
- **Local surface-owned behavior that remains**: artifact resources continue to decide when to expose an operation or review drillthrough; they do not own a second operation URL contract
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check
N/A - no shared provider or platform-core identity boundary is changed directly in this slice. Provider-profile extraction remains Spec `281`, and broader copy neutralization remains Spec `286`.
## UI / Surface Guardrail Impact
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Governance register family (`inventory`, `policies`, `policy versions`, `findings`, `finding exceptions`) | yes | Native Filament resources | navigation, related links, environment context, table/detail routes | page, detail, URL-query | no | Reuses current resources and action surfaces; route ownership and admin registration change only |
| Recovery and backup family (`backup schedules`, `backup sets`, `restore runs`) | yes | Native Filament resources | operation links, related links, environment context | page, detail, modal, URL-query | no | Existing high-impact action hierarchy remains intact |
| Evidence, review, and reporting family (`evidence`, `reviews`, `review packs`, `stored reports`) | yes | Native Filament resources plus existing artifact presenters | evidence/report viewers, related-context drillthroughs, operation links | page, detail, URL-query | no | Existing read-only and reporting semantics stay intact |
| Environment-to-artifact drillthroughs and artifact-to-operations links | yes | Native Filament pages plus shared navigation helpers | navigation entry points, operation links, breadcrumb shell | shell, page, URL-query | no | Reuses workspace-first environment and operations contracts rather than adding local redirects |
## Decision-First Surface Role
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Governance register family | Secondary Context Surface | Operator has already chosen the environment and now decides which governed object or exception to inspect | environment-scoped list truth, core status, next inspect action | raw evidence, payloads, or deep diagnostics stay progressive | Secondary because the workspace and environment dashboards remain the initial scope-selection surfaces | Continues existing environment-scoped governance work without a second panel | Removes route and context reconstruction before domain work can start |
| Recovery and backup family | Secondary Context Surface | Operator is already in one environment and needs to inspect or continue recovery-safe work | backup or restore state, safe next action, environment scope | detailed run history, related dependencies, and diagnostics stay secondary | Secondary because the decision to work on that environment already happened upstream | Keeps recovery workflows environment-first and workspace-safe | Removes panel hopping for backup or restore follow-up |
| Evidence, review, and reporting family | Secondary Context Surface | Operator is already in one environment and needs to inspect retained evidence or reporting truth | artifact status, current or historical truth, environment scope | viewer detail, operation drillthroughs, and report specifics remain secondary | Secondary because these are downstream artifact consumers, not new top-level decision queues | Reuses current artifact viewers and presenters under truthful route ownership | Removes hidden tenant-panel dependencies from calm read-only surfaces |
| Environment-to-artifact and artifact-to-operations drillthroughs | Secondary Context Surface | Operator moves from one context into the next without losing environment scope | current environment and target destination only | deeper artifact or operation detail remains on the destination page | Secondary because these links support the already chosen workflow | Keeps navigation aligned to environment work rather than storage structure | Eliminates duplicate route languages and manual scope reconstruction |
## Audience-Aware Disclosure
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Governance register family | operator-MSP, support-platform | environment-scoped register truth and inspect affordance | per-record diagnostics stay on detail views | raw payloads stay in dedicated sections or related viewers | `Open` or existing primary inspect action | raw JSON, low-level identifiers, and support-only detail remain secondary | route shell adds scope once instead of duplicating record summaries |
| Recovery and backup family | operator-MSP, support-platform | current environment scope, current safe action, current lifecycle | run history and dependency detail remain secondary | support-only evidence stays gated or downstream | existing primary safe action or inspect action | raw failure context and support detail stay secondary | scope shell does not repeat run or backup outcome text already shown in the page body |
| Evidence, review, and reporting family | operator-MSP, support-platform, customer-safe read-only where already supported | artifact truth, environment scope, current or historical status | related operation, review, or evidence diagnostics remain secondary | raw evidence or support detail remains gated by existing capabilities | existing inspect or download action where already allowed | raw/support detail stays hidden by default | environment context moves into the shell instead of being restated in each artifact summary |
| Environment-to-artifact and artifact-to-operations drillthroughs | operator-MSP, support-platform | destination context and the one next step | none beyond explicit destination labels | none | `Open` or `View operation` | debug route or context detail stays hidden | link labels state scope once and delegate detail to the destination |
## UI/UX Surface Classification
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Governance register family | List / Table / Resource family | CRUD or registry resources | Open one environment-owned record | preserve each resource's existing inspect model | preserve current contract | preserve current contract | preserve current contract | `/admin/workspaces/{workspace}/environments/{environment}/{domain}` | `/admin/workspaces/{workspace}/environments/{environment}/{domain}/{record}` | workspace plus managed environment | existing domain noun | active workspace and environment scope plus resource-local truth | none |
| Recovery and backup family | List / Table / Resource family | action-bearing operational resources | Inspect or continue one backup or restore flow | preserve each resource's existing inspect model | preserve current contract | preserve current contract | preserve current contract | `/admin/workspaces/{workspace}/environments/{environment}/{domain}` | `/admin/workspaces/{workspace}/environments/{environment}/{domain}/{record}` | workspace plus managed environment | existing domain noun | active workspace and environment scope plus operational state | none |
| Evidence, review, and reporting family | List / Table / Read-only resource family | read-only registry and detail resources | Open one artifact viewer or report | preserve each resource's existing inspect model | preserve current contract | preserve current contract | preserve current contract | `/admin/workspaces/{workspace}/environments/{environment}/{domain}` | `/admin/workspaces/{workspace}/environments/{environment}/{domain}/{record}` | workspace plus managed environment | existing domain noun | active workspace and environment scope plus artifact truth | none |
| Environment-to-artifact and artifact-to-operations drillthroughs | Related Context / Navigation / Deep links | workflow navigation | Open the next relevant domain or operation page | explicit link or row affordance already owned by the source surface | preserve current contract | none beyond source-surface rules | none | source environment page or artifact page | workspace-first environment or operations destination | current workspace and environment | existing domain noun | destination scope and current context | none |
## Operator Surface Contract
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance register family | Environment operator | Decide which governed record or exception to inspect next | Environment-scoped resource family | Which record in this environment needs inspection now? | environment scope, table truth, current record status | record-local diagnostics and related evidence | resource-local state families | preserve current semantics | preserve current primary inspect action | preserve current dangerous actions |
| Recovery and backup family | Environment operator | Decide whether backup or restore work needs follow-up | Environment-scoped operational resource family | Which recovery-safe item or run needs follow-up? | environment scope, lifecycle, and safe next action | deeper run diagnostics or dependency detail | lifecycle, execution state, readiness | preserve current semantics | preserve current primary inspect or continue action | preserve current dangerous actions |
| Evidence, review, and reporting family | Environment operator or reviewer | Decide which retained artifact to open and consume | Environment-scoped read-only resource family | Which artifact represents the truth I need to inspect now? | environment scope, artifact truth, current or historical posture | deeper viewer detail, report family detail, related operations | artifact truth, readiness, lifecycle where already present | preserve current semantics | preserve current inspect or download action | preserve current dangerous actions where already defined |
| Environment-to-artifact and artifact-to-operations drillthroughs | Environment operator | Move safely into the next relevant page without losing scope | Navigation and related-context paths | Am I opening the right destination for this environment? | destination label plus current scope | none | none beyond current scope | none | `Open` or `View operation` | none |
## Proportionality Review
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: environment-owned governance artifacts already persist correctly, but their admin ownership, route language, and context resolution still depend on a temporary tenant-panel model.
- **Existing structure is insufficient because**: the route and registration split repeats across many existing resources and link builders. Keeping those assumptions local would preserve the broken contract after the workspace-first panel cutover.
- **Narrowest correct implementation**: reuse the current resources and shared helpers, move only the surface ownership and link contract, and keep all lifecycle, naming, capability, and taxonomy follow-up work out of scope.
- **Ownership cost**: focused updates across resource registration, context resolution, route generation, and proof coverage for the touched artifact families.
- **Alternative intentionally rejected**: local redirects, per-resource admin fallbacks, or widening the slice into lifecycle, naming, or RBAC work. Those options either preserve drift or inflate scope.
- **Release truth**: current-release truth
### External implementation prerequisite
Spec `280` must already be merged or otherwise present on the implementation branch before `282` runtime work begins. This package is prep-complete only when read as the bounded artifact-surface slice that reuses the `280` workspace-first environment route shell.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, route aliases, tenant-panel shims, dual registration, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: fast-feedback, confidence, browser
- **Why this classification and these lanes are sufficient**: this slice changes resource registration, route ownership, context resolution, authorization boundaries, and drillthrough links across existing Filament resources. Focused feature coverage proves route and entitlement truth; one browser smoke proves the surviving environment-to-artifact flow in the real admin shell.
- **New or expanded test families**: one governance-artifact resource-registration family, one environment-context and authorization family, one deep-link contract family, one legacy-tenant-panel guard family, and one narrow browser smoke
- **Fixture / helper cost impact**: moderate. Proof needs workspace membership, managed-environment membership, representative artifact fixtures, and one real browser path. No new global fixture defaults should be introduced.
- **Heavy-family visibility / justification**: one browser smoke only. No heavy-governance family is justified.
- **Special surface test profile**: standard-native-filament, global-context-shell
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the resource families; one browser smoke is required because the cutover spans route shell plus multiple resource families.
- **Reviewer handoff**: reviewers must confirm Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, touched artifact resources either remain non-globally-searchable or keep a valid view/edit destination, destructive actions touched by the retarget continue to require confirmation plus authorization, no new asset registration appears, and the planned proof commands stay aligned across `spec.md`, `plan.md`, and `quickstart.md`.
- **Budget / baseline / trend impact**: moderate feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec282GovernanceArtifactRetargetingSmokeTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
## Scope Boundaries
### In Scope
- document the verified candidate deviation that governance artifact persistence is already `managed_environment_id` plus `workspace_id` based in repo truth
- register the current environment-owned governance artifact resource families on the surviving admin panel and workspace-first environment routes
- retarget environment context resolution for those artifact families so admin-panel requests no longer depend on the tenant panel or `ManagedEnvironment::current()` as the only source of truth
- retarget related navigation and operation drillthrough links from the touched artifact families to the workspace-first route contract prepared by Spec `280`
- keep existing capability and action-surface semantics intact while moving route ownership and registration into the workspace-first shell
- keep `TenantReview`, `StoredReport`, `ReviewPack`, and evidence viewers environment-aware on the admin panel without widening into copy or lifecycle rewrites
### Non-Goals
- any new schema move for `managed_environment_id`, `workspace_id`, or `tenant_id`
- lifecycle, retention, hold, delete-request, or export governance contract work from Spec `267`
- broader stored-report browse and presentation work beyond the already prepared Spec `277`
- provider capability registry, provider-neutral artifact taxonomy, or provider-boundary extraction work reserved for Specs `281`, `283`, and `284`
- workspace-first RBAC redesign or environment-access model changes reserved for Spec `285`
- broad tenant-to-managed-environment copy, naming, or localization changes reserved for Spec `286`
- cutover quality-gate hardening beyond the feature-local proof for this slice, reserved for Spec `287`
- compatibility routes, redirects, local route shims, or hidden tenant-panel fallbacks
## Assumptions
- Spec `279` already completed the model-level `managed_environment_id` cutover and remains historical prerequisite context only.
- Spec `280` prepares the workspace-first route shell and remains a blocking runtime prerequisite; `282` must reuse that contract rather than redefine it.
- Spec `281` remains the provider-boundary package; `282` should inherit its provider-neutral seams when implementation begins, not absorb them during prep.
- The existing artifact presenters, resource classes, and context helpers are the correct extension points for this slice.
- Most touched governance artifact resources already remain non-globally-searchable or have existing view pages; `282` should not widen global search.
## Risks
- Some resources may appear superficially admin-safe because they already use `ResolvesPanelTenantContext`, while still hiding from admin navigation or emitting stale `tenant:` URLs from action closures.
- Read-only artifact surfaces such as review packs and stored reports may still rely on `ManagedEnvironment::current()` in subtle fallbacks that break after the tenant panel is removed.
- Recovery-safe surfaces may carry many action URLs and related-resource links; if those links are not retargeted through shared helpers, the slice could leave a half-migrated admin experience.
- The slice could sprawl into lifecycle semantics, stored-report productization, copy-neutralization, or RBAC redesign if the boundaries against Specs `267`, `277`, and `283` through `287` are not kept explicit.
## Candidate Selection Gate Summary
- **Selected candidate**: `282 - Governance Artifact Retargeting to ManagedEnvironment`
- **Source locations**:
- `docs/product/spec-candidates.md` under the reserved workspace-first managed-environment cutover pack
- `docs/product/roadmap.md` under the same cutover ordering
- **Why selected now**: the user explicitly directed work toward slot `282`, and repo truth confirms it is the next reserved cutover slice that still lacks a spec directory. After `279` moved persistence and `280` and `281` prepare the shell and provider seams, the real remaining blocker is the governance artifact surface layer.
- **Why close alternatives were deferred**:
- `283` belongs to capability-registry follow-through and would widen the slice beyond route and context retargeting
- `284` belongs to provider-neutral artifact taxonomy and would add new cross-domain semantics before the current surfaces finish moving
- `285` belongs to RBAC redesign and capability-model follow-through, not current route ownership
- `286` belongs to copy and localization neutralization, not current artifact registration and link truth
- `287` belongs to cutover-wide no-legacy enforcement after the current reserved slices exist
- `267` and `277` are already prepared packages for artifact lifecycle and stored-report surface work and must remain separate
- **Smallest viable implementation slice**: move the existing environment-owned governance artifact resource families onto the workspace-first admin runtime, unify environment-context resolution there, and retarget their related links and operation drillthroughs without changing persistence or naming.
- **Documented deviations from raw candidate wording**:
- the raw candidate still sounds like a model or schema retarget of artifact tables, but repo truth already completed that move through `managed_environment_id` plus `workspace_id`
- the raw candidate mentions renaming review concepts and removing remaining `/admin/t` links broadly; this package narrows that to the touched governance artifact surface families and explicitly defers broader copy or no-legacy work to Specs `286` and `287`
- the raw candidate's `operation_runs` retarget is already satisfied at the persistence layer by Spec `279`; `282` only retargets artifact-origin links into the existing workspace-first operations routes prepared by Spec `280`
- the raw candidate's `backup items` concern remains nested under existing backup-set or restore-run surfaces and does not create a separate top-level route family in `282`
## Completed-Spec Guardrail Result
- `specs/279-workspace-managed-environment-core/` already exists with implementation-close-out history and remains historical prerequisite context only
- `specs/280-workspace-tenancy-environment-routing/` already exists with `Status: Ready` and remains adjacent prepared context only
- `specs/281-provider-connection-scope/` already exists with `Status: Ready` and remains adjacent prepared context only
- `specs/267-artifact-lifecycle-retention/` already exists with `Status: Ready for implementation` and remains a separate lifecycle package
- `specs/277-stored-reports-surface/` already exists with `Status: Ready for implementation` and remains a separate stored-report package
- the target package `specs/282-governance-artifact-retargeting/` did not exist before this prep run and is the sole new package created here
## Deferred Adjacent Candidates
- `283 - Provider Capability Registry v1`
- `284 - Provider-neutral Artifact Source Taxonomy v1`
- `285 - Workspace-first RBAC & Environment Access Scoping`
- `286 - UI Copy, IA & Localization Neutralization`
- `287 - Cutover Quality Gates & No-Legacy Enforcement`
## User Scenarios & Testing
### User Story 1 - Open governance resources for one environment inside the admin panel (Priority: P1)
As an operator, I want the core governance resource families for one managed environment to open under the workspace-first admin routes so I can inspect records without crossing into the removed tenant panel.
**Why this priority**: this is the central artifact-retargeting outcome. If these resources still hide from the admin panel or rely on `panel: 'tenant'`, the reserved cutover slot is not complete.
**Independent Test**: select a workspace and managed environment, then open representative governance resources such as findings, policies, inventory, or backups and confirm they resolve under the workspace-first environment route family.
**Acceptance Scenarios**:
1. **Given** an operator is in one workspace and managed environment, **When** they open a touched governance register, **Then** the destination stays on `/admin/workspaces/{workspace}/environments/{environment}/...` and lists only that environment's records.
2. **Given** an operator requests a governance resource under a mismatched workspace or environment pair, **When** the route resolves, **Then** the app returns `404` and reveals no artifact data.
---
### User Story 2 - Read retained evidence and reporting artifacts with the same environment context (Priority: P1)
As an operator, I want evidence, reviews, review packs, and stored reports to stay environment-aware on the surviving admin panel so I can consume retained artifact truth without depending on `ManagedEnvironment::current()` from the tenant panel.
**Why this priority**: these resource families are the calmest artifact consumers and are the easiest place for hidden panel-context fallbacks to survive.
**Independent Test**: open evidence, one review-pack or review detail, and one stored-report detail from the current environment and confirm each surface renders under the workspace-first environment shell with the correct environment scope.
**Acceptance Scenarios**:
1. **Given** an operator opens an evidence or reporting artifact from one managed environment, **When** the page renders, **Then** the surface resolves the current environment through the admin-panel route context and not only through the old tenant panel.
2. **Given** an artifact row belongs to a different managed environment than the current route context, **When** the operator tries to open it directly, **Then** access is denied as `404`.
---
### User Story 3 - Follow artifact drillthroughs into operations without stale tenant-panel links (Priority: P2)
As an operator, I want artifact-origin links such as restore-run follow-up or review-pack operation drillthroughs to open the workspace-first operations surfaces so I do not lose environment scope while tracing what produced the artifact.
**Why this priority**: artifact truth is only trustworthy if its related operation and navigation links stay on the same workspace-first contract.
**Independent Test**: open a touched artifact surface with an operation drillthrough, follow `View operation`, and confirm the destination is the workspace-first operations route with environment-safe context.
**Acceptance Scenarios**:
1. **Given** an artifact surface exposes a related operation, **When** the operator opens that drillthrough, **Then** the destination is the workspace-first operations collection or detail route rather than a tenant-panel URL.
2. **Given** the related operation belongs to the current workspace but not the current environment, **When** the destination resolves, **Then** the link either narrows safely to the correct environment context or denies access without leaking scope.
---
### User Story 4 - Keep authorization, search, and no-legacy expectations truthful (Priority: P3)
As an operator, I want direct URLs and any touched searchable surfaces to stay truthful after the retarget so the admin panel does not leak stale tenant-panel hints or broken destinations.
**Why this priority**: route and registration work often leaves behind silent search drift or compatibility fallbacks unless the spec pins them down.
**Independent Test**: open touched direct URLs and any touched searchable destinations, then confirm authorized routes resolve under the workspace-first environment contract and no touched artifact surface still depends on the tenant panel.
**Acceptance Scenarios**:
1. **Given** a touched artifact surface remains reachable from search or related navigation, **When** the operator opens it, **Then** the destination is a valid workspace-first environment route.
2. **Given** a touched artifact surface cannot keep a truthful destination under the new route contract, **When** the cutover ships, **Then** that surface is hidden from search or related entry points in the same slice.
### Edge Cases
- A request that carries a remembered or explicit environment from another workspace must clear or deny that context before any touched artifact surface loads.
- A touched resource that already uses `ResolvesPanelTenantContext` may still fail if it also hides from admin registration or emits `tenant:` URLs from actions or related links; both sides of the contract must move together.
- Read-only artifact surfaces that fall back to `ManagedEnvironment::current()` must continue to work on the admin panel when the tenant panel is gone.
- Restore and backup surfaces with many action closures must not leave a single stale tenant-panel URL behind when the rest of the resource is retargeted.
- If a touched resource remains globally searchable or reachable from related navigation, its destination must stay valid under the workspace-first route family or be disabled in the same slice.
## Requirements
**Constitution alignment (required):** This slice changes route ownership, environment-context resolution, navigation, related links, and admin registration for existing governance artifact surfaces. It does not introduce new persistence, new Microsoft Graph calls, new long-running workflow types, or a new artifact lifecycle contract.
**Constitution alignment (XCUT-001 / UI-FIL-001):** The slice must reuse the existing resource classes, artifact presenters, navigation helpers, and workspace-first environment route shell. It must not create a second artifact workbench, local route helper framework, or ad hoc UI system.
**Constitution alignment (RBAC-UX):** Workspace membership remains the first boundary and managed-environment access remains the second boundary. Non-members remain `404`, in-scope capability denials remain `403`, and touched destructive or high-impact actions retain existing confirmation plus server authorization.
**Constitution alignment (TEST-GOV-001):** Proof stays bounded to focused feature coverage, one narrow browser smoke, and a legacy-tenant-panel guard test. No heavy-governance family is introduced.
### Functional Requirements
- **FR-001**: `workspace_id` plus `managed_environment_id` remain the canonical ownership anchors for the touched governance artifact models, and the implementation MUST NOT reintroduce `tenant_id` aliases or dual relations.
- **FR-002**: The current environment-owned governance artifact resources that are still tenant-panel-bound or admin-hidden MUST register and resolve under the workspace-first admin panel and environment route family.
- **FR-003**: The touched governance artifact surface families include, at minimum, the current resources for inventory, policies, policy versions, backup schedules, backup sets, restore runs, findings, finding exceptions, evidence snapshots, tenant reviews, review packs, and stored reports.
- **FR-004**: The touched resources MUST scope collection and detail queries to the current workspace and managed environment through the surviving admin-panel context rather than through tenant-panel ownership.
- **FR-005**: `ResolvesPanelTenantContext` and any cooperating helper seams MUST resolve the current managed environment on the admin panel through the workspace-first route or current operate-hub context rather than relying on the tenant panel as the only source of truth.
- **FR-006**: Resource families that currently hide from admin navigation through `shouldRegisterNavigation()` or equivalent tenant-panel checks MUST stop doing so once their workspace-first environment route contract is available.
- **FR-007**: Touched artifact resources and pages MUST emit workspace-first environment URLs from list actions, header actions, related links, and record URLs, and MUST NOT emit `tenant:` or `panel: 'tenant'` destinations.
- **FR-008**: Evidence, reviews, review packs, and stored reports MUST remain read-only or action-equivalent to current repo truth; `282` retargets route and context ownership, not lifecycle or publish semantics.
- **FR-009**: Restore, backup, policy, and finding surfaces MUST preserve their current action hierarchy and confirmation behavior while changing only route ownership, context resolution, and related drillthrough URLs.
- **FR-010**: Artifact-origin operation drillthroughs MUST use the workspace-first operations routes and preserve managed-environment context where the current workflow already expects it.
- **FR-011**: Touched artifact surfaces MUST present breadcrumb and context-shell ordering consistent with the workspace-first environment contract from Spec `280`.
- **FR-012**: `TenantReview` naming, review-language neutralization, and other broader copy changes remain out of scope for `282` and reserved for later specs.
- **FR-013**: The slice MUST NOT absorb lifecycle work from Spec `267`, stored-report product-surface expansion from Spec `277`, or reserved follow-up work from Specs `283` through `287`.
- **FR-014**: Any touched resource that remains searchable or reachable from shared navigation MUST keep a truthful destination under the workspace-first route contract or be disabled in the same slice.
### Authorization and Safety Requirements
- **AR-001**: Workspace membership MUST remain the first access boundary for all touched artifact routes.
- **AR-002**: Managed-environment entitlement MUST remain the second access boundary for touched artifact routes and drillthroughs.
- **AR-003**: A mismatched workspace and managed environment pair MUST resolve as `404`, even if the actor can access one side individually.
- **AR-004**: Explicit hostile environment hints or record URLs outside the current workspace or environment MUST resolve as `404`, not as widened scope.
- **AR-005**: Existing destructive or high-impact actions on touched resources MUST stay server-authorized and continue to require confirmation where current repo truth already requires it.
### Non-Functional Requirements
- **NFR-001**: Filament remains v5 on Livewire v4.
- **NFR-002**: Provider registration remains in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
- **NFR-003**: Existing admin theme and asset strategy remain unchanged, and no new asset registration or deploy step is introduced.
- **NFR-004**: Touched artifact resources either remain non-globally-searchable or keep a valid view or edit destination under the new route contract.
- **NFR-005**: The slice must remain reviewable as one bounded artifact-surface package and must not silently absorb lifecycle, provider, RBAC, copy, or cutover-quality work from adjacent specs.
## UI Action Matrix
| Surface | Location | Header Actions | Inspect Affordance (List or Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create or Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance register family | touched governance resources under `/admin/workspaces/{workspace}/environments/{environment}/{domain}` | preserve current resource-local header actions | preserve each resource's existing inspect model | preserve current contract | preserve current contract | preserve current contract | preserve current contract | preserve current contract | preserve current behavior | `282` changes route ownership and registration only |
| Recovery and backup family | backup and restore resources under the same route shell | preserve current resource-local header actions | preserve each resource's existing inspect model | preserve current contract | preserve current contract | preserve current contract | preserve current contract | preserve current contract | preserve current behavior | confirmation and safety flow remain unchanged |
| Evidence, review, and reporting family | evidence, reviews, review packs, stored reports | preserve current resource-local header actions | preserve each resource's existing inspect model | preserve current contract | preserve current contract | preserve current contract | preserve current contract | `N/A` where read-only already applies | preserve current behavior | read-only and download semantics stay unchanged |
All other touched related-context links keep their existing local action contracts. This slice changes only route ownership, admin registration, environment-context resolution, and deep-link truth.
### Key Entities
- **Workspace Context**: the first isolation boundary and current admin-panel tenant context.
- **Managed Environment Context**: the nested environment scope that owns the touched governance artifact records.
- **Governance Artifact Record**: an existing environment-owned record such as `Finding`, `Policy`, `BackupSet`, `EvidenceSnapshot`, `ReviewPack`, `TenantReview`, or `StoredReport` that already persists `managed_environment_id` plus `workspace_id`.
- **Artifact Route Context**: the workspace-first route ownership for those records under `/admin/workspaces/{workspace}/environments/{environment}/...`.
- **Artifact Operation Drillthrough**: the related link from one touched artifact surface into the workspace-first operations routes.
## Success Criteria
### Measurable Outcomes
- **SC-001**: Representative governance artifact resources open under the workspace-first environment route family with no operator-visible dependency on `/admin/t` on touched surfaces.
- **SC-002**: Touched evidence, review, and reporting resources resolve the current environment correctly on the admin panel without relying on tenant-panel-only context.
- **SC-003**: Artifact-origin operation drillthroughs open the workspace-first operations routes with truthful environment context.
- **SC-004**: Wrong-workspace or wrong-environment direct access to touched artifact routes reveals no data and resolves as `404`.

View File

@ -0,0 +1,222 @@
---
description: "Task list for Governance Artifact Retargeting to ManagedEnvironment"
---
# Tasks: Governance Artifact Retargeting to ManagedEnvironment
**Input**: Design documents from `specs/282-governance-artifact-retargeting/`
**Prerequisites**: `specs/282-governance-artifact-retargeting/spec.md`, `specs/282-governance-artifact-retargeting/plan.md`, `specs/282-governance-artifact-retargeting/checklists/requirements.md`, `specs/282-governance-artifact-retargeting/research.md`, `specs/282-governance-artifact-retargeting/data-model.md`, `specs/282-governance-artifact-retargeting/quickstart.md`, `specs/282-governance-artifact-retargeting/contracts/governance-artifact-retargeting.logical.openapi.yaml`
**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php`, `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php`, `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php`, `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php`, and `apps/platform/tests/Browser/Spec282GovernanceArtifactRetargetingSmokeTest.php`.
**Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` for all artifact-origin operation drillthroughs.
**RBAC**: Workspace membership remains the first `404` boundary, managed-environment entitlement remains the second `404` boundary, and in-scope capability denials stay `403`.
**Shared Pattern Reuse**: Reuse `ResolvesPanelTenantContext`, `InteractsWithTenantOwnedRecords`, `OperateHubShell`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperationRunLinks`, and current artifact presenters. Do not add local route helper frameworks or compatibility shims.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. Touched searchable resources must keep truthful destinations or remain disabled. Existing destructive or high-impact actions keep `->requiresConfirmation()` plus current server authorization. Asset strategy stays unchanged.
**Compatibility Posture**: Reject schema changes, lifecycle rewrites, stored-report productization drift, provider/taxonomy/RBAC/copy spillover, tenant-panel fallbacks, and route aliases. Keep Specs `267`, `277`, and `283` through `287` deferred.
**External Prerequisite**: Spec `280` workspace-first environment route shell must already be merged or otherwise present on the implementation branch before any runtime or test task starts.
**Organization**: Tasks are grouped by user story so admin-panel registration, read-only artifact context, operation drillthroughs, and no-legacy guardrails remain independently testable.
**Review Outcome**: `blocked-by-prerequisite`
**Workflow Outcome**: `keep`
**Test-governance Outcome**: `keep`
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane.
- [x] New or changed tests stay in the smallest honest families under `apps/platform/tests/Feature/Filament/GovernanceArtifacts/` plus one browser smoke file only.
- [x] Workspace and managed-environment fixtures remain explicit; no tenant-panel compatibility fixtures or hidden context defaults become shared setup.
- [x] Planned validation commands match `spec.md`, `plan.md`, and `quickstart.md` exactly.
- [x] `standard-native-filament` and `global-context-shell` expectations stay explicit for touched surfaces.
- [x] Any attempt to absorb Specs `267`, `277`, or `283` through `287` resolves as `split` or `reject-or-split`, not hidden scope.
## Phase 0: External Gate
**Purpose**: Confirm the runtime prerequisite from Spec `280` is available before implementation begins.
- [x] T000 Confirm Spec `280` is already merged or otherwise present on the implementation branch before any runtime or test task begins.
---
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded artifact-surface inventory, proof files, and adjacent-spec boundaries before runtime edits begin.
- [x] T001 Review `specs/282-governance-artifact-retargeting/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/governance-artifact-retargeting.logical.openapi.yaml` together so implementation stays on Spec 282 only.
- [x] T002 [P] Confirm the current governance-register inventory and admin-hide guards in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `PolicyResource.php`, `PolicyVersionResource.php`, `FindingResource.php`, and `FindingExceptionResource.php`.
- [x] T003 [P] Confirm the current recovery and backup inventory and tenant-panel URLs in `apps/platform/app/Filament/Resources/BackupScheduleResource.php`, `BackupSetResource.php`, and `RestoreRunResource.php`.
- [x] T004 [P] Confirm the current read-only artifact fallbacks and mixed environment-context handling in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `TenantReviewResource.php`, `ReviewPackResource.php`, and `StoredReportResource.php`.
- [x] T005 [P] Confirm the shared context and deep-link seams in `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Filament/Concerns/InteractsWithTenantOwnedRecords.php`, `apps/platform/app/Support/OperateHub/OperateHubShell.php`, `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/OperationRunLinks.php`.
- [x] T006 [P] Confirm deferred boundaries in `specs/267-artifact-lifecycle-retention/spec.md`, `specs/277-stored-reports-surface/spec.md`, `specs/280-workspace-tenancy-environment-routing/spec.md`, `specs/281-provider-connection-scope/spec.md`, and `specs/282-governance-artifact-retargeting/checklists/requirements.md`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the proving suite and the shared admin-panel environment-context contract that all touched artifact families depend on.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php` for admin-panel registration and workspace-first route ownership of the touched artifact resource families.
- [x] T008 [P] Add failing coverage in `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php` for workspace membership, managed-environment entitlement, mismatched workspace and environment `404`, and admin-panel environment resolution without tenant-panel ownership. Mismatched workspace/environment `404` proof landed in `GovernanceArtifactAdminPanelRegistrationTest.php` because the live HTTP route-ownership assertions and deny-as-404 contract share the same canonical URL surface.
- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php` for artifact-origin related links and operation drillthroughs using workspace-first environment and operations routes.
- [x] T010 [P] Add failing guard coverage in `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php` for touched artifact families that still hide from admin, emit `tenant:` or `panel: 'tenant'` URLs, or depend on `/admin/t` route language.
- [x] T011 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec282GovernanceArtifactRetargetingSmokeTest.php` for one workspace-first environment artifact flow covering one governance register and one read-only artifact surface.
- [x] T012 Update `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Filament/Concerns/InteractsWithTenantOwnedRecords.php`, and any cooperating admin-shell helpers so the workspace-first environment route contract is the authoritative context source for the touched artifact families.
**Checkpoint**: The proving files exist, the admin-panel environment-context helper contract is ready, and user-story work can proceed on top of one shared context model.
---
## Phase 3: User Story 1 - Open governance resources for one environment inside the admin panel (Priority: P1)
**Goal**: The core governance resource families open inside the workspace-first admin runtime with no tenant-panel registration drift.
**Independent Test**: Open representative governance registers such as findings, policies, or inventory for one managed environment and confirm they resolve under workspace-first environment routes.
### Tests for User Story 1
- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php` to prove touched governance-register resources stop hiding from the admin panel and open only workspace-first environment routes.
- [x] T014 [P] [US1] Extend `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php` to prove collection and detail access deny mismatched workspace or environment scope as `404` for the touched governance-register families. The live `404` route proof landed in `GovernanceArtifactAdminPanelRegistrationTest.php` because it exercises the canonical HTTP resource URLs directly.
### Implementation for User Story 1
- [x] T015 [US1] Retarget admin registration and route ownership in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `PolicyResource.php`, `PolicyVersionResource.php`, `FindingResource.php`, and `FindingExceptionResource.php` so they resolve inside the workspace-first admin runtime.
- [x] T016 [US1] Align collection and detail route declarations, breadcrumb order, and context-shell labels across the touched governance-register resources so their list and detail surfaces speak one workspace-first environment contract. Shared `WorkspaceScopedTenantRoutes` plus the surviving default Filament page routes now give the touched governance-register resources one workspace-first collection/detail contract with no resource-local breadcrumb or route divergence left in the 282 slice.
- [x] T017 [US1] Update record URLs, related links, and route parameters in the touched governance-register families so no source surface emits `tenant:` or `panel: 'tenant'` destinations. The touched governance-register resources now resolve source URLs through `static::getUrl(...)`, `RelatedNavigationResolver`, and the 282 legacy-tenant guard with no remaining tenant-panel route language in those resource files.
**Checkpoint**: Governance registers and their detail surfaces live on the workspace-first admin runtime with truthful scope and no tenant-panel route language.
---
## Phase 4: User Story 2 - Read retained evidence and reporting artifacts with the same environment context (Priority: P1)
**Goal**: Evidence, reviews, review packs, and stored reports stay environment-aware on the admin panel without relying on tenant-panel-only fallbacks.
**Independent Test**: Open evidence, a review-related artifact, and a stored report from one environment and confirm each surface resolves the correct environment in the workspace-first admin shell.
### Tests for User Story 2
- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php` to cover `EvidenceSnapshotResource`, `TenantReviewResource`, `ReviewPackResource`, and `StoredReportResource` under the admin-panel environment contract.
- [x] T019 [P] [US2] Extend `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php` to prove the touched read-only artifact surfaces no longer require tenant-panel-only context.
### Implementation for User Story 2
- [x] T020 [US2] Retarget `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `TenantReviewResource.php`, `ReviewPackResource.php`, and `StoredReportResource.php` so environment resolution on the admin panel does not depend on tenant-panel-only fallbacks.
- [x] T021 [US2] Preserve existing read-only, download, and presenter semantics on those artifact surfaces while moving their route ownership and related links to the workspace-first admin runtime.
**Checkpoint**: Read-only artifact viewers and reporting surfaces remain calm and truthful on the admin panel with correct environment context.
---
## Phase 5: User Story 3 - Follow artifact drillthroughs into operations without stale tenant-panel links (Priority: P2)
**Goal**: Artifact-origin drillthroughs and operational resource actions keep truthful workspace-first navigation into operations and adjacent artifact surfaces.
**Independent Test**: Open one touched artifact or restore surface, follow its related operation or related-resource link, and confirm the destination stays inside the workspace-first contract.
### Tests for User Story 3
- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php` to prove touched artifact families use workspace-first environment and operations URLs for related-resource and `View operation` drillthroughs.
### Implementation for User Story 3
- [x] T023 [US3] Retarget related navigation and operation drillthrough calls in `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, `apps/platform/app/Support/OperationRunLinks.php`, and touched artifact resources so they use the workspace-first route contract only.
- [x] T024 [US3] Retarget action URLs and related links in `apps/platform/app/Filament/Resources/BackupScheduleResource.php`, `BackupSetResource.php`, and `RestoreRunResource.php` so recovery-safe surfaces preserve their existing action hierarchy while dropping tenant-panel route assumptions. `BackupScheduleResource` now routes operation follow-up through `OperationRunLinks`, while `BackupSetResource` and `RestoreRunResource` route related drilldowns through `RelatedNavigationResolver` and `OperationRunLinks` only, preserving the existing grouped action hierarchy without tenant-panel assumptions.
**Checkpoint**: Artifact-origin navigation and operation links stay truthful, environment-safe, and tenant-panel-free.
---
## Phase 6: User Story 4 - Keep authorization, search, and no-legacy expectations truthful (Priority: P3)
**Goal**: Direct URLs, search exposure, and guardrails remain truthful after the artifact-surface retarget.
**Independent Test**: Open touched direct URLs and any touched searchable or shared-navigation destinations, then confirm truthful workspace-first routing and no surviving tenant-panel dependency.
### Tests for User Story 4
- [x] T025 [P] [US4] Extend `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php` and `GovernanceArtifactEnvironmentContextTest.php` to cover any touched searchable destinations or explicitly prove they remain disabled.
- [x] T026 [P] [US4] Extend `apps/platform/tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php` to prove touched artifact families no longer hide from admin registration or emit tenant-panel routes.
### Implementation for User Story 4
- [x] T027 [US4] Keep touched searchable artifact surfaces truthful by preserving valid view or edit destinations or disabling search in the same slice.
- [x] T028 [US4] Remove remaining touched tenant-panel fallbacks, admin-hide guards, and stale route language from the artifact families and shared helper seams without widening into global cutover work reserved for Spec `287`.
**Checkpoint**: Direct URLs, shared-navigation entries, and touched searchable surfaces remain truthful with no surviving tenant-panel dependency in the 282 slice.
---
## Phase 7: Polish & Cross-Cutting Validation
**Purpose**: Run the exact bounded proof set, perform the final Filament review, and close the slice without reopening adjacent specs.
- [x] T029 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactAdminPanelRegistrationTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactEnvironmentContextTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactDeepLinkContractTest.php tests/Feature/Filament/GovernanceArtifacts/GovernanceArtifactLegacyTenantPanelGuardTest.php)`.
- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec282GovernanceArtifactRetargetingSmokeTest.php)`.
- [x] T031 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`.
- [x] T032 [P] Review touched resource files and helper seams to confirm Filament v5 and Livewire v4 compliance, provider registration staying in `apps/platform/bootstrap/providers.php`, truthful global-search posture, preserved destructive-action confirmation plus authorization, and unchanged asset strategy.
- [x] T033 [P] Record the implementation close-out in `specs/282-governance-artifact-retargeting/checklists/requirements.md` or the active PR notes confirming the slice stayed on surface ownership and did not absorb Specs `267`, `277`, or `283` through `287`.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 0 (External Gate)**: no dependencies; complete before implementation starts.
- **Phase 1 (Setup)**: depends on Phase 0.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all story work.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the route and registration contract for the core governance registers.
- **Phase 4 (US2)**: depends on Phase 2 and should follow once the shared environment-context helper contract is stable.
- **Phase 5 (US3)**: depends on US1 and US2 so the shared destinations are already truthful before drillthrough links converge.
- **Phase 6 (US4)**: depends on US1 through US3 so guardrails prove the final route contract rather than an intermediate state.
- **Phase 7 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and is the first required increment.
- **US2 (P1)**: independently testable after Phase 2 and should ship with or immediately after US1 because read-only artifacts share the same environment-context seam.
- **US3 (P2)**: independently testable after US1 and US2 because it relies on their route contract.
- **US4 (P3)**: independently testable after US1 through US3 and closes truthfulness and no-legacy expectations.
### Within Each User Story
- Write or extend the listed Pest coverage first and make it fail for the intended gap.
- Apply the smallest shared-seam changes needed to satisfy the story without reopening deferred specs.
- Re-run the narrowest relevant validation command for that story before moving to the next story.
## Parallel Execution Examples
- **Setup**: T002 through T006 can run in parallel once T000 and T001 set the bounded scope.
- **Foundational**: T007 through T011 can run in parallel before T012 converges the shared context helper contract.
- **US1**: T013 and T014 can run in parallel; T015 through T017 should merge serially around the touched register resources.
- **US2**: T018 and T019 can run in parallel; T020 and T021 should merge serially around the read-only artifact surfaces.
- **US3**: T022 can run in parallel with T023, then T024 follows once the shared drillthrough contract is stable.
- **US4**: T025 and T026 can run in parallel; T027 and T028 follow once the final route contract is stable.
- **Polish**: T029 through T032 can run in parallel after implementation is complete; T033 closes out last.
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2**. Land the core admin-panel artifact surface ownership first so the surviving workspace-first runtime can actually host the existing environment-owned governance artifacts.
### Incremental Delivery
1. Complete Phase 0, Phase 1, and Phase 2.
2. Deliver US1 so the core governance registers stop depending on the tenant panel.
3. Deliver US2 so read-only artifacts stop depending on tenant-panel-only context.
4. Deliver US3 so artifact drillthroughs and operations links become fully truthful.
5. Deliver US4 to close search and no-legacy truthfulness.
6. Finish with the exact validation commands and the final Filament review in Phase 7.
### Team Strategy
1. Parallelize the failing test work first.
2. Serialize merges around shared helpers and the most cross-cutting resource families.
3. Reject any branch that introduces schema, lifecycle, reporting, provider, RBAC, or copy scope while touching the artifact surfaces.
## Deferred Follow-Ups / Non-Goals
- Spec `267` artifact lifecycle and retention contract work
- Spec `277` stored-reports productization beyond route ownership
- Specs `283` through `287`