diff --git a/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php b/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php
index 9806495a..66e55527 100644
--- a/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php
+++ b/apps/platform/app/Filament/Clusters/Monitoring/AlertsCluster.php
@@ -7,6 +7,7 @@
use App\Models\Workspace;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\Navigation\WorkspaceHubFilterStateResetter;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Clusters\Cluster;
@@ -29,6 +30,16 @@ public static function shouldRegisterNavigation(): bool
return Filament::getCurrentPanel()?->getId() === 'admin';
}
+ public static function getNavigationGroup(): string
+ {
+ return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring'));
+ }
+
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(parent::getNavigationUrl());
+ }
+
public function mount(): void
{
app(WorkspaceHubFilterStateResetter::class)->neutralizeEnvironmentLikeQueryState(request());
diff --git a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
index 46541104..348be06e 100644
--- a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
+++ b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
@@ -20,6 +20,7 @@
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@@ -103,6 +104,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
}
+ public static function getNavigationGroup(): string
+ {
+ return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.governance'));
+ }
+
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
+ }
+
public static function canAccess(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
diff --git a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php
index d5d992d4..8c573115 100644
--- a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php
+++ b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php
@@ -15,6 +15,7 @@
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@@ -104,6 +105,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
}
+ public static function getNavigationGroup(): string
+ {
+ return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.governance'));
+ }
+
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
+ }
+
public function mount(): void
{
$this->authorizeWorkspaceMembership();
diff --git a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
index b9e38492..03b99080 100644
--- a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
+++ b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
@@ -23,6 +23,7 @@
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
@@ -142,6 +143,16 @@ class FindingExceptionsQueue extends Page implements HasTable
*/
private ?array $authorizedTenants = null;
+ public static function getNavigationGroup(): string
+ {
+ return WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring'));
+ }
+
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
+ }
+
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::QueueReview)
diff --git a/apps/platform/app/Filament/Pages/Monitoring/Operations.php b/apps/platform/app/Filament/Pages/Monitoring/Operations.php
index 43ed416c..01960cdd 100644
--- a/apps/platform/app/Filament/Pages/Monitoring/Operations.php
+++ b/apps/platform/app/Filament/Pages/Monitoring/Operations.php
@@ -226,17 +226,19 @@ protected function getHeaderActions(): array
$operateHubShell = app(OperateHubShell::class);
$navigationContext = $this->navigationContext();
- $actions = [
- Action::make('operate_hub_scope_operations')
- ->label($operateHubShell->scopeLabel(request()))
- ->color('gray')
- ->disabled(),
- ];
-
$activeEnvironment = $this->currentTenantFilterId() === null
? $operateHubShell->activeEntitledTenant(request())
: null;
+ $actions = [];
+
+ if ($activeEnvironment instanceof ManagedEnvironment) {
+ $actions[] = Action::make('operate_hub_scope_operations')
+ ->label($operateHubShell->scopeLabel(request()))
+ ->color('gray')
+ ->disabled();
+ }
+
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('operate_hub_back_to_origin_operations')
->label($navigationContext->backLinkLabel)
@@ -738,6 +740,17 @@ private function applyRequestedDashboardPrefilter(): void
}
}
+ $requestedOperationType = request()->query('operation_type');
+
+ if (is_string($requestedOperationType) && trim($requestedOperationType) !== '') {
+ $canonicalOperationType = OperationCatalog::canonicalCode($requestedOperationType);
+
+ if (OperationCatalog::rawValuesForCanonical($canonicalOperationType) !== []) {
+ $this->tableFilters['type']['value'] = $canonicalOperationType;
+ $this->tableDeferredFilters['type']['value'] = $canonicalOperationType;
+ }
+ }
+
$requestedProblemClass = request()->query('problemClass');
if (in_array($requestedProblemClass, self::problemClassTabs(), true)) {
diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
index 4a45a2b2..f8079464 100644
--- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
+++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
@@ -110,12 +110,14 @@ protected function getHeaderActions(): array
$activeEnvironment = $operateHubShell->activeEntitledTenant(request());
$runTenantId = isset($this->run) ? (int) ($this->run->managed_environment_id ?? 0) : 0;
- $actions = [
- Action::make('operate_hub_scope_run_detail')
+ $actions = [];
+
+ if ($activeEnvironment instanceof ManagedEnvironment) {
+ $actions[] = Action::make('operate_hub_scope_run_detail')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
- ->disabled(),
- ];
+ ->disabled();
+ }
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php
index 77376d52..87bfd2d3 100644
--- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php
+++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php
@@ -27,6 +27,7 @@
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\OperationRunLinks;
use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@@ -99,7 +100,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public static function getNavigationGroup(): string
{
- return __('localization.review.reporting');
+ return WorkspaceHubNavigation::workspaceWideGroup(__('localization.review.reporting'));
}
public static function getNavigationLabel(): string
@@ -107,6 +108,11 @@ public static function getNavigationLabel(): string
return __('localization.review.customer_reviews');
}
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
+ }
+
public function getTitle(): string
{
return __('localization.review.customer_review_workspace');
diff --git a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php
index aba39a3e..ab97139f 100644
--- a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php
+++ b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php
@@ -22,6 +22,7 @@
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@@ -79,6 +80,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
}
+ public static function getNavigationGroup(): string
+ {
+ return WorkspaceHubNavigation::workspaceWideGroup(__('localization.review.reporting'));
+ }
+
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(static::getUrl(panel: 'admin'));
+ }
+
public function mount(): void
{
$this->authorizePageAccess();
diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedEnvironmentsLanding.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedEnvironmentsLanding.php
index 19f8190c..4b03e097 100644
--- a/apps/platform/app/Filament/Pages/Workspaces/ManagedEnvironmentsLanding.php
+++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedEnvironmentsLanding.php
@@ -4,7 +4,6 @@
namespace App\Filament\Pages\Workspaces;
-use App\Filament\Pages\ChooseEnvironment;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
@@ -90,11 +89,6 @@ public function getTenants(): Collection
->values();
}
- public function goToChooseEnvironment(): void
- {
- $this->redirect(route('admin.workspace.managed-environments.index', ['workspace' => $this->workspace]));
- }
-
public function openTenant(int $tenantId): void
{
$user = auth()->user();
diff --git a/apps/platform/app/Filament/Resources/AlertDeliveryResource.php b/apps/platform/app/Filament/Resources/AlertDeliveryResource.php
index db148eeb..f9488318 100644
--- a/apps/platform/app/Filament/Resources/AlertDeliveryResource.php
+++ b/apps/platform/app/Filament/Resources/AlertDeliveryResource.php
@@ -15,6 +15,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@@ -67,6 +68,11 @@ public static function shouldRegisterNavigation(): bool
return parent::shouldRegisterNavigation();
}
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(parent::getNavigationUrl());
+ }
+
public static function canViewAny(): bool
{
$user = auth()->user();
diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php
index 56951f71..a47b1de7 100644
--- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php
+++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php
@@ -21,6 +21,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ManagedEnvironmentLinks;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
@@ -84,6 +85,16 @@ public static function getNavigationParentItem(): ?string
return 'Integrations';
}
+ public static function getNavigationGroup(): string
+ {
+ return WorkspaceHubNavigation::workspaceAdminGroup();
+ }
+
+ public static function getNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(parent::getNavigationUrl());
+ }
+
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
diff --git a/apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php b/apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php
index 42ab4bbf..fd1fdccd 100644
--- a/apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php
+++ b/apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php
@@ -53,14 +53,8 @@ public function __invoke(Request $request): RedirectResponse
private function isEnvironmentScopedEvidencePath(string $previousPath): bool
{
- if ($previousPath === '/admin/evidence') {
- return true;
- }
+ $normalizedPath = '/'.ltrim($previousPath, '/');
- if (! str_starts_with($previousPath, '/admin/evidence/')) {
- return false;
- }
-
- return ! str_starts_with($previousPath, '/admin/evidence/overview');
+ return preg_match('#^/admin/workspaces/[^/]+/environments/[^/]+/evidence(?:/|$)#', $normalizedPath) === 1;
}
}
diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php
index 8d684542..fb68909f 100644
--- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php
+++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php
@@ -8,6 +8,7 @@
use App\Filament\Pages\ChooseEnvironment;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\CrossEnvironmentComparePage;
+use App\Filament\Pages\EnvironmentDashboard;
use App\Filament\Pages\EnvironmentRequiredPermissions;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
@@ -20,7 +21,6 @@
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Pages\Settings\WorkspaceSettings;
-use App\Filament\Pages\WorkspaceOverview;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertRuleResource;
@@ -32,6 +32,7 @@
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
+use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@@ -41,14 +42,16 @@
use App\Support\Filament\PanelThemeAsset;
use App\Support\Navigation\AdminSurfaceScope;
use App\Support\Navigation\NavigationScope;
-use App\Support\Navigation\WorkspaceHubRegistry;
+use App\Support\Navigation\WorkspaceHubNavigation;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
+use Filament\Facades\Filament;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
+use Filament\Navigation\NavigationGroup;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
@@ -86,8 +89,19 @@ public function panel(Panel $panel): Panel
->colors([
'primary' => Color::Indigo,
])
+ ->navigationGroups([
+ NavigationGroup::make('Inventory'),
+ NavigationGroup::make(__('localization.navigation.monitoring')),
+ NavigationGroup::make(__('localization.review.reporting')),
+ NavigationGroup::make(__('localization.navigation.settings')),
+ NavigationGroup::make(__('localization.navigation.governance')),
+ NavigationGroup::make('Backups & Restore'),
+ NavigationGroup::make('Directory'),
+ NavigationGroup::make(__('localization.navigation.workspace_wide')),
+ NavigationGroup::make(__('localization.navigation.workspace_admin')),
+ ])
->navigationItems([
- WorkspaceOverview::navigationItem(),
+ $this->overviewNavigationItem(),
NavigationItem::make('Items')
->url(fn (): string => InventoryCluster::getUrl(panel: 'admin'))
->icon('heroicon-o-squares-2x2')
@@ -107,15 +121,15 @@ public function panel(Panel $panel): Panel
->sort(10)
->visible(fn (): bool => NavigationScope::shouldRegisterEnvironmentNavigation() && EntraGroupResource::canViewAny()),
NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
- ->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('filament.admin.resources.provider-connections.index')))
+ ->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(route('filament.admin.resources.provider-connections.index')))
->icon('heroicon-o-link')
- ->group(fn (): string => __('localization.navigation.settings'))
+ ->group(fn (): string => WorkspaceHubNavigation::workspaceAdminGroup())
->sort(15)
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
NavigationItem::make(fn (): string => __('localization.navigation.settings'))
- ->url(fn (): string => WorkspaceHubRegistry::cleanUrl(WorkspaceSettings::getUrl(panel: 'admin')))
+ ->url(fn (): string => WorkspaceHubNavigation::cleanUrl(WorkspaceSettings::getUrl(panel: 'admin')))
->icon('heroicon-o-cog-6-tooth')
- ->group(fn (): string => __('localization.navigation.settings'))
+ ->group(fn (): string => WorkspaceHubNavigation::workspaceAdminGroup())
->sort(20)
->visible(function (): bool {
$user = auth()->user();
@@ -144,10 +158,10 @@ public function panel(Panel $panel): Panel
}),
NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
->url(function (): string {
- return WorkspaceHubRegistry::cleanUrl(route('filament.admin.resources.workspaces.index'));
+ return WorkspaceHubNavigation::cleanUrl(route('filament.admin.resources.workspaces.index'));
})
->icon('heroicon-o-squares-2x2')
- ->group(fn (): string => __('localization.navigation.settings'))
+ ->group(fn (): string => WorkspaceHubNavigation::workspaceAdminGroup())
->sort(10)
->visible(function (): bool {
$user = auth()->user();
@@ -164,24 +178,24 @@ public function panel(Panel $panel): Panel
->exists();
}),
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
- ->url(fn (): string => WorkspaceHubRegistry::cleanUrl(OperationRunLinks::index()))
+ ->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(OperationRunLinks::index()))
->icon('heroicon-o-queue-list')
- ->group(fn (): string => __('localization.navigation.monitoring'))
+ ->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
->sort(10),
NavigationItem::make('Alerts')
- ->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('filament.admin.alerts')))
+ ->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(route('filament.admin.alerts')))
->icon('heroicon-o-bell-alert')
- ->group(fn (): string => __('localization.navigation.monitoring'))
+ ->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
->sort(23),
NavigationItem::make(fn (): string => __('localization.navigation.evidence_overview'))
- ->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('admin.evidence.overview')))
+ ->url(fn (): string => $this->workspaceEvidenceOverviewNavigationUrl())
->icon('heroicon-o-shield-check')
- ->group(fn (): string => __('localization.navigation.monitoring'))
+ ->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
->sort(27),
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
- ->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('admin.monitoring.audit-log')))
+ ->url(fn (): string => WorkspaceHubNavigation::environmentFilteredUrl(route('admin.monitoring.audit-log')))
->icon('heroicon-o-clipboard-document-list')
- ->group(fn (): string => __('localization.navigation.monitoring'))
+ ->group(fn (): string => WorkspaceHubNavigation::workspaceWideGroup(__('localization.navigation.monitoring')))
->sort(30),
])
->renderHook(
@@ -192,6 +206,10 @@ public function panel(Panel $panel): Panel
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.context-bar')->render()
)
+ ->renderHook(
+ PanelsRenderHook::SIDEBAR_NAV_START,
+ fn () => view('filament.partials.sidebar-scope-indicator')->render()
+ )
->renderHook(
PanelsRenderHook::PAGE_START,
fn (): string => AdminSurfaceScope::fromRequest(request()) === AdminSurfaceScope::OnboardingWorkflow
@@ -264,4 +282,39 @@ public function panel(Panel $panel): Panel
return $panel;
}
+
+ private function workspaceEvidenceOverviewNavigationUrl(): string
+ {
+ return WorkspaceHubNavigation::environmentFilteredUrl(route('admin.evidence.overview'));
+ }
+
+ private function overviewNavigationItem(): NavigationItem
+ {
+ return NavigationItem::make('Overview')
+ ->url(function (): string {
+ $tenant = $this->environmentBoundNavigationTenant();
+
+ return $tenant instanceof ManagedEnvironment
+ ? EnvironmentDashboard::getUrl(panel: 'admin', tenant: $tenant)
+ : route('admin.home');
+ })
+ ->icon('heroicon-o-home')
+ ->sort(-100)
+ ->isActiveWhen(fn (): bool => request()->routeIs('admin.home', 'admin.workspace.environments.show'));
+ }
+
+ private function environmentBoundNavigationTenant(): ?ManagedEnvironment
+ {
+ if (! AdminSurfaceScope::fromRequest(request())->requiresExplicitEnvironment()) {
+ return null;
+ }
+
+ $tenant = Filament::getTenant();
+
+ if ($tenant instanceof ManagedEnvironment) {
+ return $tenant;
+ }
+
+ return app(WorkspaceContext::class)->rememberedEnvironment(request());
+ }
}
diff --git a/apps/platform/app/Support/Navigation/AdminSurfaceScope.php b/apps/platform/app/Support/Navigation/AdminSurfaceScope.php
index 4bece63d..569bb1a9 100644
--- a/apps/platform/app/Support/Navigation/AdminSurfaceScope.php
+++ b/apps/platform/app/Support/Navigation/AdminSurfaceScope.php
@@ -14,7 +14,6 @@ enum AdminSurfaceScope: string
case WorkspaceScoped = 'workspace_scoped';
case WorkspaceChooserException = 'workspace_chooser_exception';
case EnvironmentBound = 'environment_bound';
- case EnvironmentScopedEvidence = 'environment_scoped_evidence';
case OnboardingWorkflow = 'onboarding_workflow';
case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer';
@@ -47,13 +46,6 @@ public static function fromPath(string $path): self
return self::WorkspaceOwnedAnalysisSurface;
}
- if (
- str_starts_with($normalizedPath, '/admin/evidence/')
- && ! str_starts_with($normalizedPath, '/admin/evidence/overview')
- ) {
- return self::EnvironmentScopedEvidence;
- }
-
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $normalizedPath) === 1) {
return self::OnboardingWorkflow;
}
@@ -108,7 +100,7 @@ public function forcesEnvironmentlessShellContext(): bool
public function requiresExplicitEnvironment(): bool
{
return match ($this) {
- self::EnvironmentBound, self::EnvironmentScopedEvidence => true,
+ self::EnvironmentBound => true,
default => false,
};
}
diff --git a/apps/platform/app/Support/Navigation/NavigationScope.php b/apps/platform/app/Support/Navigation/NavigationScope.php
index b9f3e3bb..c8092098 100644
--- a/apps/platform/app/Support/Navigation/NavigationScope.php
+++ b/apps/platform/app/Support/Navigation/NavigationScope.php
@@ -17,7 +17,6 @@ public static function isEnvironmentSurface(?Request $request = null): bool
{
return in_array(self::pageCategory($request), [
AdminSurfaceScope::EnvironmentBound,
- AdminSurfaceScope::EnvironmentScopedEvidence,
], true);
}
diff --git a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php
index bbe18755..6c6a6d1a 100644
--- a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php
+++ b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php
@@ -1008,11 +1008,6 @@ private function contextForFinding(Finding $finding, string $surface): Canonical
tenantId: $tenant?->getKey(),
backLinkLabel: $backLabel,
backLinkUrl: $backUrl,
- filterPayload: $tenant instanceof ManagedEnvironment ? [
- 'tableFilters' => [
- 'managed_environment_id' => ['value' => (string) $tenant->getKey()],
- ],
- ] : [],
);
}
@@ -1030,11 +1025,6 @@ private function contextForPolicyVersion(PolicyVersion $version, string $surface
tenantId: $tenant?->getKey(),
backLinkLabel: $backLabel,
backLinkUrl: $backUrl,
- filterPayload: $tenant instanceof ManagedEnvironment ? [
- 'tableFilters' => [
- 'managed_environment_id' => ['value' => (string) $tenant->getKey()],
- ],
- ] : [],
);
}
@@ -1067,11 +1057,6 @@ private function contextForBackupSet(BackupSet $backupSet, string $surface): Can
tenantId: $tenant?->getKey(),
backLinkLabel: $backLabel,
backLinkUrl: $backUrl,
- filterPayload: $tenant instanceof ManagedEnvironment ? [
- 'tableFilters' => [
- 'managed_environment_id' => ['value' => (string) $tenant->getKey()],
- ],
- ] : [],
);
}
@@ -1085,11 +1070,6 @@ private function contextForOperationRun(OperationRun $run): CanonicalNavigationC
tenantId: $tenant?->getKey(),
backLinkLabel: 'Back to operations',
backLinkUrl: OperationRunLinks::index($tenant),
- filterPayload: $tenant instanceof ManagedEnvironment ? [
- 'tableFilters' => [
- 'managed_environment_id' => ['value' => (string) $tenant->getKey()],
- ],
- ] : [],
);
}
diff --git a/apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php b/apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php
new file mode 100644
index 00000000..b0eb14cc
--- /dev/null
+++ b/apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php
@@ -0,0 +1,61 @@
+query($url, [
+ 'environment_id' => (int) $environment->getKey(),
+ ]);
+ }
+
+ public static function cleanUrl(string $url): string
+ {
+ return WorkspaceHubRegistry::cleanUrl($url);
+ }
+
+ public static function environmentContext(): ?ManagedEnvironment
+ {
+ if (! NavigationScope::isEnvironmentSurface()) {
+ return null;
+ }
+
+ $tenant = Filament::getTenant();
+
+ if ($tenant instanceof ManagedEnvironment) {
+ return $tenant;
+ }
+
+ return app(WorkspaceContext::class)->rememberedEnvironment(request());
+ }
+}
diff --git a/apps/platform/app/Support/OperateHub/OperateHubShell.php b/apps/platform/app/Support/OperateHub/OperateHubShell.php
index 4035e1c4..8423ee5a 100644
--- a/apps/platform/app/Support/OperateHub/OperateHubShell.php
+++ b/apps/platform/app/Support/OperateHub/OperateHubShell.php
@@ -64,12 +64,15 @@ public function headerActions(
string $returnActionName = 'operate_hub_return',
?Request $request = null,
): array {
- $actions = [
- Action::make($scopeActionName)
+ $actions = [];
+ $activeEnvironment = $this->activeEntitledTenant($request);
+
+ if ($activeEnvironment instanceof ManagedEnvironment) {
+ $actions[] = Action::make($scopeActionName)
->label($this->scopeLabel($request))
->color('gray')
- ->disabled(),
- ];
+ ->disabled();
+ }
$returnAffordance = $this->returnAffordance($request);
@@ -249,12 +252,8 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
state: 'missing_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
- recoveryAction: $pageCategory === AdminSurfaceScope::EnvironmentScopedEvidence
- ? 'redirect_evidence_overview'
- : 'abort_not_found',
- recoveryDestination: $pageCategory === AdminSurfaceScope::EnvironmentScopedEvidence
- ? '/admin/evidence/overview'
- : null,
+ recoveryAction: 'abort_not_found',
+ recoveryDestination: null,
recoveryReason: $recoveryReason ?? 'missing_tenant',
);
}
@@ -320,7 +319,6 @@ private function resolveWorkspaceForPageCategory(
?Request $request = null,
): ?Workspace {
return match ($pageCategory) {
- AdminSurfaceScope::EnvironmentScopedEvidence => $this->workspaceContext->currentWorkspace($request),
AdminSurfaceScope::EnvironmentBound => $this->workspaceContext->currentWorkspaceOrEnvironmentWorkspace($tenant, $request),
default => $this->workspaceContext->currentWorkspaceOrEnvironmentWorkspace($tenant, $request),
};
diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php
index fce3e5db..c531e0c3 100644
--- a/apps/platform/app/Support/OperationRunLinks.php
+++ b/apps/platform/app/Support/OperationRunLinks.php
@@ -116,8 +116,8 @@ public static function index(
}
}
- if (is_string($operationType) && $operationType !== '') {
- $parameters['tableFilters']['type']['value'] = OperationCatalog::canonicalCode($operationType);
+ if (is_string($operationType) && trim($operationType) !== '') {
+ $parameters['operation_type'] = OperationCatalog::canonicalCode($operationType);
}
return route('admin.operations.index', $parameters);
diff --git a/apps/platform/app/Support/Tenants/TenantInteractionLane.php b/apps/platform/app/Support/Tenants/TenantInteractionLane.php
index 689ed9e5..13492115 100644
--- a/apps/platform/app/Support/Tenants/TenantInteractionLane.php
+++ b/apps/platform/app/Support/Tenants/TenantInteractionLane.php
@@ -17,8 +17,7 @@ public static function fromSurfaceScope(AdminSurfaceScope $pageCategory): self
{
return match ($pageCategory) {
AdminSurfaceScope::OnboardingWorkflow => self::OnboardingWorkflow,
- AdminSurfaceScope::EnvironmentBound,
- AdminSurfaceScope::EnvironmentScopedEvidence => self::AdministrativeManagement,
+ AdminSurfaceScope::EnvironmentBound => self::AdministrativeManagement,
AdminSurfaceScope::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
AdminSurfaceScope::WorkspaceWideSurface,
AdminSurfaceScope::WorkspaceOwnedAnalysisSurface,
diff --git a/apps/platform/lang/de/localization.php b/apps/platform/lang/de/localization.php
index 0732676e..bda5b778 100644
--- a/apps/platform/lang/de/localization.php
+++ b/apps/platform/lang/de/localization.php
@@ -25,6 +25,13 @@
'save_preference' => 'Einstellung speichern',
'inherit_workspace' => 'Workspace-Standard verwenden',
'workspace' => 'Workspace',
+ 'workspace_scope' => 'Workspace-Kontext',
+ 'workspace_scope_short' => 'Workspace',
+ 'environment_scope_short' => 'Umgebung',
+ 'workspace_context_label' => 'Workspace: :workspace',
+ 'workspace_scope_no_environment' => 'Keine Umgebung ausgewählt',
+ 'workspace_wide_scope' => 'Workspace-weit',
+ 'scope_indicator_action' => ':scope öffnen',
'choose_workspace' => 'Workspace auswählen',
'switch_workspace' => 'Workspace wechseln',
'workspace_home' => 'Workspace-Start',
@@ -94,6 +101,8 @@
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
+ 'workspace_wide' => 'Workspace-weit',
+ 'workspace_admin' => 'Workspace-Administration',
'dashboard' => 'Dashboard',
],
'dashboard' => [
diff --git a/apps/platform/lang/en/localization.php b/apps/platform/lang/en/localization.php
index ecbda3b5..2848453d 100644
--- a/apps/platform/lang/en/localization.php
+++ b/apps/platform/lang/en/localization.php
@@ -25,6 +25,13 @@
'save_preference' => 'Save preference',
'inherit_workspace' => 'Use workspace default',
'workspace' => 'Workspace',
+ 'workspace_scope' => 'Workspace scope',
+ 'workspace_scope_short' => 'Workspace',
+ 'environment_scope_short' => 'Environment',
+ 'workspace_context_label' => 'Workspace: :workspace',
+ 'workspace_scope_no_environment' => 'No environment selected',
+ 'workspace_wide_scope' => 'Workspace-wide',
+ 'scope_indicator_action' => 'Open :scope',
'choose_workspace' => 'Choose workspace',
'switch_workspace' => 'Switch workspace',
'workspace_home' => 'Workspace Home',
@@ -94,6 +101,8 @@
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
+ 'workspace_wide' => 'Workspace-wide',
+ 'workspace_admin' => 'Workspace admin',
'dashboard' => 'Dashboard',
],
'dashboard' => [
diff --git a/apps/platform/resources/views/filament/components/product-process-flow-horizontal.blade.php b/apps/platform/resources/views/filament/components/product-process-flow-horizontal.blade.php
index 282ddde7..2137c930 100644
--- a/apps/platform/resources/views/filament/components/product-process-flow-horizontal.blade.php
+++ b/apps/platform/resources/views/filament/components/product-process-flow-horizontal.blade.php
@@ -4,6 +4,24 @@
$stepTestId = $stepTestId ?? 'product-process-flow-step';
$connectorTestId = $connectorTestId ?? 'product-process-flow-connector';
$badgeTestId = $badgeTestId ?? 'product-process-flow-badge';
+ $layoutMode = $layoutMode ?? 'viewport';
+ $usesContainerLayout = $layoutMode === 'container';
+ $flowClasses = $usesContainerLayout ? '@container space-y-5' : 'space-y-5';
+ $listClasses = $usesContainerLayout
+ ? 'flex flex-col gap-3 @5xl:flex-row @5xl:items-stretch @5xl:gap-1.5'
+ : 'flex flex-col gap-3 lg:flex-row lg:items-stretch lg:gap-1.5';
+ $stepItemClasses = $usesContainerLayout
+ ? 'flex min-w-0 flex-1 flex-col gap-2 @5xl:flex-row @5xl:items-stretch'
+ : 'flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch';
+ $stepHeaderClasses = $usesContainerLayout
+ ? 'flex min-w-0 items-start gap-3 @5xl:flex-col @5xl:gap-2'
+ : 'flex min-w-0 items-start gap-3 lg:flex-col lg:gap-2';
+ $connectorClasses = $usesContainerLayout
+ ? 'flex shrink-0 items-center justify-center py-0.5 text-gray-400 dark:text-gray-500 @5xl:w-6 @5xl:py-0'
+ : 'flex shrink-0 items-center justify-center py-0.5 text-gray-400 dark:text-gray-500 lg:w-6 lg:py-0';
+ $connectorIconClasses = $usesContainerLayout
+ ? 'inline-flex h-7 min-w-7 rotate-90 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900 @5xl:rotate-0'
+ : 'inline-flex h-7 min-w-7 rotate-90 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900 lg:rotate-0';
$statusBadgeClasses = $statusBadgeClasses ?? static fn (?string $tone): string => 'inline-flex items-center rounded-md border px-2 py-0.5 text-left text-xs font-medium leading-5 whitespace-normal break-words '.match ($tone) {
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-800 dark:bg-danger-500/10 dark:text-danger-300',
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-800 dark:bg-success-500/10 dark:text-success-300',
@@ -14,7 +32,7 @@
};
@endphp
-
+
{{ $title ?? 'Product process flow' }}
@@ -26,7 +44,7 @@
@endif
-
+
@foreach ($steps as $step)
@php
$isCurrentBlocker = (bool) ($step['currentBlocker'] ?? false);
@@ -43,16 +61,16 @@
data-step-label="{{ $step['label'] }}"
data-step-state="{{ $step['state'] }}"
data-step-current-blocker="{{ $isCurrentBlocker ? 'true' : 'false' }}"
- class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
+ class="{{ $stepItemClasses }}"
>
-
+
@@ -73,10 +91,10 @@ class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
-
+
→
diff --git a/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php b/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php
index 93e84e08..e9646cf6 100644
--- a/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php
+++ b/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php
@@ -49,8 +49,8 @@
-
-
+
+
Primary proof path
@@ -60,6 +60,32 @@
+ @if ($decisionCard['actionUrl'])
+
+ {{ $decisionCard['actionLabel'] }}
+
+ @else
+
+
+ {{ $decisionCard['actionLabel'] }}
+
+
+ @if ($decisionCard['helperText'])
+
+ {{ $decisionCard['helperText'] }}
+
+ @endif
+
+ @endif
+
+
+
{{ $payload['primary_title'] }}
@@ -134,78 +160,8 @@
-
- @if ($decisionCard['actionUrl'])
-
- {{ $decisionCard['actionLabel'] }}
-
- @else
-
-
- {{ $decisionCard['actionLabel'] }}
-
-
- @if ($decisionCard['helperText'])
-
- {{ $decisionCard['helperText'] }}
-
- @endif
-
- @endif
-
- @if (! empty($payload['readiness_flow']))
-
- @include('filament.components.product-process-flow-horizontal', [
- 'title' => 'Evidence readiness flow',
- 'subtitle' => 'Customer-safe evidence requires source data, evidence snapshot, stored report, review pack, and export readiness.',
- 'ariaLabel' => 'Evidence readiness pipeline',
- 'steps' => $payload['readiness_flow'],
- 'flowTestId' => 'evidence-readiness-flow',
- 'stepTestId' => 'evidence-readiness-step',
- 'connectorTestId' => 'evidence-readiness-connector',
- 'badgeTestId' => 'evidence-review-pack-status-badge',
- 'statusBadgeClasses' => $statusBadgeClasses,
- ])
-
- @endif
-
-
-
-
- Review pack contents / coverage
-
-
- Repo-backed values only.
-
-
-
-
- {{ $payload['review_pack_coverage']['description'] }}
-
-
- @if (! empty($payload['review_pack_coverage']['items']))
-
- @foreach ($payload['review_pack_coverage']['items'] as $coverageItem)
-
-
- {{ $coverageItem['label'] }}
-
-
- {{ $coverageItem['value'] }}
-
-
- @endforeach
-
- @endif
-
+ @if (! empty($payload['readiness_flow']))
+
+ @include('filament.components.product-process-flow-horizontal', [
+ 'title' => 'Evidence readiness flow',
+ 'subtitle' => 'Customer-safe evidence requires source data, evidence snapshot, stored report, review pack, and export readiness.',
+ 'ariaLabel' => 'Evidence readiness pipeline',
+ 'steps' => $payload['readiness_flow'],
+ 'flowTestId' => 'evidence-readiness-flow',
+ 'stepTestId' => 'evidence-readiness-step',
+ 'connectorTestId' => 'evidence-readiness-connector',
+ 'badgeTestId' => 'evidence-review-pack-status-badge',
+ 'statusBadgeClasses' => $statusBadgeClasses,
+ 'layoutMode' => 'container',
+ ])
+
+ @endif
+
+
+
+
+ Review pack contents / coverage
+
+
+ Repo-backed values only.
+
+
+
+
+ {{ $payload['review_pack_coverage']['description'] }}
+
+
+ @if (! empty($payload['review_pack_coverage']['items']))
+
+ @foreach ($payload['review_pack_coverage']['items'] as $coverageItem)
+
+
+ {{ $coverageItem['label'] }}
+
+
+ {{ $coverageItem['value'] }}
+
+
+ @endforeach
+
+ @endif
+
+
diff --git a/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php b/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php
index 84ffa604..80dc64e9 100644
--- a/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php
+++ b/apps/platform/resources/views/filament/pages/monitoring/operations.blade.php
@@ -22,9 +22,11 @@
-
- {{ $landingHierarchy['scope_label'] }}
-
+ @if ($landingHierarchy['scope_label'] !== __('localization.shell.all_environments'))
+
+ {{ $landingHierarchy['scope_label'] }}
+
+ @endif
{{ $landingHierarchy['scope_body'] }}
diff --git a/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php b/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php
index e7bef612..40fc2503 100644
--- a/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php
+++ b/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php
@@ -88,7 +88,9 @@
Scope context
-
{{ $monitoringDetail['scope_label'] }}
+ @if ($monitoringDetail['scope_label'] !== __('localization.shell.all_environments'))
+
{{ $monitoringDetail['scope_label'] }}
+ @endif
{{ $monitoringDetail['scope_body'] }}
diff --git a/apps/platform/resources/views/filament/pages/workspaces/managed-environments-landing.blade.php b/apps/platform/resources/views/filament/pages/workspaces/managed-environments-landing.blade.php
index ffe175a8..e82da064 100644
--- a/apps/platform/resources/views/filament/pages/workspaces/managed-environments-landing.blade.php
+++ b/apps/platform/resources/views/filament/pages/workspaces/managed-environments-landing.blade.php
@@ -60,15 +60,6 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
-
- {{ __('localization.shell.choose_environment') }}
-
{{-- ManagedEnvironment cards --}}
diff --git a/apps/platform/resources/views/filament/partials/context-bar.blade.php b/apps/platform/resources/views/filament/partials/context-bar.blade.php
index c82a4118..94114b70 100644
--- a/apps/platform/resources/views/filament/partials/context-bar.blade.php
+++ b/apps/platform/resources/views/filament/partials/context-bar.blade.php
@@ -37,7 +37,7 @@
@endphp
@php
- $environmentLabel = $currentEnvironmentName ?? __('localization.shell.no_environment_selected');
+ $environmentLabel = $currentEnvironmentName;
$workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
$hasActiveEnvironment = $currentEnvironmentName !== null;
$managedEnvironmentsUrl = $workspace
@@ -47,6 +47,9 @@
? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin');
$environmentTriggerLabel = $workspace ? $environmentLabel : __('localization.shell.choose_workspace');
+ $environmentTriggerAriaLabel = $workspace && $hasActiveEnvironment
+ ? __('localization.shell.environment_scope')
+ : __('localization.shell.select_environment');
$localePlane = 'admin';
@endphp
@@ -61,21 +64,25 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t
{{ $workspaceLabel }}
- @if ($workspace)
+ @if ($workspace && $hasActiveEnvironment)
@endif
- {{-- Dropdown trigger: environment label + chevron --}}
+ {{-- Dropdown trigger: environment label or compact picker + chevron --}}
@@ -131,7 +138,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
- {{ __('localization.shell.selected_environment') }}
+ {{ $hasActiveEnvironment ? __('localization.shell.selected_environment') : __('localization.shell.choose_environment') }}
@@ -168,12 +175,6 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@else
- @if (! $hasActiveEnvironment)
-
- {{ __('localization.shell.workspace_wide_available_without_environment') }}
-
- @endif
-
resolvedContext(request());
+ $workspace = $resolvedContext->workspace;
+ $environment = $resolvedContext->tenant;
+ $isEnvironmentScope = $resolvedContext->pageCategory->requiresExplicitEnvironment()
+ && $environment instanceof ManagedEnvironment;
+
+ $environmentDisplayName = static function (ManagedEnvironment $environment): string {
+ $displayName = trim((string) ($environment->display_name ?: $environment->name ?: $environment->external_id ?: ''));
+
+ return $displayName !== '' ? $displayName : 'Environment #'.$environment->getKey();
+ };
+
+ $scopeUrl = $isEnvironmentScope
+ ? ManagedEnvironmentLinks::viewUrl($environment)
+ : route('admin.home');
+
+ $scopeKind = $isEnvironmentScope
+ ? __('localization.shell.environment_scope_short')
+ : __('localization.shell.workspace_scope_short');
+
+ $scopeName = $isEnvironmentScope ? $environmentDisplayName($environment) : $workspace?->name;
+
+ $workspaceEnvironmentCount = 0;
+ $sidebarUser = auth()->user();
+ if ($sidebarUser instanceof User && $workspace) {
+ $workspaceEnvironmentCount = collect($sidebarUser->getTenants(Filament::getCurrentOrDefaultPanel()))
+ ->filter(fn ($candidate): bool => $candidate instanceof ManagedEnvironment && (int) $candidate->workspace_id === (int) $workspace->getKey())
+ ->count();
+ }
+
+ $scopeDescription = $isEnvironmentScope
+ ? __('localization.shell.workspace_context_label', ['workspace' => $workspace?->name])
+ : trans_choice('localization.shell.environment_count', $workspaceEnvironmentCount, ['count' => $workspaceEnvironmentCount]);
+
+ $scopeActionLabel = __('localization.shell.scope_indicator_action', ['scope' => $scopeName]);
+@endphp
+
+@if ($workspace)
+
+
+
+
+
+
+
+ {{ $scopeKind }}
+
+
+
+ {{ $scopeName }}
+
+
+
+ {{ $scopeDescription }}
+
+
+
+
+
+@endif
diff --git a/apps/platform/tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php b/apps/platform/tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php
index 614776cf..d14068e9 100644
--- a/apps/platform/tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php
@@ -68,7 +68,7 @@
$landing = visit(route('admin.workspace.managed-environments.index', ['workspace' => $workspace]))
->waitForText('Managed environments')
- ->assertSee('Choose environment')
+ ->assertDontSee('Choose environment')
->assertSee('Spec 286 Production')
->assertSee('Spec 286 Secondary')
->assertDontSee('Managed tenants')
@@ -92,4 +92,4 @@
->assertSee('Source: Microsoft Intune')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
-});
\ No newline at end of file
+});
diff --git a/apps/platform/tests/Browser/Spec314WorkspaceHubNavigationContextSmokeTest.php b/apps/platform/tests/Browser/Spec314WorkspaceHubNavigationContextSmokeTest.php
index 356aa6b8..6fe89572 100644
--- a/apps/platform/tests/Browser/Spec314WorkspaceHubNavigationContextSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec314WorkspaceHubNavigationContextSmokeTest.php
@@ -49,7 +49,7 @@
$expectedPath = json_encode((string) parse_url($url, PHP_URL_PATH), JSON_THROW_ON_ERROR);
$page
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Spec314 Browser Environment')
->assertScript("window.location.pathname === {$expectedPath}", true)
->assertScript('! window.location.search.includes("tenant=")', true)
@@ -74,7 +74,7 @@
$page->script('window.location.reload();');
- $page->waitForText(__('localization.shell.no_environment_selected'));
+ $page->waitForText('Search');
$assertCleanWorkspaceHub($page, $url);
}
@@ -96,15 +96,15 @@
$encodedUrl = json_encode($url, JSON_THROW_ON_ERROR);
$historyPage->script("window.location.assign({$encodedUrl});");
- $historyPage->waitForText(__('localization.shell.no_environment_selected'));
+ $historyPage->waitForText('Search');
$assertCleanWorkspaceHub($historyPage, $url);
}
$historyPage->script('window.history.back();');
- $historyPage->waitForText(__('localization.shell.no_environment_selected'));
+ $historyPage->waitForText('Search');
$assertCleanWorkspaceHub($historyPage, $hubUrls[3]);
$historyPage->script('window.history.forward();');
- $historyPage->waitForText(__('localization.shell.no_environment_selected'));
+ $historyPage->waitForText('Search');
$assertCleanWorkspaceHub($historyPage, $hubUrls[4]);
});
diff --git a/apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php b/apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php
index 1508f791..9c4b022b 100644
--- a/apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php
@@ -107,7 +107,7 @@
->assertNoJavaScriptErrors();
spec316BrowserClearEnvironmentFilter($page)
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($hub['wide_text'])
->assertDontSee('Environment filter:')
->assertSee($hub['wide_text'])
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -121,7 +121,7 @@
$page->script('window.location.reload();');
$page
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($hub['wide_text'])
->assertDontSee('Environment filter:')
->assertSee($hub['wide_text'])
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -160,7 +160,7 @@
->assertDontSee($environmentB->name);
spec316BrowserClearEnvironmentFilter($page)
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($environmentB->name)
->assertDontSee('Environment filter:')
->assertSee($environmentB->name);
@@ -175,7 +175,7 @@
$page->script('window.history.forward();');
$page
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($environmentB->name)
->assertDontSee('Environment filter:')
->assertSee($environmentB->name)
->assertScript('! window.location.search.includes("environment_id=")', true)
@@ -233,7 +233,7 @@
]);
visit($case['url'])
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($case['wide_text'])
->assertDontSee('Environment filter:')
->assertSee($case['wide_text'])
->assertNoJavaScriptErrors();
diff --git a/apps/platform/tests/Browser/Spec322AlertsAuditNoDriftSmokeTest.php b/apps/platform/tests/Browser/Spec322AlertsAuditNoDriftSmokeTest.php
index 425b1e70..a3e4d69d 100644
--- a/apps/platform/tests/Browser/Spec322AlertsAuditNoDriftSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec322AlertsAuditNoDriftSmokeTest.php
@@ -83,7 +83,7 @@
foreach ($configurationUrls as $url) {
visit($url)
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText('Alerts')
->assertDontSee('Environment filter:')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
diff --git a/apps/platform/tests/Browser/Spec322WorkspaceHubNoDriftSmokeTest.php b/apps/platform/tests/Browser/Spec322WorkspaceHubNoDriftSmokeTest.php
index 2dd80710..8e78e7ad 100644
--- a/apps/platform/tests/Browser/Spec322WorkspaceHubNoDriftSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec322WorkspaceHubNoDriftSmokeTest.php
@@ -114,7 +114,7 @@
foreach ($legacyUrls as $url) {
visit($url)
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($fixture['environmentB']->name)
->assertDontSee('Environment filter:')
->assertSee($fixture['environmentB']->name)
->assertNoJavaScriptErrors()
diff --git a/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php b/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php
index b352faa6..b5b67ec6 100644
--- a/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec326CustomerReviewWorkspaceProductizationSmokeTest.php
@@ -26,7 +26,7 @@
visit(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->waitForText('Customer-safe review packages')
- ->assertSee('No environment selected')
+ ->assertDontSee('No environment selected')
->assertDontSee('Environment filter:')
->assertSee('Is this review ready to share?')
->assertSee('Evidence path')
@@ -86,7 +86,7 @@
$page
->click('[data-testid="workspace-hub-environment-filter-clear"]')
- ->waitForText('No environment selected')
+ ->waitForText($environmentB->name)
->assertDontSee('Environment filter:')
->assertSee($environmentB->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -98,7 +98,7 @@
$page->script('window.location.reload();');
$page
- ->waitForText('No environment selected')
+ ->waitForText($environmentB->name)
->assertDontSee('Environment filter:')
->assertSee($environmentB->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
diff --git a/apps/platform/tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php b/apps/platform/tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php
index 86399cf0..5fb2ad04 100644
--- a/apps/platform/tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php
@@ -18,7 +18,7 @@
->resize(1440, 1100)
->waitForText('Governance Inbox')
->assertSee('Prioritized governance decisions, owners, evidence, and follow-up actions across entitled environments.')
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee('Environment filter:')
->assertSee('What decision clears the highest-priority item?')
->assertSee('Decision workbench')
@@ -103,7 +103,7 @@
$page
->click('[data-testid="workspace-hub-environment-filter-clear"]')
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($environmentB->name)
->assertDontSee('Environment filter:')
->assertSee($environmentB->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -117,7 +117,7 @@
$page->script('window.location.reload();');
$page
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($environmentB->name)
->assertDontSee('Environment filter:')
->assertSee($environmentB->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
diff --git a/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php b/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php
index b29121f8..3345ec86 100644
--- a/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec328OperationsHubProductizationSmokeTest.php
@@ -19,7 +19,7 @@
$page = visit(OperationRunLinks::index(workspace: $environmentA->workspace))
->resize(1440, 1100)
->waitForText('Operations Hub')
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee('Environment filter:')
->assertSee('Execution follow-up workbench')
->assertSee('Which operation needs attention now?')
@@ -157,7 +157,7 @@
spec328CopyBrowserScreenshot('operations-hub--filtered');
spec328ClearEnvironmentFilter($page)
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText('Policy sync')
->assertDontSee('Environment filter:')
->assertSee('Policy sync')
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -171,7 +171,7 @@
$page->script('window.location.reload();');
$page
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText('Policy sync')
->assertDontSee('Environment filter:')
->assertSee('Policy sync')
->assertScript("window.location.pathname === {$cleanPath}", true)
diff --git a/apps/platform/tests/Browser/Spec329EvidenceAuditDisclosureSmokeTest.php b/apps/platform/tests/Browser/Spec329EvidenceAuditDisclosureSmokeTest.php
index 4f8db3ba..987575c2 100644
--- a/apps/platform/tests/Browser/Spec329EvidenceAuditDisclosureSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec329EvidenceAuditDisclosureSmokeTest.php
@@ -27,7 +27,7 @@
$page = visit(route('admin.evidence.overview'))
->resize(1440, 1100)
->waitForText('What proof is available for this scope?')
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee('Environment filter:')
->assertSee('Evidence proof workbench')
->assertSee('Primary proof path')
@@ -138,7 +138,7 @@
spec329CopyDisclosureScreenshot('evidence-overview--filtered');
spec329ClearDisclosureEnvironmentFilter($page)
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($fixture['environmentB']->name)
->assertDontSee('Environment filter:')
->waitForText($fixture['environmentB']->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -152,7 +152,7 @@
$page->script('window.location.reload();');
$page
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText($fixture['environmentB']->name)
->assertDontSee('Environment filter:')
->assertSee($fixture['environmentB']->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -175,7 +175,7 @@
]))
->resize(1440, 1100)
->waitForText('Which event proves what happened?')
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee('Environment filter:')
->assertSee('Audit proof workbench')
->assertSee('Selected event proof')
@@ -248,7 +248,7 @@
spec329CopyDisclosureScreenshot('audit-log--filtered');
spec329ClearDisclosureEnvironmentFilter($page)
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText('Workspace selected by browser proof B')
->assertDontSee('Environment filter:')
->waitForText('Workspace selected by browser proof B')
->assertScript("window.location.pathname === {$cleanPath}", true)
@@ -262,7 +262,7 @@
$page->script('window.location.reload();');
$page
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->waitForText('Workspace selected by browser proof B')
->assertDontSee('Environment filter:')
->assertSee('Workspace selected by browser proof B')
->assertScript("window.location.pathname === {$cleanPath}", true)
diff --git a/apps/platform/tests/Browser/Spec337EvidenceReviewPackProductFlowSmokeTest.php b/apps/platform/tests/Browser/Spec337EvidenceReviewPackProductFlowSmokeTest.php
index 042dd39b..8a42e41a 100644
--- a/apps/platform/tests/Browser/Spec337EvidenceReviewPackProductFlowSmokeTest.php
+++ b/apps/platform/tests/Browser/Spec337EvidenceReviewPackProductFlowSmokeTest.php
@@ -129,12 +129,14 @@ function spec337BrowserEnvironmentFor(User $user, ManagedEnvironment $baseEnviro
$page = visit(route('admin.evidence.overview', [
'environment_id' => (int) $missingEnvironment->getKey(),
]))
- ->resize(1440, 1100)
+ ->resize(1236, 862)
->waitForText('Evidence snapshot required')
->assertSee('Is this evidence package ready for customer or auditor consumption?')
->assertSee('Generate evidence snapshot')
->assertSee('Evidence readiness flow')
->assertScript('document.querySelectorAll("[data-testid=\"evidence-readiness-step\"]").length === 6', true)
+ ->assertScript('(() => { const list = document.querySelector("[data-testid=\"evidence-readiness-flow\"] ol"); return list !== null && getComputedStyle(list).flexDirection === "column"; })()', true)
+ ->assertScript('(() => { const steps = Array.from(document.querySelectorAll("[data-testid=\"evidence-readiness-step\"]")); return steps.length === 6 && steps.every((step) => step.getBoundingClientRect().width >= 300); })()', true)
->assertScript('document.querySelector("[data-step-label=\"Evidence snapshot\"]")?.dataset.stepState === "Missing"', true)
->assertScript('document.querySelector("[data-step-label=\"Evidence snapshot\"]")?.dataset.stepCurrentBlocker === "true"', true)
->assertScript('document.querySelector("[data-testid=\"evidence-disclosure-diagnostics\"]")?.open === false', true)
diff --git a/apps/platform/tests/Browser/Spec338ScopeContractSmokeTest.php b/apps/platform/tests/Browser/Spec338ScopeContractSmokeTest.php
new file mode 100644
index 00000000..8d77dfc4
--- /dev/null
+++ b/apps/platform/tests/Browser/Spec338ScopeContractSmokeTest.php
@@ -0,0 +1,58 @@
+browser()->timeout(60_000);
+
+it('Spec338 smokes environment origin operations link contract uses environment_id and avoids Filament internals', function (): void {
+ $fixture = Spec322Harness::fixture();
+
+ Spec322Harness::authenticate($this, $fixture['user'], $fixture['workspace'], $fixture['environmentA']);
+
+ $page = visit(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $fixture['environmentA']))
+ ->waitForText($fixture['environmentA']->name)
+ ->assertScript('Array.from(document.querySelectorAll("a[href*=\"/operations\"]")).some((element) => element.href.includes("environment_id=") && !element.href.includes("tableFilters"))', true)
+ ->assertNoJavaScriptErrors()
+ ->assertNoConsoleLogs();
+
+ $page->script('(() => {
+ const link = Array.from(document.querySelectorAll("a[href*=\\"/operations\\"]"))
+ .find((element) => element.href.includes("environment_id=") && ! element.href.includes("tableFilters"));
+
+ if (! link) {
+ return;
+ }
+
+ window.location.assign(link.href);
+ })()');
+
+ Spec322Harness::assertFilteredWorkspaceHub($page, $fixture['environmentA']);
+});
+
+it('Spec338 smokes clearing environment context from environment evidence surface redirects to Evidence Overview hub', function (): void {
+ $fixture = Spec322Harness::fixture();
+
+ Spec322Harness::authenticate($this, $fixture['user'], $fixture['workspace'], $fixture['environmentA']);
+
+ $overviewPath = json_encode((string) parse_url(route('admin.evidence.overview'), PHP_URL_PATH), JSON_THROW_ON_ERROR);
+
+ $page = visit(EvidenceSnapshotResource::getUrl('index', tenant: $fixture['environmentA'], panel: 'admin'))
+ ->waitForText('Evidence')
+ ->assertNoJavaScriptErrors()
+ ->assertNoConsoleLogs();
+
+ $page
+ ->assertScript('document.querySelector(\'form[action*="/admin/clear-environment-context"]\') instanceof HTMLFormElement', true)
+ ->script('document.querySelector(\'form[action*="/admin/clear-environment-context"]\').submit();');
+
+ $page
+ ->waitForText('Evidence Overview')
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
+ ->assertScript("window.location.pathname === {$overviewPath}", true)
+ ->assertNoJavaScriptErrors()
+ ->assertNoConsoleLogs();
+});
diff --git a/apps/platform/tests/Browser/Support/Spec322WorkspaceEnvironmentBrowserHarness.php b/apps/platform/tests/Browser/Support/Spec322WorkspaceEnvironmentBrowserHarness.php
index a4347956..045b6219 100644
--- a/apps/platform/tests/Browser/Support/Spec322WorkspaceEnvironmentBrowserHarness.php
+++ b/apps/platform/tests/Browser/Support/Spec322WorkspaceEnvironmentBrowserHarness.php
@@ -159,8 +159,14 @@ public static function authenticate(object $testCase, User $user, Workspace $wor
public static function assertWorkspaceOnly(mixed $page, ?string $wideText = null, ?string $environmentName = null): mixed
{
+ if ($wideText !== null) {
+ $page->waitForText($wideText);
+ } else {
+ $page->assertScript('document.querySelector("nav.fi-topbar") instanceof HTMLElement', true);
+ }
+
$page
- ->waitForText(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee('Environment filter:')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
@@ -215,7 +221,7 @@ public static function clearWorkspaceHubEnvironmentFilter(mixed $page): mixed
$page->assertScript('document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\') instanceof HTMLAnchorElement', true);
$page->script('window.location.assign(document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\').href);');
- return $page->waitForText(__('localization.shell.no_environment_selected'));
+ return $page;
}
private static function findingException(
diff --git a/apps/platform/tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php b/apps/platform/tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php
index 7162c722..919e78ae 100644
--- a/apps/platform/tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php
+++ b/apps/platform/tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php
@@ -21,7 +21,7 @@
->assertDontSee('Clear tenant scope');
});
-it('renders all-environments shell wording on tenantless monitoring pages', function (): void {
+it('does not render generic all-environments shell wording on tenantless monitoring pages', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
@@ -30,6 +30,6 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get(route('admin.operations.index', ['workspace' => $environment->workspace]))
->assertOk()
- ->assertSee('All environments')
+ ->assertDontSee('All environments')
->assertDontSee('All tenants');
});
diff --git a/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php b/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php
index 14897ee4..9beba7fa 100644
--- a/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php
+++ b/apps/platform/tests/Feature/Filament/PanelNavigationSegregationTest.php
@@ -244,6 +244,26 @@ function seedWorkspaceSidebarVisibleDecision(ManagedEnvironment $tenant, User $a
->and(NavigationScope::isWorkspaceSurface())->toBeFalse();
});
+it('keeps the evidence overview navigation link explicitly filtered on environment surfaces', function (): void {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ Filament::setTenant($tenant, true);
+
+ $response = $this->actingAs($user)
+ ->withSession([
+ WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
+ WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
+ (string) $tenant->workspace_id => (int) $tenant->getKey(),
+ ],
+ ])
+ ->get(ManagedEnvironmentLinks::viewUrl($tenant))
+ ->assertOk();
+
+ preg_match_all('#/admin/evidence/overview[^"\']*#', $response->getContent(), $matches);
+
+ expect($matches[0] ?? [])->toContain('/admin/evidence/overview?environment_id='.(int) $tenant->getKey());
+});
+
it('registers environment-owned surfaces only on environment surfaces', function (string $class): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
diff --git a/apps/platform/tests/Feature/Filament/Spec337EvidenceReviewPackProductFlowTest.php b/apps/platform/tests/Feature/Filament/Spec337EvidenceReviewPackProductFlowTest.php
index 794ddd4b..4f6d1f7b 100644
--- a/apps/platform/tests/Feature/Filament/Spec337EvidenceReviewPackProductFlowTest.php
+++ b/apps/platform/tests/Feature/Filament/Spec337EvidenceReviewPackProductFlowTest.php
@@ -46,6 +46,7 @@
spec337AssertFlowStep($content, 'Export / delivery', 'Unavailable', false);
expect(substr_count($content, 'data-testid="evidence-readiness-step"'))->toBe(6)
+ ->and($content)->toContain('data-testid="evidence-readiness-flow" class="@container')
->and($content)->not->toContain('data-testid="evidence-disclosure-diagnostics" open');
});
diff --git a/apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php b/apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php
index e98fd71c..83736a8b 100644
--- a/apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php
+++ b/apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php
@@ -35,7 +35,8 @@
])
->get(route('admin.operations.index', ['workspace' => $validTenant->workspace, 'tenant' => $foreignTenant->external_id]))
->assertOk()
- ->assertSee('Context unavailable')
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee('Context unavailable')
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': '.$foreignTenant->name);
});
diff --git a/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php b/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php
index 855dee96..4d0eadcd 100644
--- a/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php
+++ b/apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php
@@ -95,8 +95,9 @@
])
->get(route('admin.operations.index', ['workspace' => $workspaceId, 'environment_id' => (int) $hintedTenant->getKey()]))
->assertOk()
- ->assertSee(__('localization.shell.no_environment_selected'))
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee(__('localization.shell.environment_scope').': Hinted Topbar ManagedEnvironment')
->assertDontSee(__('localization.shell.environment_scope').': Remembered Topbar ManagedEnvironment');
});
diff --git a/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php b/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
index 454db154..310296f8 100644
--- a/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
+++ b/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
@@ -1707,7 +1707,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
session()->forget(WorkspaceContext::SESSION_KEY);
Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run])
- ->assertActionVisible('operate_hub_scope_run_detail')
+ ->assertActionDoesNotExist('operate_hub_scope_run_detail')
->assertActionVisible('operate_hub_back_to_operations')
->assertActionVisible('refresh');
diff --git a/apps/platform/tests/Feature/Localization/EnvironmentContextTerminologyTest.php b/apps/platform/tests/Feature/Localization/EnvironmentContextTerminologyTest.php
index a5e62c6f..ebcc6b61 100644
--- a/apps/platform/tests/Feature/Localization/EnvironmentContextTerminologyTest.php
+++ b/apps/platform/tests/Feature/Localization/EnvironmentContextTerminologyTest.php
@@ -55,7 +55,7 @@
->get(route('admin.workspace.managed-environments.index', ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Managed environments')
- ->assertSee('Choose environment')
+ ->assertDontSee('Choose environment')
->assertSee('Add environment')
->assertDontSee('Managed tenants')
->assertDontSee('Choose tenant')
diff --git a/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueWorkspaceHubContractTest.php b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueWorkspaceHubContractTest.php
index 935b2334..5ad64466 100644
--- a/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueWorkspaceHubContractTest.php
+++ b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueWorkspaceHubContractTest.php
@@ -72,7 +72,8 @@ function spec314FindingException(ManagedEnvironment $environment, User $user, st
$this->get($url)
->assertOk()
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Queue Environment A');
});
@@ -101,7 +102,8 @@ function spec314FindingException(ManagedEnvironment $environment, User $user, st
$this->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
->assertOk()
- ->assertSee(__('localization.shell.no_environment_selected'));
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'));
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
diff --git a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php
index e3642c46..9ef045bf 100644
--- a/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php
+++ b/apps/platform/tests/Feature/Monitoring/HeaderContextBarTest.php
@@ -293,7 +293,7 @@
])
->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertSee('Canonical workspace view')
->assertSee('No environment context is currently selected.');
});
diff --git a/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php
index 989ea23a..47d31138 100644
--- a/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php
+++ b/apps/platform/tests/Feature/Monitoring/OperationsTenantScopeTest.php
@@ -55,7 +55,7 @@
->assertOk()
->assertSee('Policy sync')
->assertSee('Inventory sync')
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name);
});
@@ -105,7 +105,7 @@
->assertSee($tenantA->name)
->assertSee('Policy sync')
->assertSee('Inventory sync')
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee(__('localization.shell.environment_scope').': '.$tenantA->name);
});
diff --git a/apps/platform/tests/Feature/Monitoring/OperationsWorkspaceHubContractTest.php b/apps/platform/tests/Feature/Monitoring/OperationsWorkspaceHubContractTest.php
index c2b7f73b..addc6118 100644
--- a/apps/platform/tests/Feature/Monitoring/OperationsWorkspaceHubContractTest.php
+++ b/apps/platform/tests/Feature/Monitoring/OperationsWorkspaceHubContractTest.php
@@ -31,8 +31,9 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get($url)
->assertOk()
- ->assertSee(__('localization.shell.no_environment_selected'))
- ->assertSee(__('localization.shell.all_environments'));
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
+ ->assertDontSee(__('localization.shell.all_environments'));
});
it('Spec314 operations clean workspace entry sees runs across entitled environments', function (): void {
diff --git a/apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php b/apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php
index 37fc0a3b..010b3de9 100644
--- a/apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php
+++ b/apps/platform/tests/Feature/Navigation/Spec321AlertsAuditEnvironmentFilterContractTest.php
@@ -162,7 +162,8 @@ function spec321QueryKeys(string $url): array
->assertOk()
->assertSee('Environment filter:')
->assertSee('Spec321 Environment A')
- ->assertSee('Clear filter');
+ ->assertSee('Clear filter')
+ ->assertDontSee(__('localization.shell.all_environments'));
$values = spec321AlertsKpiValues(
Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()])
@@ -198,6 +199,7 @@ function spec321QueryKeys(string $url): array
->assertSee('Environment filter:')
->assertSee('Spec321 Environment A')
->assertSee('Clear filter')
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey())
->assertCanSeeTableRecords([$records['deliveryA']])
->assertCanNotSeeTableRecords([$records['deliveryB']]);
diff --git a/apps/platform/tests/Feature/Navigation/Spec338EnvironmentSidebarSeparationTest.php b/apps/platform/tests/Feature/Navigation/Spec338EnvironmentSidebarSeparationTest.php
new file mode 100644
index 00000000..66920b20
--- /dev/null
+++ b/apps/platform/tests/Feature/Navigation/Spec338EnvironmentSidebarSeparationTest.php
@@ -0,0 +1,119 @@
+
+ */
+function spec338EnvironmentNavigationRows(array $items): array
+{
+ $rows = [];
+
+ foreach ($items as $item) {
+ if (! $item instanceof NavigationItem || ! $item->isVisible()) {
+ continue;
+ }
+
+ $group = $item->getGroup();
+
+ $rows[] = [
+ 'label' => $item->getLabel(),
+ 'group' => $group instanceof UnitEnum ? $group->name : (is_string($group) ? $group : null),
+ 'url' => $item->getUrl(),
+ 'parent' => $item->getParentItem(),
+ ];
+
+ $childItems = $item->getChildItems();
+
+ if ($childItems instanceof Traversable) {
+ $childItems = iterator_to_array($childItems);
+ }
+
+ if (is_array($childItems) && $childItems !== []) {
+ $rows = [
+ ...$rows,
+ ...spec338EnvironmentNavigationRows($childItems),
+ ];
+ }
+ }
+
+ return $rows;
+}
+
+function spec338FindNavigationRow(string $label, ?string $group = null): ?array
+{
+ foreach (spec338EnvironmentNavigationRows(Filament::getCurrentOrDefaultPanel()->getNavigationItems()) as $row) {
+ if ($row['label'] !== $label) {
+ continue;
+ }
+
+ if ($group !== null && $row['group'] !== $group) {
+ continue;
+ }
+
+ return $row;
+ }
+
+ return null;
+}
+
+it('separates workspace-wide and workspace-admin links from environment navigation', function (): void {
+ $environment = ManagedEnvironment::factory()->active()->create(['name' => 'Spec338 IA Environment']);
+ [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
+
+ $workspace = $environment->workspace()->firstOrFail();
+
+ $workspaceWideGroup = __('localization.navigation.workspace_wide');
+ $workspaceAdminGroup = __('localization.navigation.workspace_admin');
+
+ $response = $this->actingAs($user)
+ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
+ ->get(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment));
+
+ $response
+ ->assertOk()
+ ->assertSee($workspaceWideGroup)
+ ->assertSee($workspaceAdminGroup);
+
+ $overview = spec338FindNavigationRow('Overview');
+ $operations = spec338FindNavigationRow(__('localization.navigation.operations'), $workspaceWideGroup);
+ $manageWorkspaces = spec338FindNavigationRow(__('localization.navigation.manage_workspaces'), $workspaceAdminGroup);
+ $integrations = spec338FindNavigationRow(__('localization.navigation.integrations'), $workspaceAdminGroup);
+
+ expect($overview)->not->toBeNull()
+ ->and($overview['url'])->toBe(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment))
+ ->and($operations)->not->toBeNull()
+ ->and($operations['url'])->toContain('environment_id='.(int) $environment->getKey())
+ ->and($operations['url'])->not->toContain('tableFilters')
+ ->and($operations['url'])->not->toContain('managed_environment_id=')
+ ->and($manageWorkspaces)->not->toBeNull()
+ ->and($integrations)->not->toBeNull()
+ ->and($integrations['url'])->toContain('environment_id='.(int) $environment->getKey());
+});
+
+it('keeps workspace-owned sidebar groups normal on workspace pages', function (): void {
+ $environment = ManagedEnvironment::factory()->active()->create(['name' => 'Spec338 Workspace IA Environment']);
+ [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
+
+ $workspace = $environment->workspace()->firstOrFail();
+
+ $this->actingAs($user)
+ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
+ ->get(route('admin.workspace.home', ['workspace' => $workspace]))
+ ->assertOk();
+
+ $operations = spec338FindNavigationRow(__('localization.navigation.operations'));
+
+ expect($operations)->not->toBeNull()
+ ->and($operations['url'])->not->toContain('environment_id=')
+ ->and($operations['url'])->not->toContain('tableFilters');
+});
diff --git a/apps/platform/tests/Feature/Navigation/Spec338OperationRunLinksQueryContractTest.php b/apps/platform/tests/Feature/Navigation/Spec338OperationRunLinksQueryContractTest.php
new file mode 100644
index 00000000..1821da45
--- /dev/null
+++ b/apps/platform/tests/Feature/Navigation/Spec338OperationRunLinksQueryContractTest.php
@@ -0,0 +1,51 @@
+toContain('/admin/workspaces/'.$workspace->getRouteKey().'/operations')
+ ->toContain('operation_type=inventory.sync')
+ ->not->toContain('tableFilters')
+ ->not->toContain('inventory_sync');
+});
+
+it('Spec338 operations hub maps operation_type query into the type table filter', function (): void {
+ $environment = ManagedEnvironment::factory()->active()->create([
+ 'name' => 'Spec338 Environment A',
+ 'external_id' => 'spec338-environment-a',
+ ]);
+ [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
+ $workspace = $environment->workspace()->firstOrFail();
+
+ $runInventory = OperationRun::factory()
+ ->forTenant($environment)
+ ->create(['type' => 'inventory_sync']);
+
+ $runPolicy = OperationRun::factory()
+ ->forTenant($environment)
+ ->create(['type' => 'policy.sync']);
+
+ $this->actingAs($user);
+ setAdminPanelContext();
+ session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
+
+ Livewire::withQueryParams(['operation_type' => 'inventory.sync'])
+ ->actingAs($user)
+ ->test(Operations::class)
+ ->assertSet('tableFilters.type.value', 'inventory.sync')
+ ->assertCanSeeTableRecords([$runInventory])
+ ->assertCanNotSeeTableRecords([$runPolicy]);
+});
+
diff --git a/apps/platform/tests/Feature/Navigation/Spec338SidebarScopeIndicatorTest.php b/apps/platform/tests/Feature/Navigation/Spec338SidebarScopeIndicatorTest.php
new file mode 100644
index 00000000..2a3a7877
--- /dev/null
+++ b/apps/platform/tests/Feature/Navigation/Spec338SidebarScopeIndicatorTest.php
@@ -0,0 +1,50 @@
+active()->create(['name' => 'Spec338 Sidebar Workspace Environment']);
+ [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
+
+ $workspace = $environment->workspace()->firstOrFail();
+
+ ManagedEnvironment::factory()->active()->create([
+ 'name' => 'Spec338 Sidebar Inaccessible Environment',
+ 'workspace_id' => (int) $workspace->getKey(),
+ ]);
+
+ $accessibleEnvironmentCount = 1;
+
+ $this->actingAs($user)
+ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
+ ->get(route('admin.workspace.home', ['workspace' => $workspace]))
+ ->assertOk()
+ ->assertSee('data-testid="admin-sidebar-scope-indicator"', false)
+ ->assertSee(__('localization.shell.workspace_scope_short'))
+ ->assertSee($workspace->name)
+ ->assertSee(trans_choice('localization.shell.environment_count', $accessibleEnvironmentCount, ['count' => $accessibleEnvironmentCount]))
+ ->assertDontSee(trans_choice('localization.shell.environment_count', 2, ['count' => 2]));
+});
+
+it('shows an explicit environment scope indicator in the sidebar on environment-owned pages', function (): void {
+ $environment = ManagedEnvironment::factory()->active()->create(['name' => 'Spec338 Sidebar Environment']);
+ [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
+
+ $workspace = $environment->workspace()->firstOrFail();
+
+ $this->actingAs($user)
+ ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
+ ->get(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment))
+ ->assertOk()
+ ->assertSee('data-testid="admin-sidebar-scope-indicator"', false)
+ ->assertSee(__('localization.shell.environment_scope_short'))
+ ->assertSee($environment->name)
+ ->assertSee(__('localization.shell.workspace_context_label', ['workspace' => $workspace->name]));
+});
diff --git a/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php b/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
index 82d556b9..fd845ae6 100644
--- a/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
+++ b/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
@@ -381,7 +381,7 @@
])
->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertSee('Canonical workspace view')
->assertSee('No environment context is currently selected.');
});
diff --git a/apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php b/apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php
index d86f966a..24a13e00 100644
--- a/apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php
+++ b/apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php
@@ -45,7 +45,7 @@
$this->withSession($session)
->get(route('admin.operations.index', ['workspace' => (int) $run->workspace_id]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee('Scope: ManagedEnvironment')
->assertDontSee('Scope: Workspace');
@@ -55,7 +55,7 @@
'run' => (int) $run->getKey(),
]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee('Scope: ManagedEnvironment')
->assertDontSee('Scope: Workspace');
@@ -63,14 +63,14 @@
->followingRedirects()
->get(AlertsCluster::getUrl(panel: 'admin'))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee('Scope: ManagedEnvironment')
->assertDontSee('Scope: Workspace');
$this->withSession($session)
->get(route('admin.monitoring.audit-log'))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee('Scope: ManagedEnvironment')
->assertDontSee('Scope: Workspace');
});
@@ -494,7 +494,7 @@
expect($resolved?->is($routedTenant))->toBeTrue();
})->group('ops-ux');
-it('shows all-environments shell label on workspace operations even when tenant context is active', function (): void {
+it('keeps workspace operations tenantless without showing a generic all-environments label', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user);
@@ -504,7 +504,7 @@
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.index', ['workspace' => (int) $tenant->workspace_id]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee('Scope: ManagedEnvironment')
->assertDontSee('Scope: Workspace')
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name);
@@ -641,7 +641,7 @@
$response
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertSee('Canonical workspace view')
->assertSee('No environment context is currently selected.');
})->group('ops-ux');
diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php
index e2325ade..43a0eb64 100644
--- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php
+++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php
@@ -52,7 +52,8 @@
->assertOk()
->assertSee('Spec314 Provider A')
->assertSee('Spec314 Provider B')
- ->assertSee(__('localization.shell.no_environment_selected'));
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'));
});
it('Spec314 provider connections keeps explicit environment CTA filters explicit', function (): void {
diff --git a/apps/platform/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php b/apps/platform/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php
index 38f8934d..c0e8f0de 100644
--- a/apps/platform/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php
+++ b/apps/platform/tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php
@@ -29,7 +29,7 @@
expect(Filament::getTenant())->toBe($tenant);
});
-it('renders workspace scope label when no tenant context is active', function (): void {
+it('does not render a generic all-environments badge when no tenant context is active', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
@@ -39,7 +39,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'));
+ ->assertDontSee(__('localization.shell.all_environments'));
expect(Filament::getTenant())->toBeNull();
});
diff --git a/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php b/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php
index 13d48b23..071c609b 100644
--- a/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php
+++ b/apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php
@@ -14,7 +14,7 @@
Http::preventStrayRequests();
});
-it('renders workspace scope label when tenant context is active on the workspace operations route', function (): void {
+it('keeps the workspace operations route tenantless without a generic all-environments badge', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
@@ -24,8 +24,9 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name)
->assertDontSee('Back to '.$tenant->name)
->assertDontSee(__('localization.shell.show_all_environments'));
@@ -45,7 +46,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get(route('admin.operations.index', ['workspace' => $entitledTenant->workspace]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee('Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name)
->assertDontSee(__('localization.shell.show_all_environments'));
@@ -80,7 +81,7 @@
])
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name);
});
diff --git a/apps/platform/tests/Feature/Spec085/RunDetailBackAffordanceTest.php b/apps/platform/tests/Feature/Spec085/RunDetailBackAffordanceTest.php
index 009cfcc9..5c30789f 100644
--- a/apps/platform/tests/Feature/Spec085/RunDetailBackAffordanceTest.php
+++ b/apps/platform/tests/Feature/Spec085/RunDetailBackAffordanceTest.php
@@ -84,7 +84,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get(route('admin.operations.view', ['workspace' => $entitledTenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk()
- ->assertSee(__('localization.shell.all_environments'))
+ ->assertDontSee(__('localization.shell.all_environments'))
->assertSee('Back to Operations')
->assertDontSee('← Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name)
diff --git a/apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php b/apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
index 4b43847d..53e4252c 100644
--- a/apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
+++ b/apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
@@ -148,5 +148,6 @@
->assertSee('Header Active ManagedEnvironment')
->assertDontSee('Header Onboarding ManagedEnvironment')
->assertDontSee('Header Archived ManagedEnvironment')
- ->assertSee(__('localization.shell.no_environment_selected'));
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'));
});
diff --git a/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php b/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php
index 9cd62e70..21f3a9c2 100644
--- a/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php
+++ b/apps/platform/tests/Feature/Workspaces/ChooseEnvironmentPageTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Pages\EnvironmentDashboard;
+use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\ManagedEnvironment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@@ -119,13 +120,14 @@
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertSuccessful()
- ->assertSee(__('localization.shell.all_environments'));
+ ->assertDontSee(__('localization.shell.all_environments'));
});
it('redirects clear selected tenant from the evidence index to the workspace-safe evidence overview', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant($tenant, true);
+ $evidenceIndexPath = (string) parse_url(EvidenceSnapshotResource::getUrl('index', tenant: $tenant, panel: 'admin'), PHP_URL_PATH);
$this->actingAs($user)
->withSession([
@@ -134,7 +136,7 @@
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
])
- ->from('/admin/evidence')
+ ->from($evidenceIndexPath)
->post(route('admin.clear-environment-context'))
->assertRedirect(route('admin.evidence.overview'));
diff --git a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php
index c74a8170..6c2ff3e0 100644
--- a/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php
+++ b/apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php
@@ -43,7 +43,8 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(route('admin.operations.index', ['workspace' => $workspaceTenant->workspace, 'tenant' => $foreignTenant->external_id]))
->assertOk()
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Rejected Foreign ManagedEnvironment');
});
@@ -77,7 +78,8 @@
->followingRedirects()
->get($url)
->assertOk()
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Hinted ManagedEnvironment')
->assertDontSee(__('localization.shell.environment_scope').': Remembered ManagedEnvironment')
->assertDontSee('Back to Hinted ManagedEnvironment')
diff --git a/apps/platform/tests/Feature/Workspaces/Spec195ManagedEnvironmentsLandingTest.php b/apps/platform/tests/Feature/Workspaces/Spec195ManagedEnvironmentsLandingTest.php
index 7de3467f..94eabb4d 100644
--- a/apps/platform/tests/Feature/Workspaces/Spec195ManagedEnvironmentsLandingTest.php
+++ b/apps/platform/tests/Feature/Workspaces/Spec195ManagedEnvironmentsLandingTest.php
@@ -2,7 +2,6 @@
declare(strict_types=1);
-use App\Filament\Pages\ChooseEnvironment;
use App\Filament\Pages\EnvironmentDashboard;
use App\Filament\Pages\Workspaces\ManagedEnvironmentsLanding;
use App\Filament\Resources\ManagedEnvironmentResource;
@@ -44,7 +43,7 @@
->assertDontSee(__('localization.shell.no_environment_selected'));
});
-it('routes the managed-tenants landing back into the chooser flow and open-tenant flow', function (): void {
+it('routes the managed-tenants landing card into the open-environment flow', function (): void {
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-routing']);
$user = User::factory()->create();
@@ -70,14 +69,6 @@
->test(ManagedEnvironmentsLanding::class, ['workspace' => $workspace]);
$component
- ->call('goToChooseEnvironment')
- ->assertRedirect(route('admin.workspace.managed-environments.index', ['workspace' => $workspace]));
-
- $this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
- session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
-
- Livewire::actingAs($user)
- ->test(ManagedEnvironmentsLanding::class, ['workspace' => $workspace])
->call('openTenant', $tenant->getKey())
->assertRedirect(EnvironmentDashboard::getUrl(tenant: $tenant));
});
diff --git a/apps/platform/tests/Feature/Workspaces/WorkspaceHubContextContractTest.php b/apps/platform/tests/Feature/Workspaces/WorkspaceHubContextContractTest.php
index e5de8268..93491612 100644
--- a/apps/platform/tests/Feature/Workspaces/WorkspaceHubContextContractTest.php
+++ b/apps/platform/tests/Feature/Workspaces/WorkspaceHubContextContractTest.php
@@ -38,7 +38,8 @@
->get($url)
->assertOk()
->assertSee($workspace->name)
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Active Environment Context');
})->with([
'provider connections' => ['provider_connections', fn ($workspace): string => ProviderConnectionResource::getUrl('index', panel: 'admin')],
@@ -72,6 +73,7 @@
$this->get($url)
->assertOk()
- ->assertSee(__('localization.shell.no_environment_selected'))
+ ->assertSee(__('localization.shell.choose_environment'))
+ ->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Remembered Environment Boundary');
});
diff --git a/apps/platform/tests/Unit/Tenants/AdminSurfaceScopeTest.php b/apps/platform/tests/Unit/Tenants/AdminSurfaceScopeTest.php
index a536abb5..6a38c07e 100644
--- a/apps/platform/tests/Unit/Tenants/AdminSurfaceScopeTest.php
+++ b/apps/platform/tests/Unit/Tenants/AdminSurfaceScopeTest.php
@@ -28,7 +28,7 @@
'findings intake' => ['/admin/findings/intake', AdminSurfaceScope::WorkspaceOwnedAnalysisSurface],
'findings hygiene' => ['/admin/findings/hygiene', AdminSurfaceScope::WorkspaceOwnedAnalysisSurface],
'cross-environment compare' => ['/admin/cross-environment-compare', AdminSurfaceScope::WorkspaceOwnedAnalysisSurface],
- 'tenant scoped evidence detail' => ['/admin/evidence/123', AdminSurfaceScope::EnvironmentScopedEvidence],
+ 'environment evidence detail' => ['/admin/workspaces/acme/environments/tenant-123/evidence/123', AdminSurfaceScope::EnvironmentBound],
'evidence overview' => ['/admin/evidence/overview', AdminSurfaceScope::WorkspaceWideSurface],
'customer review workspace' => ['/admin/reviews/workspace', AdminSurfaceScope::WorkspaceWideSurface],
'review register' => ['/admin/reviews', AdminSurfaceScope::WorkspaceWideSurface],
diff --git a/specs/338-workspace-environment-resource-scope-contract/checklists/requirements.md b/specs/338-workspace-environment-resource-scope-contract/checklists/requirements.md
new file mode 100644
index 00000000..6d9f98e6
--- /dev/null
+++ b/specs/338-workspace-environment-resource-scope-contract/checklists/requirements.md
@@ -0,0 +1,70 @@
+# Specification Quality Checklist: Spec 338 - Workspace / Environment Resource Scope Contract
+
+**Purpose**: Validate specification completeness, preparation quality, and readiness before implementation.
+**Created**: 2026-05-30
+**Feature**: `specs/338-workspace-environment-resource-scope-contract/spec.md`
+
+## Candidate Selection Gate
+
+- [x] Spec 338 was directly provided/promoted by the user as the preparation target.
+- [x] Completed-spec guardrail checked that no existing `specs/338-*` package existed before creation.
+- [x] Branch guardrail checked that no existing `338-*` branch existed locally before creation.
+- [x] `docs/product/spec-candidates.md` was inspected; it states the active auto-prep queue is empty, so this spec proceeds only because the user directly supplied/promoted it.
+- [x] Related completed/historical specs were treated as context only and remain unchanged:
+ - `specs/311-workspace-environment-surface-scope-contract/` (implemented + validated)
+ - `specs/320-workspace-owned-analysis-surface-registration-shell-cutover/` (completed)
+ - `specs/322-browser-no-drift-regression-guard/` (guard posture)
+
+## Close Alternatives Deferred
+
+- [x] Provider Connection Scope Hardening (already a promoted candidate) is deferred; this spec focuses on link/query and evidence scope seams.
+- [x] Canonical Link / Query Cleanup remains related and partially overlaps; Spec 338 scope is kept tight around confirmed helper outputs and evidence special casing.
+- [x] Environment Resource Context Follow-through remains separate (resource internals); this spec focuses on contract seams and helper outputs.
+
+## Content Quality
+
+- [x] Problem statement is operator-visible and framed as “scope drift + non-canonical deep links”, not internal refactor desire.
+- [x] Scope is bounded to confirmed seams: `OperationRunLinks` query output contract and evidence special casing; baseline navigation is regression-only.
+- [x] Explicit non-goals prevent reopening Spec 311/320 scope work or starting a navigation redesign.
+- [x] Mandatory Spec Candidate Check is complete (score + decision included).
+- [x] No unresolved placeholder markers remain.
+
+## Requirement Completeness
+
+- [x] Scope taxonomy and link/query contract are documented inside `spec.md`.
+- [x] Required runtime decisions are explicit for:
+ - Operation type deep links (no `tableFilters` in helper output)
+ - Evidence `/admin/evidence/*` special casing (remove if stale, otherwise document + test)
+ - baseline ownership/navigation (regression-only)
+- [x] Acceptance criteria are concrete and testable.
+
+## Plan Quality
+
+- [x] Plan records stack context (Laravel/Filament/Livewire/Pest/PostgreSQL) and the no-migration/no-route-rewrite constraint.
+- [x] Plan includes a “failing tests first” phase for contract changes.
+- [x] OperationRun UX Impact is limited to link semantics; no lifecycle changes are planned.
+
+## Task Quality
+
+- [x] Tasks are ordered from repo-truth → failing tests → implementation → validation.
+- [x] Task IDs follow the required checkbox format and are verifiable.
+- [x] Tasks include explicit non-goals to prevent scope creep.
+
+## Constitution / Repo Alignment
+
+- [x] No new persisted entity, table, or artifact is introduced by this spec.
+- [x] No new taxonomy framework is proposed; the spec reuses existing navigation/scope seams (`AdminSurfaceScope`, hub registry, navigation context).
+- [x] Provider boundary is respected: platform-core scope keys (`environment_id`) remain separate from provider “tenant” identity semantics.
+- [x] Filament v5 / Livewire v4 compliance is assumed by project baseline; this spec does not introduce version drift.
+
+## Preparation Analysis Outcome
+
+- [x] Preparation artifacts (`spec.md`, `plan.md`, `tasks.md`) are internally consistent after manual `/speckit.analyze`-style review.
+- [x] Every acceptance criterion maps to one or more tasks.
+- [x] No preparation issue requires application implementation to resolve.
+- [x] Candidate Selection Gate result: PASS.
+- [x] Spec Readiness Gate result: PASS for later implementation.
+
+## Notes
+
+- Repository has prompt/agent definitions for `speckit.tasks` and `speckit.analyze`, but no local executable Bash command for those phases. Tasks and analysis were therefore produced repo-conformantly from templates and checked manually in this checklist.
diff --git a/specs/338-workspace-environment-resource-scope-contract/plan.md b/specs/338-workspace-environment-resource-scope-contract/plan.md
new file mode 100644
index 00000000..ad095922
--- /dev/null
+++ b/specs/338-workspace-environment-resource-scope-contract/plan.md
@@ -0,0 +1,123 @@
+# Implementation Plan: Spec 338 - Workspace / Environment Resource Scope Contract
+
+- Branch: `338-workspace-environment-resource-scope-contract`
+- Date: 2026-05-30
+- Spec: `specs/338-workspace-environment-resource-scope-contract/spec.md`
+- Input: User-provided Spec 338 draft + repo inspection for link/query seams.
+
+## Summary
+
+Harden TenantPilot’s resource scope contract by tightening the canonical deep-link and query contract for workspace hubs and by eliminating first-party helper outputs that encode Filament internals (`tableFilters[...]`) as a product-level URL contract.
+
+This is contract-first and targeted:
+
+- `OperationRunLinks::index(..., operationType: ...)` must stop emitting `tableFilters[type][value]`.
+- Evidence scope special casing under `/admin/evidence/*` must be either proven real and contractual, or removed as stale ambiguity.
+- Environment-owned sidebar navigation must keep environment-owned entries primary and move workspace-wide/admin links into explicitly labeled cross-scope groups.
+- Baseline ownership/navigation is regression-only (Spec 320 already completed; do not reopen).
+
+## Technical Context
+
+- Language/Version: PHP 8.4.15, Laravel 12.52.x.
+- Primary Dependencies: Filament 5.2.x, Livewire 4.1.x, Pest 4.x, Tailwind CSS 4.x.
+- Storage: PostgreSQL; no schema change expected.
+- Testing: Pest Feature tests + minimal browser smoke only if navigation presentation is materially affected.
+- Validation Lanes: fast-feedback (Feature) + browser (smoke, scoped).
+- Target Platform: Sail locally; Dokploy/container deployment posture unchanged.
+- Project Type: Laravel monolith under `apps/platform`.
+- Constraints: No new persisted truth, migrations, packages, env vars, queue/scheduler changes, or route architecture rewrite.
+
+## UI / Surface Guardrail Plan
+
+- **Guardrail scope**: changed existing operator-facing scope/link behavior (navigation + deep links).
+- **Affected surfaces**:
+ - Workspace hub links to Operations (`OperationRunLinks` and any `CanonicalNavigationContext` filter payload usage).
+ - Evidence Overview hub + “clear environment context” redirect behavior.
+ - Environment → workspace hub “filtered” links (`environment_id` must remain canonical).
+ - Environment sidebar grouping for workspace-wide/admin links.
+- **Native vs custom**: native Filament + existing project navigation helpers; no custom UI framework.
+- **Shared-family relevance**: navigation entry points, scope presentation, deep links, hub filtering, OperationRun “view in collection” links.
+- **State layers in scope**: shell scope (route-owned), URL query contract, local table filter state (internal translation only).
+- **Handling modes**: review-mandatory.
+- **Required tests / smoke**:
+ - Feature tests for URL contract + helper output.
+ - Optional minimal browser smoke when sidebar/scope presentation changes are user-visible.
+- **UI/Productization coverage**: no new routes/pages expected; capture screenshots only when needed to prove a scope regression fix.
+
+## Shared Pattern & System Fit
+
+- **Cross-cutting feature marker**: yes.
+- **Systems touched (expected)**:
+ - `apps/platform/app/Support/OperationRunLinks.php`
+ - `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
+ - `apps/platform/app/Support/Navigation/AdminSurfaceScope.php`
+ - `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php`
+ - `apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php`
+ - `apps/platform/app/Support/Navigation/WorkspaceHubRegistry.php`
+ - `apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php`
+- **New abstraction introduced?**: `WorkspaceHubNavigation`, a narrow helper for environment-surface hub grouping and explicit `environment_id` URL carry.
+- **Shared abstractions reused**: existing `AdminSurfaceScope` + hub registry + navigation context; do not create a second taxonomy framework.
+- **Bounded deviation**: if Filament requires `tableFilters` internally, keep it internal (page-level translation) and keep first-party helper output contract stable.
+
+## OperationRun UX Impact
+
+Link semantics only (no new OperationRun types, no lifecycle changes):
+
+- Stop emitting Filament internals as deep-link contract for operation type filtering.
+- Decide between:
+ 1) `operation_type=` accepted by Operations page and mapped to internal table state, or
+ 2) removing operation-type deep-linking entirely if safe mapping is not feasible without bloat.
+
+## Implementation Approach
+
+### Phase 1 — Repo truth + failing tests first
+
+- Inventory current first-party helper outputs and navigation contexts that emit:
+ - `tableFilters[...]` (confirmed in `OperationRunLinks`; re-check `CanonicalNavigationContext` usage and call sites)
+ - legacy `/admin/evidence/*` special casing branches (`AdminSurfaceScope`, `ClearEnvironmentContextController`)
+- Add failing tests that lock the desired contract:
+ - `OperationRunLinks::index(..., operationType: ...)` must not contain `tableFilters`.
+ - Evidence Overview is workspace hub; any `/admin/evidence/*` environment-scope handling is either intentional + tested or removed.
+
+### Phase 2 — OperationRunLinks query contract
+
+- Change `OperationRunLinks::index`:
+ - replace `tableFilters[type][value]` emission with a stable query key (`operation_type`) or remove operation-type deep linking.
+- Update the Operations page boundary to translate `operation_type` into internal table state where needed (keep `environment_id` canonical).
+
+### Phase 3 — Navigation context payload hygiene
+
+- Re-check `CanonicalNavigationContext::toQuery()` usage:
+ - prefer keeping navigation metadata under `nav[...]` only,
+ - avoid emitting additional top-level filter payload that encodes `tableFilters` for hub filtering when `environment_id` is sufficient.
+- Adjust the specific call sites (e.g. RelatedNavigationResolver contexts) that currently inject `tableFilters[managed_environment_id]` into query strings when linking to Operations.
+
+### Phase 4 — Evidence scope special casing
+
+- Verify actual route inventory for `/admin/evidence/*` beyond overview.
+- Remove stale classification or redirect rules only when route inventory proves they are not real, or explicitly document + test the remaining route family if it is still reachable.
+
+### Phase 5 — Validation and regression posture
+
+- Split Environment sidebar IA:
+ - keep environment-owned resources in their domain groups,
+ - move workspace hub entries into “Workspace-wide” on environment pages,
+ - move workspace configuration/admin entries into “Workspace admin” on environment pages,
+ - preserve explicit `environment_id` only for workspace hubs that already accept that filter.
+
+Run narrow tests first:
+
+- `cd apps/platform && ./vendor/bin/sail artisan test --compact `
+- `cd apps/platform && ./vendor/bin/sail pint --dirty --format agent`
+- `git diff --check`
+
+Run minimal browser smoke only if link/scope changes are user-visible in navigation:
+
+- `cd apps/platform && php vendor/bin/pest tests/Browser --filter=Spec338 --compact`
+
+## Deployment / Ops Impact
+
+- Migrations: none expected.
+- Env vars: none expected.
+- Queues/scheduler: none expected.
+- Filament assets: no new registered assets expected; `filament:assets` posture unchanged.
diff --git a/specs/338-workspace-environment-resource-scope-contract/spec.md b/specs/338-workspace-environment-resource-scope-contract/spec.md
new file mode 100644
index 00000000..1ca31e45
--- /dev/null
+++ b/specs/338-workspace-environment-resource-scope-contract/spec.md
@@ -0,0 +1,259 @@
+# Feature Specification: Spec 338 - Workspace / Environment Resource Scope Contract
+
+**Feature Branch**: `338-workspace-environment-resource-scope-contract`
+**Created**: 2026-05-30
+**Status**: Draft
+**Input**: User-provided Spec 338 draft (“Contract-/Guard-Spec” for workspace/environment resource ownership + link/query hygiene)
+
+## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
+
+- **Problem**: TenantPilot’s workspace/environment scope foundations exist (Specs 311/319/320/321), but remaining link/query seams and navigation registration can still encode “mixed ownership” (workspace-owned surfaces appearing environment-owned, or workspace hub filters encoded as hidden context / framework internals).
+- **Today's failure**:
+ - First-party deep links can still emit Filament internal query keys (notably `tableFilters[...]`) instead of a stable product-level contract.
+ - Environment → workspace hub links can drift between “route scope” and “filter scope” depending on the helper used.
+ - Evidence has legacy `/admin/evidence/*` classification/special casing that must be either proven real and intentional, or removed as stale to reduce ambiguity.
+- **User-visible improvement**: Operators can trust that:
+ - route scope determines shell/sidebar;
+ - workspace hubs filter by a stable, explicit query contract (`environment_id`, and where needed `operation_type`);
+ - environment navigation does not claim workspace-owned portfolio surfaces as environment-owned;
+ - the sidebar exposes a direct scope signal so workspace-level and environment-level pages are distinguishable without reading the URL.
+ - workspace-wide pages do not render a generic “All environments” header/scope badge when the page is already tenantless; explicit environment filters remain visible through filter banners and table chips.
+- **Smallest enterprise-capable version**: Document the canonical ownership taxonomy and enforce only the highest-risk seams with tests:
+ - stop first-party helpers from emitting `tableFilters[...]` for hub deep links (especially Operations),
+ - ensure Evidence scope is explicit (workspace hub vs environment-owned resources),
+ - keep baseline ownership/navigation contract regression-proof (no reopen of Spec 320; fix only if regression is proven).
+- **Explicit non-goals**:
+ - no broad UI redesign of the admin shell, sidebars, or page layouts,
+ - no route restructuring (keep canonical route families as-is),
+ - no workspace/environment data model or schema changes,
+ - no Provider Connections “scope split” feature (defer to the already-listed candidate),
+ - no rewrite of Spec 311/320 behavior unless a regression is proven by tests.
+- **Permanent complexity imported**: narrow link/query contract mapping (`operation_type` deep-link), a small set of guard tests, and clarified operator-copy expectations (only where proven misleading).
+- **Why now**: Current productization and audit lanes depend on stable, explicit scope and deep links; leaving internal query keys in first-party helpers makes future specs copy the wrong contract.
+- **Why not local**: Fixing one page’s deep link without a contract + guard tests leaves the next helper or navigation entry free to reintroduce the same ambiguity.
+- **Approval class**: Cleanup / Consolidation (contract + guard hardening over existing foundations).
+- **Red flags triggered**: Cross-surface contract + shared link helper changes. **Defense**: scope is bounded to confirmed seams; no new taxonomy framework or persisted truth; tests enforce stability.
+- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
+- **Decision**: approve.
+
+## Summary
+
+TenantPilot already has strong workspace/environment scope foundations. This spec locks down a **resource ownership + link/query contract** so that:
+
+1) workspace-owned surfaces stay workspace-owned (even when entered from environment context),
+2) workspace hubs are filtered only via explicit, product-level query keys (`environment_id`, and optionally `operation_type`),
+3) environment-owned detail surfaces remain environment-route-owned,
+4) first-party helpers stop emitting Filament table internals (`tableFilters[...]`) as canonical deep link contract,
+5) the sidebar presents explicit workspace vs environment scope identity.
+
+This is a contract-first spec with targeted runtime fixes only.
+
+## Spec Scope Fields *(mandatory)*
+
+- **Scope**: canonical-view (navigation + link/query contract)
+- **Primary Routes (representative)**:
+ - Workspace hubs: `/admin/workspaces/{workspace}/operations`, `/admin/evidence/overview`, `/admin/alerts`, `/admin/audit-log`
+ - Workspace-owned portfolio surfaces: `/admin/baseline-profiles`, `/admin/baseline-snapshots`
+ - Environment-owned detail surfaces: `/admin/workspaces/{workspace}/environments/{environment}/...`
+- **Data Ownership**: no ownership model change. This spec is about UI scope signals + link/query contracts, not table ownership.
+- **RBAC**: no new capabilities. Existing workspace membership and tenant/environment membership continue to gate visibility and access.
+
+For canonical-view specs, the spec MUST define:
+
+- **Default filter behavior when tenant-context is active**:
+ - Workspace hubs MUST NOT silently infer environment filtering from remembered environment/topbar selection.
+ - If a workspace hub is filtered, it MUST be via explicit query (`environment_id`) or explicit visible UI filter state.
+ - Environment-owned routes MUST include the environment in the route (no query-derived environment ownership).
+- **Explicit entitlement checks preventing cross-tenant leakage**:
+ - `environment_id` MUST be validated as “belongs to current workspace” AND “actor is entitled”; otherwise ignore/deny safely.
+ - Canonical deep links must not widen scope through implicit session context.
+
+## Canonical Scope Taxonomy (product contract)
+
+Every reachable surface is classified as exactly one:
+
+### A. Workspace-owned source of truth
+
+Workspace-owned; may aggregate across environments; does not require an environment route.
+
+### B. Workspace hub with optional local environment filter
+
+Workspace-owned monitoring/governance hubs that may filter by environment via explicit query/UI.
+
+Rules:
+- Route determines shell (workspace shell).
+- Public filter query key is `environment_id`.
+- Hubs must not infer filters from topbar “remembered environment”.
+
+### C. Environment-owned detail surface
+
+Belongs to exactly one Managed Environment.
+
+Rules:
+- Route includes workspace + environment: `/admin/workspaces/{workspace}/environments/{environment}/...`
+- Environment is not optional or query-derived.
+
+### D. Cross-environment / portfolio aggregation
+
+Compares/aggregates across multiple environments; must not pretend to be “current environment owned”.
+
+### E. Platform / system / utility
+
+System pages (`/system`, auth callbacks, choosers). Must not create hidden environment filters.
+
+### F. Invalid / needs split
+
+Any surface that mixes route scope, navigation scope, and data scope such that the operator cannot tell “what owns this”.
+
+## Routing / Link Contract
+
+### WorkspaceLink
+
+Workspace-owned surface without environment filter.
+
+### EnvironmentLink
+
+Environment-owned surface with environment in the route.
+
+### WorkspaceFilteredLink
+
+Workspace-owned hub filtered to one environment via explicit query.
+
+Allowed public filter keys:
+
+- `environment_id` (canonical)
+- `operation_type` (Operations-only, optional; see required decision D1)
+
+Forbidden as **first-party helper output** for hub scope (canonical deep-link contract):
+
+- `tenant`
+- `tenant_id`
+- `managed_environment_id`
+- `tenant_scope`
+- `tableFilters`
+
+Note: Filament may still persist table state in the URL after user interactions. This spec’s restriction is about **first-party helper outputs** and **canonical deep links**, not about banning every possible `tableFilters` appearance after manual operator filtering.
+
+## Required Runtime Decisions
+
+### D1 — OperationRunLinks operation type filter (confirmed repo seam)
+
+Repo evidence: `apps/platform/app/Support/OperationRunLinks.php` currently emits `tableFilters[type][value]` when `operationType` is provided.
+
+Decision:
+- `tableFilters[...]` must not be emitted by first-party helpers for operation-type deep links.
+- If operation-type deep-linking is needed, use a stable query key:
+ - `operation_type=`
+
+Acceptance:
+- `OperationRunLinks::index(..., operationType: ...)` does not emit `tableFilters`.
+- Operations page accepts `operation_type` and translates it into local table state, **or** operation-type deep links are removed (prefer correctness over leaking internals).
+
+### D2 — Evidence route special casing (confirmed repo seam)
+
+Repo evidence:
+- `/admin/evidence/overview` is a workspace hub route (`admin.evidence.overview`).
+- `apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php` and `apps/platform/app/Support/Navigation/AdminSurfaceScope.php` contain legacy handling/classification for `/admin/evidence/*` paths.
+
+Decision:
+- Keep Evidence Overview as workspace hub, with optional explicit `environment_id` filter.
+- Confirm whether any `/admin/evidence/*` non-overview paths are still real and intended:
+ - If not real, remove/neutralize stale classification branches.
+ - If real, document the intended contract and stop treating it as “mystery scope”.
+
+Acceptance:
+- No ambiguous third “environment-scoped evidence under `/admin/evidence/*`” remains without explicit contract + test.
+
+### D3 — Baseline ownership & navigation (regression-only)
+
+Repo evidence: Spec 320 completed classification for baseline library surfaces as workspace-owned analysis.
+
+Decision:
+- Do not reopen baseline ownership decisions in Spec 338.
+- Only change baseline navigation registration if a regression is proven by tests or UI contract failures on current branch.
+
+Acceptance:
+- Baseline Profiles/Snapshots remain workspace-owned surfaces; environment navigation must not claim them as environment-owned.
+
+## UI Surface Impact *(mandatory — UI-COV-001)*
+
+- [ ] No UI surface impact
+- [x] Existing page changed
+- [ ] New page/route added
+- [x] Navigation changed
+- [x] Filament panel/provider surface changed
+- [ ] New modal/drawer/wizard/action added
+- [ ] New table/form/state added
+- [ ] Customer-facing surface changed
+- [ ] Dangerous action changed
+- [ ] Status/evidence/review presentation changed
+- [x] Workspace/environment context presentation changed
+
+## Scope Badge Contract Addendum
+
+- Tenantless workspace-wide pages MUST NOT render a generic “All environments” header action or workbench badge as the primary context signal.
+- When `environment_id` is present on a workspace hub, the explicit filter banner/chip is the source of truth for the narrowed dataset.
+- Environment-scoped shell labels remain valid only when the route truly resolves to an environment-owned context.
+
+## UI/Productization Coverage *(UI-COV-001)*
+
+- **Route/page/surface**: Operations hub deep links; Evidence Overview hub; environment sidebar vs workspace sidebar entries and scope identity, including separated workspace-wide/admin groups on environment-owned pages (baseline library surfaces, regression-only)
+- **Current page archetype**: Monitoring hub (Operations/Evidence); navigation shell contract
+- **Design depth**: Domain Pattern Surface (contract hardening, minimal visual work)
+- **Repo-truth level**: repo-verified (Spec 311/320/322 + current helper code)
+- **Existing pattern reused**: `AdminSurfaceScope`, `WorkspaceHubRegistry`, Filament `SIDEBAR_NAV_START` render hook, Filament navigation groups, `CanonicalNavigationContext`, `OperationRunLinks`
+- **New pattern required**: small scope-aware workspace hub navigation helper, limited to grouping and environment filter URL carry for existing hub entries
+- **Screenshot required**: yes, only for scope-regression proof in the implementation PR (light/dark where relevant)
+- **Page audit required**: no (existing archetypes; update coverage artifacts only if new navigation entries are introduced)
+- **Dangerous-action review required**: no (no destructive action changes)
+- **Coverage files to update (in implementation PR)**:
+ - [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` (only if navigation entries/routes change)
+ - [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` (only if new surface created; expected `no`)
+ - [x] `N/A - no new reachable UI surface added; contract hardening only`
+
+## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
+
+- **Cross-cutting feature?**: yes
+- **Interaction class(es)**: navigation entry points, scope presentation, deep links, hub filtering
+- **Systems touched**: `AdminSurfaceScope`, `WorkspaceHubRegistry`, `WorkspaceHubNavigation`, Filament sidebar render hook/navigation groups, `CanonicalNavigationContext`, `OperationRunLinks`, `ClearEnvironmentContextController`
+- **Existing pattern(s) to extend**: canonical workspace/environment scope contract (Specs 311/320/322)
+- **Allowed deviation and why**: none (prefer tightening existing helpers over new frameworking)
+- **Consistency impact**: “Route determines shell; query determines filter; helpers emit canonical keys.”
+- **Review focus**: no new scope magic; no helper outputs that encode Filament internals as canonical contract.
+
+## OperationRun UX Impact
+
+- **Touches OperationRun start/completion/link UX?**: yes (link semantics only)
+- **Shared OperationRun UX contract/layer reused**: `App\\Support\\OperationRunLinks`
+- **Delegated behaviors**: operation collection URL generation; environment filter key; operation type deep link key
+- **Queued DB-notification policy**: `N/A`
+- **Terminal notification path**: `N/A`
+- **Exception required?**: none
+
+## Provider Boundary / Platform Core Check
+
+- **Shared provider/platform boundary touched?**: yes (terminology + query keys must not reintroduce legacy “tenant” meaning at platform scope)
+- **Boundary classification**: platform-core (workspace/environment scope) + provider-owned (Entra “tenant” identity) must remain separated
+- **Seams affected**: query keys and helper naming only
+- **Why this does not deepen provider coupling accidentally**: enforce `environment_id`/`operation_type` at platform scope; keep `tenant` terminology provider-boundary-only.
+
+## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
+
+- **Test purpose / classification**: Feature (contract tests) + Browser (minimal smoke for sidebar/scope)
+- **Validation lane(s)**: fast-feedback (Feature) + browser (smoke), no heavy-governance required for this slice
+
+## Acceptance Criteria
+
+- **AC1**: First-party deep links use canonical query keys (`environment_id`, and where needed `operation_type`), not `tableFilters[...]`.
+- **AC2**: Evidence scope is explicit: Evidence Overview is a workspace hub; any remaining `/admin/evidence/*` special casing is either removed as stale or documented + tested as intentional.
+- **AC3**: Baseline library ownership remains workspace-owned and does not regress (no baseline ownership reopen).
+- **AC4**: Targeted tests are green (feature contract tests + minimal browser smoke if UI navigation is involved).
+- **AC5**: Workspace-owned and environment-owned pages show an explicit sidebar scope indicator that names the active workspace or environment, while tenantless workspace topbars and environment pickers do not render a negative “No environment selected” status.
+- **AC6**: Environment-owned sidebars separate workspace-wide/admin links into clearly labeled groups and carry `environment_id` only to workspace hubs that support explicit environment filtering.
+- **AC7**: Managed Environments registry pages do not duplicate the `/admin/choose-environment` flow with a redundant “Choose environment” CTA; environment cards remain the entry point, with Add Environment and Switch Workspace as the supporting actions.
+
+## Follow-up spec candidates
+
+- Provider Connection Scope Hardening (credential-adjacent authority semantics)
+- Canonical Link / Query Cleanup (broader inventory + replacement beyond Operations/Evidence)
+- Environment Resource Context Follow-through (reduce hidden context reliance inside environment-owned resources)
diff --git a/specs/338-workspace-environment-resource-scope-contract/tasks.md b/specs/338-workspace-environment-resource-scope-contract/tasks.md
new file mode 100644
index 00000000..d60fc0fa
--- /dev/null
+++ b/specs/338-workspace-environment-resource-scope-contract/tasks.md
@@ -0,0 +1,121 @@
+# Tasks: Spec 338 - Workspace / Environment Resource Scope Contract
+
+- Input: `specs/338-workspace-environment-resource-scope-contract/spec.md`, `specs/338-workspace-environment-resource-scope-contract/plan.md`
+- Preparation status: implemented + validated.
+
+**Tests**: Required. This spec changes canonical link/query contract semantics for operator-facing hubs.
+
+## Test Governance Checklist
+
+- [x] Lane assignment remains explicit and narrowest sufficient (Feature + optional Browser smoke).
+- [x] No new default-heavy helpers/factories/seeds are introduced.
+- [x] Contract changes are guarded by deterministic tests before refactors.
+- [x] Any exception resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
+
+## Phase 1: Preparation And Repo Truth (blocks runtime changes)
+
+**Purpose**: Confirm repo truth and lock the current contract seams before changing runtime behavior.
+
+- [x] T001 Re-read `spec.md` + `plan.md` + this `tasks.md`.
+- [x] T002 Confirm working tree intent and record baseline commit (`git status`, `git log -1`).
+- [x] T003 Re-verify dependency specs as context only (do not reopen them):
+ - `specs/311-workspace-environment-surface-scope-contract/` (implemented)
+ - `specs/320-workspace-owned-analysis-surface-registration-shell-cutover/` (completed)
+ - `specs/322-browser-no-drift-regression-guard/` (guard posture)
+- [x] T004 Inspect the confirmed helper seams:
+ - `apps/platform/app/Support/OperationRunLinks.php` (currently emits `tableFilters[type][value]` for `operationType`)
+ - `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` (`toQuery()` behavior)
+ - `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` (filter payload injection)
+ - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` (query parsing + filter handling)
+ - `apps/platform/app/Http/Controllers/ClearEnvironmentContextController.php` (`/admin/evidence/*` special casing)
+ - `apps/platform/app/Support/Navigation/AdminSurfaceScope.php` (evidence path classification)
+ - `apps/platform/routes/web.php` (`/admin/evidence/overview`, operations routes)
+- [x] T005 Inspect existing guard tests that already encode parts of the contract:
+ - `apps/platform/tests/Feature/Navigation/Spec322LegacyQueryAliasGuardTest.php`
+ - `apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php`
+ - `apps/platform/tests/Unit/Tenants/AdminSurfaceScopeTest.php`
+
+## Phase 2: Add failing contract tests first
+
+**Purpose**: Make contract changes reviewable and regression-proof.
+
+- [x] T006 Add a new Spec 338 contract test for `OperationRunLinks::index(..., operationType: ...)`:
+ - asserts the generated URL does not contain `tableFilters`
+ - asserts operation type deep-linking uses `operation_type` **or** is intentionally not supported
+- [x] T007 Add/extend tests ensuring environment filtering remains canonical:
+ - `environment_id` works for Operations hub filtering
+ - legacy aliases remain ignored (no regression vs Spec 322)
+- [x] T008 Add/extend Evidence scope tests:
+ - Evidence Overview is workspace hub (`/admin/evidence/overview`)
+ - `/admin/evidence/*` special casing is either removed as stale or explicitly covered by a route-inventory-backed test
+
+## Phase 3: Implement OperationRunLinks query contract
+
+**Purpose**: Remove Filament internals from first-party helper outputs.
+
+- [x] T009 Update `apps/platform/app/Support/OperationRunLinks.php` so `operationType` does not emit `tableFilters[type][value]`.
+- [x] T010 Decide and implement one of:
+ - Map `operation_type` query to internal table state in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, or
+ - Remove operation type deep-linking and keep only environment filtering + tabs/problem classes.
+- [x] T011 Update any call sites that depended on helper-emitted `tableFilters` and make them use the new canonical key (or drop the feature).
+
+## Phase 4: Navigation context payload hygiene
+
+**Purpose**: Stop emitting legacy alias filters in navigation contexts.
+
+- [x] T012 Audit `CanonicalNavigationContext` usage where `filterPayload` includes `tableFilters[managed_environment_id]` and confirm whether it is still needed.
+- [x] T013 Update call sites (e.g. `RelatedNavigationResolver`) to avoid injecting legacy alias filters when linking to workspace hubs; use `environment_id` where filter intent exists.
+- [x] T014 Ensure no first-party helper emits legacy query aliases for hub filtering (`tenant`, `tenant_id`, `managed_environment_id`, `tenant_scope`, `tableFilters`) as canonical contract.
+
+## Phase 5: Evidence scope special casing cleanup
+
+**Purpose**: Reduce ambiguity and stale branching.
+
+- [x] T015 Confirm whether any `/admin/evidence/*` non-overview route family is real and reachable on current branch:
+ - if not real: remove/neutralize stale handling in `ClearEnvironmentContextController` and `AdminSurfaceScope`
+ - if real: document intent in Spec 338 and add explicit tests proving the contract
+- [x] T016 Ensure Evidence Overview remains a workspace hub and accepts only explicit `environment_id` filtering.
+
+## Phase 6: Regression posture (baseline ownership)
+
+**Purpose**: Ensure Spec 320 baseline ownership/navigation remains stable.
+
+- [x] T017 Confirm existing baseline ownership tests remain green (no new baseline work unless regression is proven):
+ - `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`
+ - any Spec 320/322 smoke coverage already in repo
+
+## Phase 7: Optional browser smoke (only if navigation presentation changes)
+
+- [x] T018 Add a minimal browser smoke test `apps/platform/tests/Browser/Spec338ScopeContractSmokeTest.php` covering:
+ - environment → operations filtered link uses `environment_id`
+ - clearing environment context from an evidence page returns to Evidence Overview hub without ambiguous redirects
+
+## Phase 8: Validation
+
+- [x] T021 Add the sidebar scope identity indicator requested during browser review:
+ - workspace-owned pages show Workspace scope + active workspace without a negative “no environment selected” topbar or picker status
+ - environment-owned pages show Environment scope + active environment + containing workspace
+ - use Filament render hooks rather than publishing internal sidebar views
+- [x] T022 Split environment sidebar IA for workspace-owned links:
+ - workspace-wide hub entries move into a separate `Workspace-wide` group on environment pages
+ - workspace configuration/admin entries move into `Workspace admin`
+ - supported hub links carry explicit `environment_id`; clean workspace/admin links remain unfiltered
+- [x] T023 Remove the redundant “Choose environment” CTA from the Managed Environments registry:
+ - environment cards remain the entry/open affordance
+ - supporting actions stay limited to Add Environment and Switch Workspace
+ - `/admin/choose-environment` remains the dedicated fast context-switch surface
+- [x] T024 Remove generic tenantless “All environments” badges from workspace-wide pages:
+ - header scope actions are omitted when no concrete environment context exists
+ - explicit `environment_id` filters remain visible through filter banners/table chips
+- [x] T019 Run narrow tests first:
+ - `cd apps/platform && ./vendor/bin/sail artisan test --compact `
+- [x] T020 Run formatting and patch checks:
+ - `cd apps/platform && ./vendor/bin/sail pint --dirty --format agent`
+ - `git diff --check`
+
+## Explicit Non-Goals
+
+- [x] NT001 Do not add migrations, new tables, or persisted truth.
+- [x] NT002 Do not restructure route families; keep canonical workspace/environment routes.
+- [x] NT003 Do not introduce a new navigation taxonomy framework; reuse existing `AdminSurfaceScope` / hub registry seams.
+- [x] NT004 Do not change destructive action behavior (confirmation/authorization/audit).