From d0f3ff25bed19fa9990690ce73a9afe695cbe15f Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 31 May 2026 03:34:40 +0200 Subject: [PATCH] feat: enforce workspace and environment scope contract (Spec 338) --- .../Clusters/Monitoring/AlertsCluster.php | 11 + .../Pages/Governance/DecisionRegister.php | 11 + .../Pages/Governance/GovernanceInbox.php | 11 + .../Monitoring/FindingExceptionsQueue.php | 11 + .../Filament/Pages/Monitoring/Operations.php | 27 +- .../TenantlessOperationRunViewer.php | 10 +- .../Pages/Reviews/CustomerReviewWorkspace.php | 8 +- .../Filament/Pages/Reviews/ReviewRegister.php | 11 + .../Workspaces/ManagedEnvironmentsLanding.php | 6 - .../Resources/AlertDeliveryResource.php | 6 + .../Resources/ProviderConnectionResource.php | 11 + .../ClearEnvironmentContextController.php | 10 +- .../Providers/Filament/AdminPanelProvider.php | 87 ++++-- .../Support/Navigation/AdminSurfaceScope.php | 10 +- .../Support/Navigation/NavigationScope.php | 1 - .../Navigation/RelatedNavigationResolver.php | 20 -- .../Navigation/WorkspaceHubNavigation.php | 61 +++++ .../Support/OperateHub/OperateHubShell.php | 20 +- .../app/Support/OperationRunLinks.php | 4 +- .../Support/Tenants/TenantInteractionLane.php | 3 +- apps/platform/lang/de/localization.php | 9 + apps/platform/lang/en/localization.php | 9 + .../product-process-flow-horizontal.blade.php | 34 ++- .../monitoring/evidence-overview.blade.php | 147 +++++----- .../pages/monitoring/operations.blade.php | 8 +- .../tenantless-operation-run-viewer.blade.php | 4 +- .../managed-environments-landing.blade.php | 9 - .../filament/partials/context-bar.blade.php | 31 ++- .../sidebar-scope-indicator.blade.php | 80 ++++++ ...EnvironmentCopyNeutralizationSmokeTest.php | 4 +- ...WorkspaceHubNavigationContextSmokeTest.php | 10 +- ...pec316WorkspaceHubClearFilterSmokeTest.php | 10 +- .../Spec322AlertsAuditNoDriftSmokeTest.php | 2 +- .../Spec322WorkspaceHubNoDriftSmokeTest.php | 2 +- ...ReviewWorkspaceProductizationSmokeTest.php | 6 +- ...GovernanceInboxProductizationSmokeTest.php | 6 +- ...28OperationsHubProductizationSmokeTest.php | 6 +- ...pec329EvidenceAuditDisclosureSmokeTest.php | 12 +- ...EvidenceReviewPackProductFlowSmokeTest.php | 4 +- .../Browser/Spec338ScopeContractSmokeTest.php | 58 ++++ ...c322WorkspaceEnvironmentBrowserHarness.php | 10 +- .../EnvironmentContextSurfaceCopyTest.php | 4 +- .../PanelNavigationSegregationTest.php | 20 ++ ...ec337EvidenceReviewPackProductFlowTest.php | 1 + .../WorkspaceContextRecoveryDisplayTest.php | 5 +- ...aceContextTopbarAndTenantSelectionTest.php | 5 +- .../Guards/ActionSurfaceContractTest.php | 2 +- .../EnvironmentContextTerminologyTest.php | 2 +- ...xceptionsQueueWorkspaceHubContractTest.php | 6 +- .../Monitoring/HeaderContextBarTest.php | 2 +- .../Monitoring/OperationsTenantScopeTest.php | 4 +- .../OperationsWorkspaceHubContractTest.php | 5 +- ...ertsAuditEnvironmentFilterContractTest.php | 4 +- ...pec338EnvironmentSidebarSeparationTest.php | 119 ++++++++ ...c338OperationRunLinksQueryContractTest.php | 51 ++++ .../Spec338SidebarScopeIndicatorTest.php | 50 ++++ .../TenantlessOperationRunViewerTest.php | 2 +- .../Feature/OpsUx/OperateHubShellTest.php | 14 +- ...derConnectionsWorkspaceHubContractTest.php | 3 +- ...nitoringDoesNotMutateTenantContextTest.php | 4 +- .../Spec085/OperationsIndexHeaderTest.php | 11 +- .../Spec085/RunDetailBackAffordanceTest.php | 2 +- .../TenantRBAC/TenantSwitcherScopeTest.php | 3 +- .../Workspaces/ChooseEnvironmentPageTest.php | 6 +- .../GlobalContextShellContractTest.php | 6 +- .../Spec195ManagedEnvironmentsLandingTest.php | 11 +- .../WorkspaceHubContextContractTest.php | 6 +- .../Unit/Tenants/AdminSurfaceScopeTest.php | 2 +- .../checklists/requirements.md | 70 +++++ .../plan.md | 123 +++++++++ .../spec.md | 259 ++++++++++++++++++ .../tasks.md | 121 ++++++++ 72 files changed, 1444 insertions(+), 279 deletions(-) create mode 100644 apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php create mode 100644 apps/platform/resources/views/filament/partials/sidebar-scope-indicator.blade.php create mode 100644 apps/platform/tests/Browser/Spec338ScopeContractSmokeTest.php create mode 100644 apps/platform/tests/Feature/Navigation/Spec338EnvironmentSidebarSeparationTest.php create mode 100644 apps/platform/tests/Feature/Navigation/Spec338OperationRunLinksQueryContractTest.php create mode 100644 apps/platform/tests/Feature/Navigation/Spec338SidebarScopeIndicatorTest.php create mode 100644 specs/338-workspace-environment-resource-scope-contract/checklists/requirements.md create mode 100644 specs/338-workspace-environment-resource-scope-contract/plan.md create mode 100644 specs/338-workspace-environment-resource-scope-contract/spec.md create mode 100644 specs/338-workspace-environment-resource-scope-contract/tasks.md 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 }}" >
      -
      +
      {{ $loop->iteration }}
      -
      +
      {{ $step['label'] }}
      @@ -64,7 +82,7 @@ class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
      -

      +

      {{ $step['description'] }}

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