feat: enforce workspace and environment scope contract (Spec 338)

This commit is contained in:
Ahmed Darrazi 2026-05-31 03:34:40 +02:00
parent b7c0dfe0e3
commit d0f3ff25be
72 changed files with 1444 additions and 279 deletions

View File

@ -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());

View File

@ -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') {

View File

@ -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();

View File

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

View File

@ -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)) {

View File

@ -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')

View File

@ -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');

View File

@ -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();

View File

@ -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();

View File

@ -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();

View File

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

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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,
};
}

View File

@ -17,7 +17,6 @@ public static function isEnvironmentSurface(?Request $request = null): bool
{
return in_array(self::pageCategory($request), [
AdminSurfaceScope::EnvironmentBound,
AdminSurfaceScope::EnvironmentScopedEvidence,
], true);
}

View File

@ -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()],
],
] : [],
);
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Support\Navigation;
use App\Models\ManagedEnvironment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
final class WorkspaceHubNavigation
{
public static function workspaceWideGroup(string $fallbackGroup): string
{
return NavigationScope::isEnvironmentSurface()
? __('localization.navigation.workspace_wide')
: $fallbackGroup;
}
public static function workspaceAdminGroup(): string
{
return NavigationScope::isEnvironmentSurface()
? __('localization.navigation.workspace_admin')
: __('localization.navigation.settings');
}
public static function environmentFilteredUrl(string $url): string
{
$url = WorkspaceHubRegistry::cleanUrl($url);
$environment = self::environmentContext();
if (! $environment instanceof ManagedEnvironment) {
return $url;
}
return url()->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());
}
}

View File

@ -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),
};

View File

@ -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);

View File

@ -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,

View File

@ -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' => [

View File

@ -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' => [

View File

@ -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
<div data-testid="{{ $flowTestId }}" class="space-y-5">
<div data-testid="{{ $flowTestId }}" class="{{ $flowClasses }}">
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
{{ $title ?? 'Product process flow' }}
@ -26,7 +44,7 @@
@endif
</div>
<ol class="flex flex-col gap-3 lg:flex-row lg:items-stretch lg:gap-1.5" aria-label="{{ $ariaLabel ?? 'Product process flow' }}">
<ol class="{{ $listClasses }}" aria-label="{{ $ariaLabel ?? 'Product process flow' }}">
@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 }}"
>
<div class="flex min-w-0 flex-1 flex-col rounded-lg border px-3 py-2.5 {{ $stepCardClasses }}">
<div class="flex min-w-0 items-start gap-3 lg:flex-col lg:gap-2">
<div class="{{ $stepHeaderClasses }}">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none {{ $stepNumberClasses }}">
{{ $loop->iteration }}
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold leading-5 text-gray-950 break-normal dark:text-white">
<div class="break-words text-sm font-semibold leading-5 text-gray-950 dark:text-white">
{{ $step['label'] }}
</div>
@ -64,7 +82,7 @@ class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
</div>
</div>
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
<p class="mt-2 break-words text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $step['description'] }}
</p>
</div>
@ -73,10 +91,10 @@ class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
<div
data-testid="{{ $connectorTestId }}"
data-connector-label="{{ $step['label'] }} to {{ $steps[$loop->index + 1]['label'] ?? '' }}"
class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-500 lg:w-6"
class="{{ $connectorClasses }}"
aria-hidden="true"
>
<span class="inline-flex h-7 min-w-7 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">
<span class="{{ $connectorIconClasses }}">
&rarr;
</span>
</div>

View File

@ -49,8 +49,8 @@
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_22rem]" data-testid="evidence-disclosure-workbench">
<main class="min-w-0 space-y-4" data-testid="evidence-proof-primary">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-3">
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
Primary proof path
@ -60,6 +60,32 @@
</span>
</div>
@if ($decisionCard['actionUrl'])
<x-filament::button
tag="a"
:href="$decisionCard['actionUrl']"
icon="heroicon-o-arrow-top-right-on-square"
class="shrink-0 self-start whitespace-nowrap"
data-testid="evidence-primary-proof-action"
>
{{ $decisionCard['actionLabel'] }}
</x-filament::button>
@else
<div class="shrink-0 self-start sm:text-right">
<x-filament::button color="gray" disabled data-testid="evidence-primary-proof-action">
{{ $decisionCard['actionLabel'] }}
</x-filament::button>
@if ($decisionCard['helperText'])
<p class="mt-2 max-w-64 text-xs leading-5 text-gray-500 dark:text-gray-400 sm:ml-auto">
{{ $decisionCard['helperText'] }}
</p>
@endif
</div>
@endif
</div>
<div class="space-y-3">
<div>
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">
{{ $payload['primary_title'] }}
@ -134,78 +160,8 @@
</div>
</dl>
</div>
@if ($decisionCard['actionUrl'])
<x-filament::button
tag="a"
:href="$decisionCard['actionUrl']"
icon="heroicon-o-arrow-top-right-on-square"
class="shrink-0 self-start whitespace-nowrap"
data-testid="evidence-primary-proof-action"
>
{{ $decisionCard['actionLabel'] }}
</x-filament::button>
@else
<div class="shrink-0 self-start">
<x-filament::button color="gray" disabled data-testid="evidence-primary-proof-action">
{{ $decisionCard['actionLabel'] }}
</x-filament::button>
@if ($decisionCard['helperText'])
<p class="mt-2 max-w-64 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $decisionCard['helperText'] }}
</p>
@endif
</div>
@endif
</div>
</div>
@if (! empty($payload['readiness_flow']))
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
@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,
])
</div>
@endif
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900" data-testid="evidence-review-pack-coverage">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">
Review pack contents / coverage
</h3>
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
Repo-backed values only.
</p>
</div>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $payload['review_pack_coverage']['description'] }}
</p>
@if (! empty($payload['review_pack_coverage']['items']))
<div class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
@foreach ($payload['review_pack_coverage']['items'] as $coverageItem)
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
{{ $coverageItem['label'] }}
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $coverageItem['value'] }}
</div>
</div>
@endforeach
</div>
@endif
</div>
</main>
<aside class="min-w-0 space-y-4" data-testid="evidence-proof-aside">
@ -261,6 +217,53 @@ class="rounded-xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:bor
</aside>
</div>
@if (! empty($payload['readiness_flow']))
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
@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',
])
</div>
@endif
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900" data-testid="evidence-review-pack-coverage">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">
Review pack contents / coverage
</h3>
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
Repo-backed values only.
</p>
</div>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $payload['review_pack_coverage']['description'] }}
</p>
@if (! empty($payload['review_pack_coverage']['items']))
<div class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
@foreach ($payload['review_pack_coverage']['items'] as $coverageItem)
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">
{{ $coverageItem['label'] }}
</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $coverageItem['value'] }}
</div>
</div>
@endforeach
</div>
@endif
</div>
<div class="space-y-3">
<div>
<h2 class="text-sm font-semibold text-gray-950 dark:text-white">

View File

@ -22,9 +22,11 @@
</div>
<div class="flex flex-wrap gap-2 text-sm">
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $landingHierarchy['scope_label'] }}
</span>
@if ($landingHierarchy['scope_label'] !== __('localization.shell.all_environments'))
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $landingHierarchy['scope_label'] }}
</span>
@endif
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $landingHierarchy['scope_body'] }}
</span>

View File

@ -88,7 +88,9 @@
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
@if ($monitoringDetail['scope_label'] !== __('localization.shell.all_environments'))
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
@endif
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
</div>

View File

@ -60,15 +60,6 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
</span>
</div>
<x-filament::button
color="gray"
size="sm"
wire:click="goToChooseEnvironment"
icon="heroicon-m-arrow-right"
icon-position="after"
>
{{ __('localization.shell.choose_environment') }}
</x-filament::button>
</div>
{{-- ManagedEnvironment cards --}}

View File

@ -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 }}
</a>
@if ($workspace)
@if ($workspace && $hasActiveEnvironment)
<x-filament::icon icon="heroicon-m-chevron-right" class="h-3 w-3 shrink-0 text-gray-300 dark:text-gray-600" />
@endif
{{-- Dropdown trigger: environment label + chevron --}}
{{-- Dropdown trigger: environment label or compact picker + chevron --}}
<x-filament::dropdown placement="bottom-start" teleport width="xs">
<x-slot name="trigger">
<button
type="button"
aria-label="{{ $workspace ? __('localization.shell.environment_scope') : __('localization.shell.select_environment') }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
aria-label="{{ $environmentTriggerAriaLabel }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10 {{ $workspace && ! $hasActiveEnvironment ? 'border-l border-gray-200 dark:border-white/10' : '' }}"
>
<span class="{{ $workspace && $hasActiveEnvironment ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $environmentTriggerLabel }}
</span>
@if ($environmentTriggerLabel !== null)
<span class="{{ $workspace && $hasActiveEnvironment ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
{{ $environmentTriggerLabel }}
</span>
@else
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
@endif
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
</button>
@ -131,7 +138,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.selected_environment') }}
{{ $hasActiveEnvironment ? __('localization.shell.selected_environment') : __('localization.shell.choose_environment') }}
</div>
</div>
@ -168,12 +175,6 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
</a>
</div>
@else
@if (! $hasActiveEnvironment)
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
{{ __('localization.shell.workspace_wide_available_without_environment') }}
</div>
@endif
<input
type="text"
class="fi-input fi-text-input w-full"

View File

@ -0,0 +1,80 @@
@php
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperateHub\OperateHubShell;
use Filament\Facades\Filament;
$resolvedContext = app(OperateHubShell::class)->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)
<a
href="{{ $scopeUrl }}"
wire:navigate
data-testid="admin-sidebar-scope-indicator"
aria-label="{{ $scopeKind }}: {{ $scopeName }}"
title="{{ $scopeActionLabel }}"
class="group mx-3 mb-4 flex min-w-0 items-center gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-start shadow-xs transition hover:border-gray-300 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
>
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border {{ $isEnvironmentScope ? 'border-primary-200 bg-primary-50 text-primary-600 dark:border-primary-800 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-gray-50 text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' }}">
<x-filament::icon
:icon="$isEnvironmentScope ? 'heroicon-o-building-office-2' : 'heroicon-o-squares-2x2'"
class="h-4 w-4"
/>
</span>
<span class="min-w-0 flex-1">
<span class="block text-[0.6875rem] font-semibold uppercase text-gray-500 dark:text-gray-400">
{{ $scopeKind }}
</span>
<span class="mt-0.5 block truncate text-sm font-semibold text-gray-950 dark:text-white" title="{{ $scopeName }}">
{{ $scopeName }}
</span>
<span class="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
{{ $scopeDescription }}
</span>
</span>
<x-filament::icon
icon="heroicon-m-chevron-right"
class="h-4 w-4 shrink-0 text-gray-300 transition group-hover:text-gray-400 dark:text-gray-600 dark:group-hover:text-gray-400"
/>
</a>
@endif

View File

@ -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();
});
});

View File

@ -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]);
});

View File

@ -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();

View File

@ -83,7 +83,7 @@
foreach ($configurationUrls as $url) {
visit($url)
->waitForText(__('localization.shell.no_environment_selected'))
->waitForText('Alerts')
->assertDontSee('Environment filter:')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\EnvironmentDashboard;
use App\Filament\Resources\EvidenceSnapshotResource;
use Tests\Browser\Support\Spec322WorkspaceEnvironmentBrowserHarness as Spec322Harness;
pest()->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();
});

View File

@ -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(

View File

@ -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');
});

View File

@ -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');

View File

@ -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');
});

View File

@ -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);
});

View File

@ -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');
});

View File

@ -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');

View File

@ -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')

View File

@ -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();

View File

@ -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.');
});

View File

@ -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);
});

View File

@ -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 {

View File

@ -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']]);

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\EnvironmentDashboard;
use App\Models\ManagedEnvironment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Navigation\NavigationItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return list<array{label: string, group: string|null, url: string|null, parent: string|null}>
*/
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');
});

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('Spec338 OperationRunLinks operation type deep links use operation_type and never tableFilters', function (): void {
[$workspace] = localizationWorkspaceMember();
$url = OperationRunLinks::index(operationType: 'inventory_sync', workspace: $workspace);
expect($url)
->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]);
});

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\EnvironmentDashboard;
use App\Models\ManagedEnvironment;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows an explicit workspace scope indicator in the sidebar on workspace-owned pages', function (): void {
$environment = ManagedEnvironment::factory()->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]));
});

View File

@ -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.');
});

View File

@ -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');

View File

@ -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 {

View File

@ -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();
});

View File

@ -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);
});

View File

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

View File

@ -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'));
});

View File

@ -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'));

View File

@ -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')

View File

@ -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));
});

View File

@ -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');
});

View File

@ -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],

View File

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

View File

@ -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 TenantPilots 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=<canonical-code>` 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 <new/updated Spec 338 tests>`
- `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.

View File

@ -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**: TenantPilots 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 pages 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 specs 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=<canonical-code>`
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)

View File

@ -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 <new/updated Spec 338 tests>`
- [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).