diff --git a/.gitignore b/.gitignore index 3f59c2df..52ec55e2 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ Thumbs.db /references /tests/Browser/Screenshots /apps/platform/tests/Browser/Screenshots +/.playwright-mcp *.tmp *.swp /apps/platform/.env diff --git a/apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php b/apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php index 4e3128a8..ed124f82 100644 --- a/apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php +++ b/apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php @@ -247,14 +247,6 @@ protected static function resolveScopedTenant(ManagedEnvironment|string|null $te ->first(); } - $queryTenant = request()->query('tenant'); - - if (is_string($queryTenant) && $queryTenant !== '') { - return ManagedEnvironment::query() - ->where('slug', $queryTenant) - ->first(); - } - return null; } diff --git a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php index 348be06e..e8145d98 100644 --- a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php +++ b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php @@ -332,6 +332,11 @@ public function table(Table $table): Table ->visible(fn (): bool => $this->registerState === 'recently_closed') ->wrap(), ]) + ->filters([ + Tables\Filters\SelectFilter::make('managed_environment_id') + ->label('Environment') + ->options(fn (): array => $this->tenantFilterOptions()), + ]) ->emptyStateHeading($this->emptyStateHeading()) ->emptyStateDescription($this->emptyStateDescription()) ->emptyStateActions($this->emptyStateActions()); @@ -476,6 +481,21 @@ private function visibleDecisionTenants(): array return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants); } + /** + * @return array + */ + private function tenantFilterOptions(): array + { + $options = []; + + foreach ($this->visibleDecisionTenants() as $tenant) { + $label = (string) ($tenant->name ?: $tenant->slug ?: ('Environment '.(int) $tenant->getKey())); + $options[(string) $tenant->getKey()] = $label; + } + + return $options; + } + private function applyRequestedTenantPrefilter(): void { $workspace = $this->workspace(); @@ -495,6 +515,8 @@ private function applyRequestedTenantPrefilter(): void foreach ($this->visibleDecisionTenants() as $tenant) { if ((int) $tenant->getKey() === $environmentId) { $this->tenantId = $environmentId; + $this->tableFilters['managed_environment_id']['value'] = (string) $environmentId; + $this->tableDeferredFilters['managed_environment_id']['value'] = (string) $environmentId; return; } diff --git a/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php b/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php index 3db0f99b..d0b6e0d3 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php +++ b/apps/platform/app/Filament/Pages/Monitoring/AuditLog.php @@ -289,7 +289,7 @@ public function table(Table $table): Table ->searchable() ->toggleable(), TextColumn::make('tenant.name') - ->label('ManagedEnvironment') + ->label('Environment') ->formatStateUsing(fn (?string $state): string => $state ?: 'Workspace') ->toggleable(), TextColumn::make('recorded_at') @@ -299,7 +299,7 @@ public function table(Table $table): Table ]) ->filters([ SelectFilter::make('managed_environment_id') - ->label('ManagedEnvironment') + ->label('Environment') ->options(fn (): array => $this->tenantFilterOptions()) ->default(fn (): ?string => $this->defaultTenantFilter()) ->searchable(), diff --git a/apps/platform/app/Filament/Resources/AlertDeliveryResource.php b/apps/platform/app/Filament/Resources/AlertDeliveryResource.php index f9488318..c97774df 100644 --- a/apps/platform/app/Filament/Resources/AlertDeliveryResource.php +++ b/apps/platform/app/Filament/Resources/AlertDeliveryResource.php @@ -185,7 +185,7 @@ public static function infolist(Schema $schema): Schema ->formatStateUsing(fn (?string $state): string => ucfirst((string) $state)) ->placeholder('—'), TextEntry::make('tenant.name') - ->label('ManagedEnvironment'), + ->label('Environment'), TextEntry::make('rule.name') ->label('Rule') ->placeholder('—'), @@ -246,7 +246,7 @@ public static function table(Table $table): Table ->since() ->sortable(), TextColumn::make('tenant.name') - ->label('ManagedEnvironment') + ->label('Environment') ->searchable(), TextColumn::make('event_type') ->label('Event') @@ -274,7 +274,7 @@ public static function table(Table $table): Table ]) ->filters([ SelectFilter::make('managed_environment_id') - ->label('ManagedEnvironment') + ->label('Environment') ->options(function (): array { $user = auth()->user(); diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index d8a1f896..e423e957 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; +use App\Support\Workspaces\WorkspaceContext; use Filament\Actions; use Filament\Resources\Pages\ListRecords; use Filament\Schemas\Components\EmbeddedTable; @@ -28,6 +29,7 @@ public function mount(): void parent::mount(); $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); + $this->applyRequestedEnvironmentFilter(); } private function tableHasRecords(): bool @@ -243,6 +245,34 @@ private function resolveTenantExternalIdForCreateAction(): ?string return null; } + private function applyRequestedEnvironmentFilter(): void + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return; + } + + $environment = ProviderConnectionResource::resolveRequestedEnvironment(); + + if (! $environment instanceof ManagedEnvironment) { + return; + } + + if ((int) $environment->workspace_id !== $workspaceId) { + abort(404); + } + + $slug = (string) $environment->slug; + + if ($slug === '') { + return; + } + + $this->tableFilters['tenant']['value'] = $slug; + $this->tableDeferredFilters['tenant']['value'] = $slug; + } + /** * @return array{label: string, clear_url: string}|null */ diff --git a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php index 033b5ac6..cf07621e 100644 --- a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -214,10 +214,6 @@ private function requestHasExplicitTenantContext(Request $request): bool return false; } - if (filled($request->query('tenant')) || filled($request->query('managed_environment_id'))) { - return true; - } - $route = $request->route(); return $route?->hasParameter('tenant') && filled($route->parameter('tenant')); diff --git a/apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php b/apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php index 8e7591b2..8aac8556 100644 --- a/apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php +++ b/apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php @@ -6,7 +6,6 @@ use App\Models\User; use App\Support\Navigation\AdminSurfaceScope; use App\Support\Navigation\NavigationScope; -use App\Support\Navigation\WorkspaceHubRegistry; use App\Support\Navigation\WorkspaceSidebarNavigation; use App\Support\OperateHub\OperateHubShell; use App\Support\Workspaces\WorkspaceContext; @@ -87,10 +86,6 @@ public function handle(Request $request, Closure $next): Response ! $resolvedContext->hasTenant() && $this->adminPathRequiresTenantSelection($path) ) { - if ($this->requestHasExplicitTenantHint($request)) { - abort(404); - } - $workspace = $workspaceContext->currentWorkspace($request); if ($workspace !== null) { @@ -178,15 +173,6 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool return AdminSurfaceScope::fromPath($path) === AdminSurfaceScope::CanonicalWorkspaceRecordViewer; } - private function requestHasExplicitTenantHint(Request $request): bool - { - if (WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) { - return false; - } - - return filled($request->query('tenant')) || filled($request->query('managed_environment_id')); - } - private function adminPathRequiresTenantSelection(string $path): bool { if (! str_starts_with($path, '/admin/')) { diff --git a/apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php b/apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php index b0eb14cc..0f97430d 100644 --- a/apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php +++ b/apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php @@ -5,6 +5,7 @@ namespace App\Support\Navigation; use App\Models\ManagedEnvironment; +use App\Support\OperateHub\OperateHubShell; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -50,6 +51,12 @@ public static function environmentContext(): ?ManagedEnvironment return null; } + $resolved = app(OperateHubShell::class)->resolvedContext(request()); + + if ($resolved->tenant instanceof ManagedEnvironment) { + return $resolved->tenant; + } + $tenant = Filament::getTenant(); if ($tenant instanceof ManagedEnvironment) { diff --git a/apps/platform/app/Support/OperateHub/OperateHubShell.php b/apps/platform/app/Support/OperateHub/OperateHubShell.php index 8423ee5a..de3abb21 100644 --- a/apps/platform/app/Support/OperateHub/OperateHubShell.php +++ b/apps/platform/app/Support/OperateHub/OperateHubShell.php @@ -11,7 +11,6 @@ use App\Services\Tenants\TenantOperabilityService; use App\Support\ManagedEnvironmentLinks; use App\Support\Navigation\AdminSurfaceScope; -use App\Support\Navigation\WorkspaceHubRegistry; use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; @@ -185,35 +184,6 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo ); } - $queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory); - - if ($queryHintTenant['tenant'] instanceof ManagedEnvironment) { - return new ResolvedShellContext( - workspace: $workspace, - tenant: $queryHintTenant['tenant'], - pageCategory: $pageCategory, - state: 'tenant_scoped', - displayMode: 'tenant_scoped', - workspaceSource: $workspaceSource, - tenantSource: 'query_hint', - ); - } - - $recoveryReason ??= $queryHintTenant['reason']; - - if ($this->requiresStrictQueryTenantHintResolution($request)) { - return new ResolvedShellContext( - workspace: $workspace, - tenant: null, - pageCategory: $pageCategory, - state: 'invalid_tenant', - displayMode: 'recovery', - workspaceSource: $workspaceSource, - recoveryAction: 'abort_not_found', - recoveryReason: $recoveryReason ?? 'missing_tenant', - ); - } - $tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace); if ($tenant instanceof ManagedEnvironment) { @@ -324,30 +294,6 @@ private function resolveWorkspaceForPageCategory( }; } - private function resolveValidatedQueryHintTenant( - ?Request $request, - Workspace $workspace, - AdminSurfaceScope $pageCategory, - ): array { - if (! $pageCategory->allowsQueryEnvironmentHints()) { - return ['tenant' => null, 'reason' => null]; - } - - $queryTenant = $this->resolveQueryTenantHint($request); - - if (! $queryTenant instanceof ManagedEnvironment) { - return ['tenant' => null, 'reason' => null]; - } - - $reason = $this->tenantValidationReason($queryTenant, $workspace, $request, $pageCategory); - - if ($reason !== null) { - return ['tenant' => null, 'reason' => $reason]; - } - - return ['tenant' => $queryTenant, 'reason' => null]; - } - private function resolveRouteTenantCandidate(?Request $request = null, ?AdminSurfaceScope $pageCategory = null): ?ManagedEnvironment { $route = $request?->route(); @@ -412,55 +358,6 @@ private function resolveRefererTenantCandidate(?Request $request, AdminSurfaceSc return $this->resolveTenantIdentifier($environmentIdentifier); } - private function resolveQueryTenantHint(?Request $request = null): ?ManagedEnvironment - { - if ($request instanceof Request && WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) { - return null; - } - - $queryTenant = $request?->query('tenant'); - - if (filled($queryTenant)) { - return $this->resolveTenantIdentifier($queryTenant); - } - - $queryTenantId = $request?->query('managed_environment_id'); - - if (filled($queryTenantId)) { - return $this->resolveTenantIdentifier($queryTenantId); - } - - return null; - } - - private function hasExplicitQueryTenantHint(?Request $request = null): bool - { - if ($request instanceof Request && WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) { - return false; - } - - return filled($request?->query('tenant')) || filled($request?->query('managed_environment_id')); - } - - private function requiresStrictQueryTenantHintResolution(?Request $request = null): bool - { - if (! $this->hasExplicitQueryTenantHint($request)) { - return false; - } - - $path = '/'.ltrim((string) $request?->path(), '/'); - - if (! str_starts_with($path, '/admin/')) { - return false; - } - - if (str_starts_with($path, '/admin/finding-exceptions/queue')) { - return false; - } - - return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1; - } - private function resolveTenantIdentifier(mixed $tenantIdentifier): ?ManagedEnvironment { if ($tenantIdentifier instanceof ManagedEnvironment) { diff --git a/apps/platform/routes/web.php b/apps/platform/routes/web.php index daecbb93..33c98941 100644 --- a/apps/platform/routes/web.php +++ b/apps/platform/routes/web.php @@ -110,7 +110,7 @@ $query->where('slug', $identifier); if (ctype_digit($identifier)) { - $query->orWhereKey((int) $identifier); + $query->orWhere('id', (int) $identifier); } }) ->first(); @@ -132,7 +132,7 @@ $query->where('slug', $identifier); if (ctype_digit($identifier)) { - $query->orWhereKey((int) $identifier); + $query->orWhere('id', (int) $identifier); } }) ->first(); diff --git a/apps/platform/tests/Feature/Navigation/Spec341CanonicalLinkQueryCleanupTest.php b/apps/platform/tests/Feature/Navigation/Spec341CanonicalLinkQueryCleanupTest.php new file mode 100644 index 00000000..3877d13b --- /dev/null +++ b/apps/platform/tests/Feature/Navigation/Spec341CanonicalLinkQueryCleanupTest.php @@ -0,0 +1,130 @@ +active()->create([ + 'name' => 'Spec341 Environment A', + 'external_id' => 'spec341-environment-a', + ]); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner'); + $workspace = $environment->workspace()->firstOrFail(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + session()->forget(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY); + + $shell = app(OperateHubShell::class); + + $queryCases = [ + 'tenant' => ['tenant' => (string) $environment->getRouteKey()], + 'managed_environment_id' => ['managed_environment_id' => (int) $environment->getKey()], + ]; + + foreach ($queryCases as $query) { + $request = Request::create('/admin/onboarding', 'GET', $query); + $request->setUserResolver(fn () => $user); + + $resolved = $shell->resolvedContext($request); + + expect($resolved->hasTenant())->toBeFalse() + ->and($resolved->tenantSource)->not->toBe('query_hint'); + } +}); + +it('Spec341 EnvironmentRequiredPermissions does not resolve tenant from legacy query keys', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec341 Permissions Environment', + 'external_id' => 'spec341-permissions-environment', + ]); + + $originalRequest = app('request'); + + try { + $request = Request::create('/livewire/update', 'GET', [ + 'tenant' => (string) $environment->getRouteKey(), + ]); + app()->instance('request', $request); + + $method = new ReflectionMethod(EnvironmentRequiredPermissions::class, 'resolveScopedTenant'); + $method->setAccessible(true); + + /** @var ManagedEnvironment|null $resolvedTenant */ + $resolvedTenant = $method->invoke(null, null); + + expect($resolvedTenant)->toBeNull(); + } finally { + app()->instance('request', $originalRequest); + } +}); + +it('Spec341 environment-bound routes remain route-owned even when legacy tenant query hints are present', function (): void { + $environmentA = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec341 Baseline Environment A', + 'external_id' => 'spec341-baseline-environment-a', + ]); + [$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner', workspaceRole: 'owner'); + + $environmentB = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $environmentA->workspace_id, + 'name' => 'Spec341 Baseline Environment B', + 'external_id' => 'spec341-baseline-environment-b', + ]); + createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner', workspaceRole: 'owner'); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id); + + baselineCompareLandingLivewire($environmentB, ['tenant' => (string) $environmentA->getRouteKey()], $user) + ->assertSet('scopedEnvironmentId', (int) $environmentB->getKey()); +}); + +it('Spec341 WorkspaceHubNavigation carries route-owned environment context into workspace hub URLs', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec341 Hub Navigation Environment', + 'external_id' => 'spec341-hub-navigation-environment', + ]); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner'); + $workspace = $environment->workspace()->firstOrFail(); + + $this->actingAs($user); + setAdminPanelContext(); + + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + session()->forget(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY); + + $originalRequest = app('request'); + + try { + $workspaceKey = ManagedEnvironmentLinks::workspaceRouteKey($workspace); + $environmentKey = ManagedEnvironmentLinks::environmentRouteKey($environment); + + $request = Request::create('/livewire/update', 'POST'); + $request->headers->set('x-livewire', '1'); + $request->headers->set('referer', url("/admin/workspaces/{$workspaceKey}/environments/{$environmentKey}/required-permissions")); + $request->setUserResolver(fn () => $user); + + app()->instance('request', $request); + + $url = WorkspaceHubNavigation::environmentFilteredUrl(url('/admin/provider-connections')); + + $query = []; + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect((int) ($query['environment_id'] ?? 0))->toBe((int) $environment->getKey()); + } finally { + app()->instance('request', $originalRequest); + } +}); diff --git a/apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php b/apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php index 645d53fa..14fe0960 100644 --- a/apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php +++ b/apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php @@ -110,6 +110,12 @@ ->assertSee($environmentA->name) ->assertDontSee('Spec315 Decision B'); + Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()]) + ->actingAs($user) + ->test(DecisionRegister::class) + ->assertSet('tableFilters.managed_environment_id.value', (string) $environmentA->getKey()) + ->assertSet('tableDeferredFilters.managed_environment_id.value', (string) $environmentA->getKey()); + $this->get(route('admin.evidence.overview', ['environment_id' => (int) $environmentA->getKey()])) ->assertOk() ->assertSee('Environment filter:') diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php index 43a0eb64..7a4b8bf0 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php @@ -116,3 +116,26 @@ expect(data_get(session()->get($filtersSessionKey, []), 'tenant.value'))->toBeNull(); }); + +it('Spec341 provider connections translates environment_id query into an active table filter', function (): void { + $environment = ManagedEnvironment::factory()->active()->create([ + 'external_id' => 'spec341-provider-connections-env', + ]); + [$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner'); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'display_name' => 'Spec341 Provider Filtered', + ]); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id); + + Livewire::actingAs($user) + ->withQueryParams(['environment_id' => (int) $environment->getKey()]) + ->test(ListProviderConnections::class) + ->assertSet('tableFilters.tenant.value', (string) $environment->slug) + ->assertSet('tableDeferredFilters.tenant.value', (string) $environment->slug); +}); diff --git a/specs/341-canonical-link-query-cleanup/artifacts/screenshots/decision-register-active-filters.png b/specs/341-canonical-link-query-cleanup/artifacts/screenshots/decision-register-active-filters.png new file mode 100644 index 00000000..31aa3e2d Binary files /dev/null and b/specs/341-canonical-link-query-cleanup/artifacts/screenshots/decision-register-active-filters.png differ diff --git a/specs/341-canonical-link-query-cleanup/artifacts/screenshots/provider-connections-final.png b/specs/341-canonical-link-query-cleanup/artifacts/screenshots/provider-connections-final.png new file mode 100644 index 00000000..04ee5f0e Binary files /dev/null and b/specs/341-canonical-link-query-cleanup/artifacts/screenshots/provider-connections-final.png differ diff --git a/specs/341-canonical-link-query-cleanup/checklists/requirements.md b/specs/341-canonical-link-query-cleanup/checklists/requirements.md new file mode 100644 index 00000000..4174c5db --- /dev/null +++ b/specs/341-canonical-link-query-cleanup/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Spec 341 - Canonical Link / Query Cleanup + +**Purpose**: Validate Spec 341 preparation completeness before implementation. +**Created**: 2026-05-31 +**Feature**: `specs/341-canonical-link-query-cleanup/spec.md` + +## Candidate Selection Gate + +- [x] CHK001 The selected candidate matches `canonical-link-query-cleanup` in `docs/product/spec-candidates.md`. +- [x] CHK002 Related specs were checked for completed-spec signals and are treated as context only (no rewrites). +- [x] CHK003 The package does not overwrite an existing `specs/341-*` directory. +- [x] CHK004 Close alternatives (`environment-resource-context-follow-through`, `product-truth-docs-drift-cleanup`) are deferred instead of hidden scope. +- [x] CHK005 The spec is scoped as link/query contract hygiene (no feature expansion). + +## Content Quality + +- [x] CHK006 The spec has a concrete problem statement, user value, and explicit non-goals. +- [x] CHK007 The spec includes acceptance criteria, out-of-scope boundaries, assumptions, risks, and open questions. +- [x] CHK008 The spec avoids new persistence, new abstractions, or frameworking without proportionality justification. +- [x] CHK009 No placeholder markers (`[FEATURE]`, `[DATE]`, `NEEDS CLARIFICATION`) remain in `spec.md`, `plan.md`, or `tasks.md`. +- [x] CHK010 The spec’s scope is small enough for a bounded implementation loop. + +## Constitution And Scope + +- [x] CHK011 The Spec Candidate Check is filled and scored above the approval threshold. +- [x] CHK012 Pre-production posture is respected: no compatibility redirects or legacy alias preservation “just in case”. +- [x] CHK013 Workspace/environment isolation and deny-as-not-found semantics are explicitly preserved. +- [x] CHK014 UI/Productization Coverage is completed as “no new surface; contract hardening only”. + +## Plan Quality + +- [x] CHK015 `plan.md` is repo-aware and lists the expected touched seams. +- [x] CHK016 The plan sequences work as inventory → failing tests → behavior change → regression guards → validation. +- [x] CHK017 Test governance is explicit: Feature lane first; browser only if justified. +- [x] CHK018 Deployment impact is explicitly none (no migrations/env/queues/assets). + +## Task Quality + +- [x] CHK019 `tasks.md` exists and is dependency-ordered into small phases. +- [x] CHK020 Tasks reference concrete repo files and do not invent new architecture. +- [x] CHK021 Tasks include explicit validation commands and formatting checks. +- [x] CHK022 Tasks explicitly forbid schema/persistence/framework scope expansion. + +## Spec Readiness Gate + +- [x] CHK023 `spec.md`, `plan.md`, and `tasks.md` exist. +- [x] CHK024 No open question blocks safe implementation (inventory-driven follow-ups are allowed). +- [x] CHK025 Result: ready for implementation loop. diff --git a/specs/341-canonical-link-query-cleanup/plan.md b/specs/341-canonical-link-query-cleanup/plan.md new file mode 100644 index 00000000..6c1e1d69 --- /dev/null +++ b/specs/341-canonical-link-query-cleanup/plan.md @@ -0,0 +1,111 @@ +# Implementation Plan: Spec 341 - Canonical Link / Query Cleanup + +**Branch**: `341-canonical-link-query-cleanup` | **Date**: 2026-05-31 | **Spec**: `specs/341-canonical-link-query-cleanup/spec.md` +**Input**: Candidate `canonical-link-query-cleanup` from `docs/product/spec-candidates.md` + repo inspection of legacy scope query parsing seams. + +## Summary + +Inventory and remove remaining legacy “scope hint” query parsing (`tenant`, `managed_environment_id`, etc.) in shared request-scoping seams and align canonical link generation so that: + +- workspace hubs are workspace-wide unless explicitly filtered via `environment_id`; +- environment-bound pages are route-owned and do not accept legacy query aliases as authority; and +- “clear filter” behavior returns to clean canonical URLs. + +This is contract hardening; it must not introduce new abstractions, persistence, or UI frameworks. + +## Technical Context + +- **Language/Version**: PHP 8.4.15, Laravel 12.52.x +- **Primary Dependencies**: Filament v5, Livewire v4, Pest v4 +- **Storage**: PostgreSQL (no schema changes planned) +- **Testing**: Pest Feature tests (navigation + scope contracts) +- **Validation Lanes**: fast-feedback (Feature); browser only if later proven necessary for visible UX behaviors that cannot be asserted reliably in Feature tests +- **Target Platform**: `apps/platform` (Sail locally; Dokploy/container posture unchanged) +- **Constraints**: + - no compatibility redirects or “legacy alias preservation” + - no new packages, migrations, queues, scheduler, storage changes + - deny-as-not-found semantics remain authoritative + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: existing reachable surfaces; navigation + scope contract hardening +- **Affected routes/pages/actions/states** (inventory-driven): + - workspace hubs that accept environment narrowing via `environment_id` and expose “clear filter” behavior + - environment-bound pages under `/admin/workspaces/{workspace}/environments/{environment}/...` that must remain route-owned + - shared request-scoping seams that must not treat legacy query keys as authority +- **State layers in scope**: URL/query + route parameters + session workspace context +- **Handling mode**: review-mandatory (scope/authority semantics) +- **Required tests or manual smoke**: Feature contract tests for clean vs filtered hub URLs and legacy alias rejection/ignore behavior; browser smoke only if required later +- **UI/Productization coverage decision**: existing pages changed / navigation semantics changed; no new reachable surface added; do not update `docs/ui-ux-enterprise-audit/*` unless implementation materially changes navigation entries or routes (not expected) + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched** (expected): + - `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php` + - `apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php` + - `apps/platform/app/Filament/Concerns/UsesAdminEnvironmentFilterQueryParameter.php` + - `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php` + - `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php` + - `apps/platform/app/Support/OperateHub/OperateHubShell.php` + - `apps/platform/tests/Feature/Navigation/*` +- **Shared abstractions reused**: existing hub filter contract helpers, `WorkspaceContext`, deny-as-not-found semantics, existing guard-test patterns +- **New abstraction introduced?**: none +- **Spread control**: inventory-driven; limit changes to URL/query scope semantics and canonical link generation only + +## OperationRun UX Impact + +N/A — no `OperationRun` start/completion/link UX changes. + +## Implementation Approach + +### Phase 1 — Repo truth inventory + failing tests first + +- inventory every remaining legacy scope query parsing seam (query keys only, not DB naming) +- add/adjust Feature tests that fail on today’s behavior and encode the intended contract + +### Phase 2 — Remove legacy query scope hint parsing in shared seams + +- remove or strictly ignore legacy scope hints in: + - `EnsureWorkspaceSelected` + - `EnsureEnvironmentContextSelected` + - `OperateHubShell` + - `EnvironmentRequiredPermissions` (query-hint handling only) +- ensure out-of-scope requests preserve deny-as-not-found semantics + +### Phase 3 — Align canonical link generation + +- ensure link generation: + - never emits legacy scope query keys + - uses `environment_id` only for workspace hub narrowing + - does not treat `environment_id` as a replacement for route-owned environment pages + +### Phase 4 — Regression guards + +- add/extend guard tests that block: + - parsing legacy scope keys as authority + - generating navigation URLs containing forbidden scope query keys + +### Phase 5 — Validation + close-out + +- run the narrowest Feature tests +- run `pint` on dirty files and `git diff --check` + +## Test Governance Check + +- **Test purpose / classification**: Feature +- **Affected validation lanes**: fast-feedback (Feature) +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation --filter=Spec341` +- **Fixture / helper cost risks**: none expected; reuse existing workspace/environment factories + navigation harness helpers +- **Escalation path**: `document-in-feature` if a bounded exception is proven necessary; otherwise treat legacy alias preservation as a blocker + +## Deployment / Ops Impact + +No migrations, env vars, queues, scheduler, storage, or asset pipeline changes are planned. + +## Constitution Check (subset) + +- Proportionality: no new persistence/abstractions/taxonomy introduced +- Compatibility posture: legacy alias preservation is out of scope +- Scope & isolation: route-owned environment remains authoritative; hub narrowing is `environment_id` only; deny-as-not-found semantics preserved diff --git a/specs/341-canonical-link-query-cleanup/spec.md b/specs/341-canonical-link-query-cleanup/spec.md new file mode 100644 index 00000000..9b7cddd3 --- /dev/null +++ b/specs/341-canonical-link-query-cleanup/spec.md @@ -0,0 +1,191 @@ +# Feature Specification: Spec 341 - Canonical Link / Query Cleanup + +**Feature Branch**: `341-canonical-link-query-cleanup` +**Created**: 2026-05-31 +**Status**: Draft +**Input**: Candidate `canonical-link-query-cleanup` from `docs/product/spec-candidates.md` + repo inspection of remaining legacy scope query hints in middleware/shell after Specs 338–340 and Spec 339. + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: The workspace/environment scope contract is now repo-real (Specs 314–322, 338–339), but some remaining URL/query seams still interpret legacy scope query keys (`tenant`, `managed_environment_id`, etc.) as “environment hints”. That reintroduces hidden scope, weakens trust for credential-adjacent and environment-bound surfaces, and makes deep links less predictable. +- **Today's failure**: Repo inspection shows remaining legacy query parsing in shared request-scoping seams (e.g. environment/tenant selection middleware and `OperateHubShell`) that can treat `?tenant=` or `?managed_environment_id=` as scope hints outside workspace hubs. This undermines the “explicit or route-owned” scope rule and increases the risk of accidental scope drift resurfacing. +- **User-visible improvement**: Operators can share and reload admin links confidently: workspace hubs are workspace-wide by default and only narrow via explicit `environment_id`; environment-bound pages are route-owned and do not accept legacy query aliases as authority; “clear filter” links return to clean canonical URLs; and guard tests block reintroducing legacy query scope behavior. +- **Smallest enterprise-capable version**: Inventory remaining legacy scope query parsing, remove it (or make it strictly ignore-only with safe fallback), align canonical link generation across hubs/shell/middleware seams, and add targeted tests to prevent regressions. +- **Explicit non-goals**: + - No new UI shell/sidebar/topbar redesign. + - No new routes or panels. + - No new persisted entities, enums, or abstraction frameworks. + - No RBAC model or capability registry changes. + - No provider/OAuth redesign (Spec 281 family). + - No Graph integration changes. +- **Permanent complexity imported**: A small, explicit inventory + a small set of guard/contract tests around canonical URL/query semantics. No new runtime taxonomy or UI framework. +- **Why now**: Specs 338–340 and Spec 339 hardened scope/authority. The next risk is “scope drift through links and query parsing” reappearing as features continue. This cleanup locks the contract so future work cannot accidentally revive legacy hints. +- **Why not local**: Fixing a single page’s link generator is insufficient because the drift sits in shared request-scoping seams. The smallest correct slice must cover the shared parsing/link seams and prove behavior with tests. +- **Approval class**: Core Enterprise (scope/authorization safety hardening). +- **Red flags triggered**: Cross-surface navigation semantics + shared middleware/shell seams. **Defense**: bounded to removing legacy query hint parsing and aligning canonical URL generation; no new frameworking/persistence; tests make the change reviewable. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 9/12** +- **Decision**: approve. + +## Summary + +Canonicalize admin navigation and request scope semantics so that: + +- Workspace hubs remain workspace-wide unless explicitly filtered by `environment_id`. +- Environment-bound pages derive environment context only from route parameters (not legacy query aliases). +- Legacy scope query keys do not grant authority, do not silently narrow results, and do not reintroduce “hidden environment context”. +- Shared seams (`EnsureWorkspaceSelected`, `EnsureEnvironmentContextSelected`, `OperateHubShell`) do not parse legacy query scope hints for authority. + +This is a hardening / cleanup slice: it removes legacy scope query hint parsing and aligns link/query semantics with the existing scope contract. + +## Completed-Spec Guardrail Result + +Related specs are context only and must not be rewritten: + +- Workspace hub contracts and legacy alias guards: Specs 314–322, 317, 338. +- Credential-adjacent authority hardening: Spec 339. +- Post-scope browser verification gate: Spec 340. + +No `specs/341-*` package existed before this prep package was created. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view (navigation + link/query contract hygiene). +- **Primary routes / surfaces affected** (contract-level; exact list is inventory-driven): + - Workspace hubs that accept environment narrowing via `environment_id` (e.g. Governance Inbox, Decision Register, Review Register, Evidence Overview, Audit Log, Alerts, Provider Connections index, Customer Review Workspace). + - Environment-bound pages under `/admin/workspaces/{workspace}/environments/{environment}/...` that must not accept legacy query scope hints. + - Shared request-scoping seams that currently inspect legacy query keys: + - `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php` + - `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php` + - `apps/platform/app/Support/OperateHub/OperateHubShell.php` + - `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php` (query-hint cleanup only) +- **Data Ownership**: No schema changes. This slice changes URL/query handling and link generation only. +- **RBAC**: + - No new capabilities. + - Existing 404 vs 403 semantics remain authoritative. + - `environment_id` is a filter hint only; it must be validated against current workspace context + actor entitlement. + +For canonical-view surfaces: + +- **Default filter behavior when tenant-context is active**: Workspace hub clean URLs are workspace-wide and must not inherit remembered environment context, Filament tenant fallback, session table filters, or legacy query keys. If narrowed, the page must show an explicit `environment_id` filter state. +- **Explicit entitlement checks preventing cross-tenant leakage**: Any requested `environment_id` must resolve to an environment in the current workspace that the actor can access; foreign/out-of-scope IDs must fail as not found and must not leak existence. + +## UI Surface Impact *(mandatory — UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [ ] No UI surface impact +- [x] Existing page changed +- [ ] New page/route added +- [x] Navigation changed +- [ ] 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 + +## UI/Productization Coverage *(UI-COV-001)* + +- **Route/page/surface**: Cross-surface link + query semantics for workspace hubs and environment-bound pages (no new UI surface). +- **Current page archetype**: scope-contract / navigation semantics (global-context-shell + workspace hub filter contract). +- **Design depth**: Domain Pattern Surface (scope/authority hardening) — minimal visible UX change expected. +- **Repo-truth level**: repo-verified (existing hub filter contracts + shared middleware/shell code). +- **Existing pattern reused**: Spec 314/315/316 hub contracts + Spec 317 legacy alias cleanup + Spec 338 contract tests + existing navigation helpers. +- **New pattern required**: none (tighten and converge; do not invent a new link-normalization framework). +- **Screenshot required**: no (unless implementation introduces visible copy changes; then add one targeted screenshot in the implementation PR only). +- **Page audit required**: no (no new archetype; contract hardening only). +- **Customer-safe review required**: no (scope contract applies broadly; not a customer-safe productization slice). +- **Dangerous-action review required**: no (no action behavior change; link/query semantics only). +- **Coverage files to update (in implementation PR)**: + - [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` (only if routes/nav entries change materially; not expected) + - [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` (no new surface; expected `no`) + - [x] `N/A - no new reachable UI surface added; contract/URL semantics only` + +## Cross-Cutting / Shared Pattern Reuse *(mandatory)* + +- **Cross-cutting feature?**: yes. +- **Interaction class(es)**: navigation deep links, scope presentation, URL/query semantics, “clear filter” behavior. +- **Systems touched (expected)**: + - `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php` + - `apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php` + - `apps/platform/app/Filament/Concerns/UsesAdminEnvironmentFilterQueryParameter.php` + - `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php` + - `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php` + - `apps/platform/app/Support/OperateHub/OperateHubShell.php` + - `apps/platform/tests/Feature/Navigation/*` +- **Existing pattern(s) to extend**: Explicit `environment_id` filter only for workspace hubs; route-owned environment for environment-bound pages; deny-as-not-found for out-of-scope. +- **Allowed deviation and why**: none. This slice removes legacy behavior; it does not add a second link/query interpretation path. +- **Consistency impact**: One canonical environment filter key (`environment_id`) for hubs; no legacy query alias authority anywhere; all “clear filter” links return to clean canonical URLs. +- **Review focus**: “legacy query scope hints are not authority” + tests that make regressions obvious. + +## OperationRun UX Impact + +N/A — no `OperationRun` start, link, or lifecycle semantics are changed in this slice. + +## Provider Boundary / Platform Core Check + +- **Shared provider/platform boundary touched?**: no. +- **N/A**: This is platform scope/navigation hygiene. It must not interpret provider “tenant” identifiers as platform authority. + +## Proportionality Review + +N/A — no new persisted entities, abstraction frameworks, enums/status families, or taxonomy systems are introduced. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature (scope/link/query contract + regression guard). +- **Validation lane(s)**: fast-feedback (Feature). Browser smoke is optional and only justified if implementation changes visible navigation/URL behavior that cannot be proven reliably in Feature tests alone. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation --filter=Canonical` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces --filter=Scope` + - `cd apps/platform && ./vendor/bin/sail pint --dirty` + +## Acceptance Criteria + +- **AC1**: Workspace hubs accept `environment_id` as the only environment-narrowing query key; legacy aliases (`tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`) never establish filter authority. +- **AC2**: Environment-bound routes derive environment context only from route params and do not parse legacy query aliases as scope hints. +- **AC3**: “Clear filter” returns to the clean canonical hub URL and clears any persisted hub filter state as per existing contracts. +- **AC4**: Out-of-scope `environment_id` is rejected with deny-as-not-found semantics and does not leak environment existence. +- **AC5**: Targeted Feature tests fail loudly on regression and pass after implementation; no new heavy-governance or browser family is introduced without an explicit spec decision. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Share a canonical workspace hub link (Priority: P1) + +As a workspace operator, I can share a link to a workspace hub that is either workspace-wide (clean URL) or explicitly narrowed to one environment using `environment_id`, and the page shows scope/filter state correctly after reload/back/forward. + +**Independent Test**: A Feature test renders each critical hub with clean URL and with `?environment_id=` and asserts filter state, clear-filter behavior, and no legacy query keys. + +### User Story 2 — Legacy query aliases never become authority (Priority: P1) + +As a security reviewer, I can trust that legacy query keys cannot establish environment context or widen results on environment-bound pages or shared scoping middleware. + +**Independent Test**: Feature tests verify that `?tenant=` and `?managed_environment_id=` are ignored or rejected consistently and never treated as an environment hint for authority. + +### User Story 3 — Guard tests prevent reintroduction (Priority: P2) + +As a developer, I have regression guards that fail CI if new code introduces legacy query parsing or generates links containing legacy scope query keys. + +**Independent Test**: A guard test scans/validates generated navigation URLs for forbidden query keys. + +## Out of Scope + +- Any feature work that changes business logic, capabilities, policies, or data model. +- Any broad UI redesign, new surfaces, or new navigation architecture. +- Any provider/OAuth or credential workflow changes. +- Any compatibility redirects or preserving legacy scope query keys “just in case”. + +## Risks + +- Some internal/shared surfaces may still rely on legacy query hints for environment selection. Implementation must replace that behavior with explicit route-owned context or explicit `environment_id` on hubs, and prove it with tests. +- Over-scoping this cleanup can become a refactor. Tasks must stay inventory-driven and bounded to canonical scope/query semantics only. + +## Assumptions + +- The workspace/environment scope contract tests and hub filter contracts remain authoritative. +- This is pre-production: removing legacy scope query keys is allowed and preferred over compatibility layers. + +## Open Questions + +None blocking. Any discovered “needed but unclear” legacy behavior must be recorded as a follow-up candidate rather than silently preserved. diff --git a/specs/341-canonical-link-query-cleanup/tasks.md b/specs/341-canonical-link-query-cleanup/tasks.md new file mode 100644 index 00000000..93cea049 --- /dev/null +++ b/specs/341-canonical-link-query-cleanup/tasks.md @@ -0,0 +1,93 @@ +# Tasks: Spec 341 - Canonical Link / Query Cleanup + +- Input: `specs/341-canonical-link-query-cleanup/spec.md`, `specs/341-canonical-link-query-cleanup/plan.md` +- Preparation status: implementation-ready. + +**Tests**: Required. This spec hardens scope/URL semantics and must be guarded by deterministic Feature tests. + +## Test Governance Checklist + +- [x] Lane assignment is explicit and narrow: Feature (navigation/scope contract). +- [x] No new default-heavy helpers/factories/seeds are introduced. +- [x] Contract tests are written before refactors to keep review safe. +- [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**: Identify every remaining legacy scope query parsing seam and the canonical link helpers to reuse. + +- [x] T001 Re-read `specs/341-canonical-link-query-cleanup/spec.md`, `specs/341-canonical-link-query-cleanup/plan.md`, and this `tasks.md`. +- [x] T002 Confirm branch and working tree intent and record baseline commit (`git status --short --branch`, `git log -1 --oneline`). +- [x] T003 Inventory legacy scope query parsing in runtime code (focus: request query keys, not DB column names): + - `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php` (`query('tenant')`, `query('managed_environment_id')`) + - `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php` (legacy query hints) + - `apps/platform/app/Support/OperateHub/OperateHubShell.php` (legacy query tenant hints) + - `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php` (legacy query hint) + - Search guard: `rg -n \"query\\('tenant'\\)|query\\('managed_environment_id'\\)\" apps/platform/app` +- [x] T004 Inventory existing navigation contract tests and decide where Spec 341 regression coverage belongs: + - `apps/platform/tests/Feature/Navigation/Spec322LegacyQueryAliasGuardTest.php` + - `apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php` + - `apps/platform/tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php` + - `apps/platform/tests/Feature/Workspaces/WorkspaceHubContextContractTest.php` + +## Phase 2: Add failing contract tests first + +**Purpose**: Make the cleanup reviewable and prevent accidental reintroduction of legacy scope hints. + +- [x] T005 Add a Spec 341 Feature test proving legacy scope query keys do not establish authority in shared seams: + - Requests with `?tenant=` or `?managed_environment_id=` do not establish environment context and do not widen access. + - Implement in: `apps/platform/tests/Feature/Navigation/Spec341CanonicalLinkQueryCleanupTest.php` +- [x] T006 [P] Add a test proving workspace hub narrowing is `environment_id`-only: + - Every in-scope hub URL accepts `environment_id`; + - legacy aliases (`tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`) are rejected/ignored. + - Extend/verify: `apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php` +- [x] T007 [P] Add a test proving environment-bound pages remain route-owned: + - No environment-bound route derives environment scope from legacy query keys. + - Target at least one representative environment-bound route (e.g. Baseline Compare) plus one middleware-driven entry. + +## Phase 3: Remove legacy query scope hint parsing (Spec 341 contract) + +**Purpose**: Remove hidden environment authority sources and converge on explicit `environment_id` (hubs) or route-owned environment context (environment-bound pages). + +- [x] T008 Update `apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php`: + - remove `?tenant=` / `?managed_environment_id=` handling as “explicit tenant context” signals; + - keep workspace selection and deny-as-not-found semantics correct. +- [x] T009 Update `apps/platform/app/Support/Middleware/EnsureEnvironmentContextSelected.php`: + - remove legacy scope query hint parsing; + - ensure workspace hubs remain exempt from tenant/environment selection requirements. +- [x] T010 Update `apps/platform/app/Support/OperateHub/OperateHubShell.php`: + - remove `resolveQueryTenantHint()` and related “explicit query tenant hint” behavior (or convert to ignore-only with no authority); + - ensure all environment-bound context comes from route parameters + validated workspace context. +- [x] T011 Update `apps/platform/app/Filament/Pages/EnvironmentRequiredPermissions.php`: + - remove legacy query hint parsing; route-owned environment only. + +## Phase 4: Align canonical link generation + +**Purpose**: Ensure the code never generates legacy scope query keys and that “clear filter” links return to clean canonical URLs. + +- [x] T012 Remove/replace any link generation that emits legacy scope query keys (focus: URL query keys, not Graph tenant context): + - Use `rg -n \"\\?tenant=|\\btenant=\\\"|query\\('tenant'\\)\" apps/platform` to find offenders. +- [x] T013 Confirm `apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php` and `WorkspaceHubEnvironmentFilter.php` remain the single source for hub filter link building and parsing (`environment_id` only). +- [x] T014 Confirm `apps/platform/app/Filament/Concerns/UsesAdminEnvironmentFilterQueryParameter.php` continues to strip legacy scope keys and that no new legacy keys are added elsewhere. + +## Phase 5: Regression guards + +**Purpose**: Prevent future drift back to legacy scope query keys. + +- [x] T015 Add a guard test that fails if generated navigation URLs contain forbidden scope query keys (e.g. `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`). +- [x] T016 If a bounded exception is proven necessary, document it explicitly in the spec/PR and add a dedicated test; otherwise treat legacy scope alias preservation as a blocker. + +## Phase 6: Validation + +- [x] T017 Run narrow tests first: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation --filter=Spec341` +- [x] T018 Run formatting and patch checks: + - `cd apps/platform && ./vendor/bin/sail pint --dirty` + - `git diff --check` + +## Explicit Non-Goals + +- [x] NT001 Do not add migrations, new tables, or new persisted truth. +- [x] NT002 Do not introduce a new link-normalization abstraction framework. +- [x] NT003 Do not add compatibility redirects for legacy query keys. +- [x] NT004 Do not change provider/OAuth behavior or credential flows (Spec 281 family).