Spec 103: IA semantics (scope vs filter vs targeting) + UI polish (#126)

Implements Spec 103 (IA semantics: Scope vs Filter vs Targeting) across Monitoring + Manage.

Changes
- Monitoring tenant indicator copy: “All tenants” / “Filtered by tenant: …”
- Alerts KPI header resolves tenant via OperateHubShell::activeEntitledTenant() for consistency
- Manage list pages (Alert Rules / Destinations) no longer show tenant indicator
- AlertRule form uses targeting semantics + sections (Rule / Applies to / Delivery)
- Additional UI polish: resource sections, tenant view widgets layout, RBAC progressive disclosure (“Not configured” when empty)

Notes
- US6 (“Add current tenant” convenience button) intentionally skipped (optional P3).

Testing
- CI=1 vendor/bin/sail artisan test tests/Feature/TenantRBAC/ tests/Feature/Onboarding/OnboardingIdentifyTenantTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #126
This commit is contained in:
ahmido 2026-02-21 00:28:15 +00:00
parent 558b5d3807
commit d32b2115a8
25 changed files with 1448 additions and 203 deletions

View File

@ -31,6 +31,8 @@ ## Active Technologies
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class (103-ia-scope-filter-semantics)
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -50,6 +52,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 103-ia-scope-filter-semantics: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class
- 101-golden-master-baseline-governance-v1: Added PHP 8.4.x
- 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
<!-- MANUAL ADDITIONS START -->

View File

@ -5,7 +5,6 @@
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
@ -16,10 +15,6 @@ class ListAlertDestinations extends ListRecords
protected function getHeaderActions(): array
{
return [
...app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
),
CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),

View File

@ -28,6 +28,7 @@
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
@ -144,64 +145,76 @@ public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
Select::make('event_type')
->required()
->options(self::eventTypeOptions())
->native(false),
Select::make('minimum_severity')
->required()
->options(self::severityOptions())
->native(false),
Select::make('tenant_scope_mode')
->required()
->options([
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Allowlist',
])
->default(AlertRule::TENANT_SCOPE_ALL)
->native(false)
->live(),
Select::make('tenant_allowlist')
->label('Tenant allowlist')
->multiple()
->options(self::tenantOptions())
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
->native(false),
TextInput::make('cooldown_seconds')
->label('Cooldown (seconds)')
->numeric()
->minValue(0)
->nullable(),
Toggle::make('quiet_hours_enabled')
->label('Enable quiet hours')
->default(false)
->live(),
TextInput::make('quiet_hours_start')
->label('Quiet hours start')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
TextInput::make('quiet_hours_end')
->label('Quiet hours end')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('quiet_hours_timezone')
->label('Quiet hours timezone')
->options(self::timezoneOptions())
->searchable()
->native(false)
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('destination_ids')
->label('Destinations')
->multiple()
->required()
->options(self::destinationOptions())
->native(false),
Section::make('Rule')
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Toggle::make('is_enabled')
->label('Enabled')
->default(true),
Select::make('event_type')
->required()
->options(self::eventTypeOptions())
->native(false),
Select::make('minimum_severity')
->required()
->options(self::severityOptions())
->native(false),
]),
Section::make('Applies to')
->schema([
Select::make('tenant_scope_mode')
->label('Applies to tenants')
->required()
->options([
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Selected tenants',
])
->default(AlertRule::TENANT_SCOPE_ALL)
->native(false)
->live()
->helperText('This rule is workspace-wide. Use this to limit where it applies.'),
Select::make('tenant_allowlist')
->label('Selected tenants')
->multiple()
->options(self::tenantOptions())
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
->native(false)
->helperText('Only these tenants will trigger this rule.'),
]),
Section::make('Delivery')
->schema([
TextInput::make('cooldown_seconds')
->label('Cooldown (seconds)')
->numeric()
->minValue(0)
->nullable(),
Toggle::make('quiet_hours_enabled')
->label('Enable quiet hours')
->default(false)
->live(),
TextInput::make('quiet_hours_start')
->label('Quiet hours start')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
TextInput::make('quiet_hours_end')
->label('Quiet hours end')
->type('time')
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('quiet_hours_timezone')
->label('Quiet hours timezone')
->options(self::timezoneOptions())
->searchable()
->native(false)
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
Select::make('destination_ids')
->label('Destinations')
->multiple()
->required()
->options(self::destinationOptions())
->native(false),
]),
]);
}

View File

@ -5,7 +5,6 @@
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
@ -16,10 +15,6 @@ class ListAlertRules extends ListRecords
protected function getHeaderActions(): array
{
return [
...app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
),
CreateAction::make()
->label('Create rule')
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),

View File

@ -140,35 +140,44 @@ public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->helperText('A descriptive name for this baseline profile.'),
Textarea::make('description')
->rows(3)
->maxLength(1000)
->helperText('Explain the purpose and scope of this baseline.'),
TextInput::make('version_label')
->label('Version label')
->maxLength(50)
->placeholder('e.g. v2.1 — February rollout')
->helperText('Optional label to identify this version.'),
Select::make('status')
->required()
->options([
BaselineProfile::STATUS_DRAFT => 'Draft',
BaselineProfile::STATUS_ACTIVE => 'Active',
BaselineProfile::STATUS_ARCHIVED => 'Archived',
Section::make('Profile')
->schema([
TextInput::make('name')
->required()
->maxLength(255)
->helperText('A descriptive name for this baseline profile.'),
Textarea::make('description')
->rows(3)
->maxLength(1000)
->helperText('Explain the purpose and scope of this baseline.'),
TextInput::make('version_label')
->label('Version label')
->maxLength(50)
->placeholder('e.g. v2.1 — February rollout')
->helperText('Optional label to identify this version.'),
Select::make('status')
->required()
->options([
BaselineProfile::STATUS_DRAFT => 'Draft',
BaselineProfile::STATUS_ACTIVE => 'Active',
BaselineProfile::STATUS_ARCHIVED => 'Archived',
])
->default(BaselineProfile::STATUS_DRAFT)
->native(false)
->helperText('Only active baselines are enforced during compliance checks.'),
])
->default(BaselineProfile::STATUS_DRAFT)
->native(false)
->helperText('Only active baselines are enforced during compliance checks.'),
Select::make('scope_jsonb.policy_types')
->label('Policy type scope')
->multiple()
->options(self::policyTypeOptions())
->helperText('Leave empty to include all policy types.')
->native(false),
->columns(2)
->columnSpanFull(),
Section::make('Scope')
->schema([
Select::make('scope_jsonb.policy_types')
->label('Policy type scope')
->multiple()
->options(self::policyTypeOptions())
->helperText('Leave empty to include all policy types.')
->native(false),
])
->columnSpanFull(),
]);
}

View File

@ -32,6 +32,7 @@
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Filters\Filter;
@ -401,29 +402,39 @@ public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('display_name')
->label('Display name')
->required()
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Entra tenant ID')
->required()
->maxLength(255)
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']),
Toggle::make('is_default')
->label('Default connection')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->helperText('Exactly one default connection is required per tenant/provider.'),
TextInput::make('status')
->label('Status')
->disabled()
->dehydrated(false),
TextInput::make('health_status')
->label('Health')
->disabled()
->dehydrated(false),
Section::make('Connection')
->schema([
TextInput::make('display_name')
->label('Display name')
->required()
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Entra tenant ID')
->required()
->maxLength(255)
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']),
Toggle::make('is_default')
->label('Default connection')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->helperText('Exactly one default connection is required per tenant/provider.'),
])
->columns(2)
->columnSpanFull(),
Section::make('Status')
->schema([
TextInput::make('status')
->label('Status')
->disabled()
->dehydrated(false),
TextInput::make('health_status')
->label('Health')
->disabled()
->dehydrated(false),
])
->columns(2)
->columnSpanFull(),
]);
}

View File

@ -46,6 +46,7 @@
use Filament\Infolists;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
@ -832,69 +833,129 @@ public static function infolist(Schema $schema): Schema
// ... [Infolist Omitted - No Change] ...
return $schema
->schema([
Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
Infolists\Components\TextEntry::make('domain')->copyable(),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Infolists\Components\ViewEntry::make('provider_connection_state')
->label('Provider connection')
->state(fn (Tenant $record): array => static::providerConnectionState($record))
->view('filament.infolists.entries.provider-connection-state')
->columnSpanFull(),
Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
Infolists\Components\TextEntry::make('rbac_status')
->label('RBAC status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus)),
Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'),
Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(),
Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
Infolists\Components\TextEntry::make('rbac_role_definition_id')->label('Role definition ID')->copyable(),
Infolists\Components\TextEntry::make('rbac_scope_mode')->label('RBAC scope'),
Infolists\Components\TextEntry::make('rbac_scope_id')->label('Scope ID'),
Infolists\Components\TextEntry::make('rbac_group_id')->label('RBAC group ID')->copyable(),
Infolists\Components\TextEntry::make('rbac_role_assignment_id')->label('Role assignment ID')->copyable(),
Infolists\Components\ViewEntry::make('rbac_summary')
->label('Last RBAC Setup')
->view('filament.infolists.entries.rbac-summary')
->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
Infolists\Components\TextEntry::make('admin_consent_url')
->label('Admin consent URL')
->state(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (?string $state) => filled($state))
->copyable(),
Infolists\Components\RepeatableEntry::make('permissions')
->label('Required permissions')
->state(fn (Tenant $record) => static::storedPermissionSnapshot($record))
Section::make('Identity')
->schema([
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
Infolists\Components\TextEntry::make('type')->badge(),
Infolists\Components\TextEntry::make('features')
->label('Features')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
Infolists\Components\TextEntry::make('domain')->copyable(),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)),
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
])
->columns(2)
->columnSpanFull(),
Section::make('Provider')
->schema([
Infolists\Components\ViewEntry::make('provider_connection_state')
->label('Provider connection')
->state(fn (Tenant $record): array => static::providerConnectionState($record))
->view('filament.infolists.entries.provider-connection-state')
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('RBAC')
->schema([
Infolists\Components\TextEntry::make('rbac_not_configured_hint')
->label('Status')
->state('Not configured')
->icon('heroicon-o-shield-exclamation')
->color('warning')
->columnSpanFull()
->visible(fn (Tenant $record): bool => blank($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_status')
->label('RBAC status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus))
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_status_reason')
->label('RBAC reason')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_last_checked_at')
->label('RBAC last checked')
->since()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_role_display_name')
->label('RBAC role')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_role_definition_id')
->label('Role definition ID')
->copyable()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_scope_mode')
->label('RBAC scope')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_scope_id')
->label('Scope ID')
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_group_id')
->label('RBAC group ID')
->copyable()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\TextEntry::make('rbac_role_assignment_id')
->label('Role assignment ID')
->copyable()
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Infolists\Components\ViewEntry::make('rbac_summary')
->label('Last RBAC Setup')
->view('filament.infolists.entries.rbac-summary')
->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
])
->columns(2)
->columnSpanFull()
->collapsible(),
Section::make('Integration')
->schema([
Infolists\Components\TextEntry::make('admin_consent_url')
->label('Admin consent URL')
->state(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (?string $state) => filled($state))
->copyable()
->columnSpanFull(),
])
->columnSpanFull()
->collapsible(),
Section::make('Metadata')
->schema([
Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
])
->columns(2)
->columnSpanFull()
->collapsed(),
Section::make('Required permissions')
->schema([
Infolists\Components\RepeatableEntry::make('permissions')
->label('')
->state(fn (Tenant $record) => static::storedPermissionSnapshot($record))
->schema([
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
Infolists\Components\TextEntry::make('type')->badge(),
Infolists\Components\TextEntry::make('features')
->label('Features')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)),
])
->columnSpanFull(),
])
->columnSpanFull()
->collapsible(),
]);
}

View File

@ -22,6 +22,11 @@ class ViewTenant extends ViewRecord
{
protected static string $resource = TenantResource::class;
public function getHeaderWidgetsColumns(): int|array
{
return 1;
}
protected function getHeaderWidgets(): array
{
return [

View File

@ -12,8 +12,8 @@
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder;
@ -96,7 +96,7 @@ private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'));
$activeTenant = Filament::getTenant();
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$query->where('tenant_id', (int) $activeTenant->getKey());

View File

@ -25,10 +25,10 @@ public function scopeLabel(?Request $request = null): string
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return 'Scope: Tenant — '.$activeTenant->name;
return 'Filtered by tenant: '.$activeTenant->name;
}
return 'Scope: Workspace — all tenants';
return 'All tenants';
}
/**

View File

@ -4,24 +4,22 @@
/** @var string $operationsIndexUrl */
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold">Recent operations</div>
<x-filament::section heading="Recent operations">
<x-slot name="afterHeader">
<a
href="{{ $operationsIndexUrl }}"
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
View all operations
</a>
</div>
</x-slot>
@if ($runs->isEmpty())
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
<div class="text-sm text-gray-500 dark:text-gray-400">
No operations yet.
</div>
@else
<ul class="mt-3 divide-y divide-gray-100 dark:divide-gray-800">
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
@foreach ($runs as $run)
<li class="flex items-center justify-between gap-3 py-2">
<div class="min-w-0">
@ -51,5 +49,5 @@ class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-pri
@endforeach
</ul>
@endif
</div>
</x-filament::section>

View File

@ -0,0 +1,37 @@
# Specification Quality Checklist: IA Semantics — Scope vs Filter vs Targeting
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-20
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- FR-001 through FR-014 are all testable with clear pass/fail criteria mapped to acceptance scenarios.
- The spec intentionally references codebase-specific method names (e.g. `OperateHubShell::scopeLabel()`, `activeEntitledTenant()`) in the Assumptions section and Implementation Notes to provide implementation context — these are scoped to assumptions, not leaked into functional requirements or success criteria.
- User Story 6 (P3, Optional) is explicitly marked as non-required for DoD. This is intentional and documented.
- No [NEEDS CLARIFICATION] markers — all decisions were resolvable from the detailed user input specification.

View File

@ -0,0 +1,34 @@
# Data Model: 103 — IA Semantics: Scope vs Filter vs Targeting
**Status**: No schema changes required.
## Summary
This spec is a copy/label/bugfix change only. No new tables, columns, indexes, or migrations.
## Existing Entities (unchanged)
### AlertRule
| Field | Type | Notes |
|---|---|---|
| tenant_scope_mode | string | Values: `all`, `allowlist`. DB values unchanged. |
| tenant_allowlist | json | Array of tenant UUIDs. Structure unchanged. |
**Constants**: `AlertRule::TENANT_SCOPE_ALL`, `AlertRule::TENANT_SCOPE_ALLOWLIST` — unchanged.
### Tenant
Referenced by `OperateHubShell::activeEntitledTenant()` for indicator display. No changes.
## UI-Only Changes (no persistence impact)
| Current Label | New Label | Location |
|---|---|---|
| `Scope: Tenant — {name}` | `Filtered by tenant: {name}` | `OperateHubShell::scopeLabel()` |
| `Scope: Workspace — all tenants` | `All tenants` | `OperateHubShell::scopeLabel()` |
| Tenant scope mode → "All tenants" | "All tenants" | `AlertRuleResource::form()` option |
| Tenant scope mode → "Allowlist" | "Selected tenants" | `AlertRuleResource::form()` option |
| "Tenant allowlist" label | "Selected tenants" | `AlertRuleResource::form()` field label |
## State Transitions
N/A — no state machine changes.

View File

@ -0,0 +1,241 @@
# Implementation Plan: IA Semantics — Scope vs Filter vs Targeting (Monitoring + Manage)
**Branch**: `103-ia-scope-filter-semantics` | **Date**: 2026-02-20 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/103-ia-scope-filter-semantics/spec.md`
## Summary
Replace "Scope: …" wording with "Filtered by tenant: …" / "All tenants" on Monitoring canonical pages, fix the `AlertsKpiHeader` tenant-resolution bug that causes KPI numbers to diverge from the indicator when lastTenantId fallback is active, remove the semantically incorrect tenant indicator from workspace-owned Manage pages (Alert Rules, Alert Destinations), and relabel + restructure the AlertRule edit form to use targeting semantics instead of scope semantics.
**Technical approach**: Update `OperateHubShell::scopeLabel()` (single source of truth for indicator copy), fix `AlertsKpiHeader::deliveriesQueryForViewer()` to call `OperateHubShell::activeEntitledTenant()` instead of `Filament::getTenant()`, remove header-action spreads from Manage list pages, and refactor `AlertRuleResource::form()` labels/sections.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, `OperateHubShell` support class
**Storage**: PostgreSQL — no schema changes
**Testing**: Pest v4 (feature tests)
**Target Platform**: Web (Laravel Sail / Dokploy)
**Project Type**: Web application (Laravel monolith)
**Performance Goals**: N/A — copy + bugfix, no performance-sensitive changes
**Constraints**: DB-only renders on Monitoring pages (no Graph/HTTP calls)
**Scale/Scope**: 5 source files modified, 12 test files updated/created
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- [x] **Inventory-first**: N/A — no inventory data touched.
- [x] **Read/write separation**: No new writes introduced. The KPI bugfix corrects an existing read query filter — no preview/confirmation/audit needed.
- [x] **Graph contract path**: No Graph calls introduced or modified. All changes are DB-only.
- [x] **Deterministic capabilities**: No capability changes. Existing RBAC enforcement unchanged.
- [x] **RBAC-UX (two planes)**: No routing changes. All affected pages remain on `/admin` plane. No cross-plane routing introduced.
- [x] **Workspace isolation**: No workspace isolation changes. Monitoring pages remain workspace-context. Manage pages remain workspace-owned.
- [x] **RBAC-UX (destructive actions)**: No destructive actions added. Removal of OperateHubShell header actions from Manage pages removes informational indicators, not mutation actions.
- [x] **RBAC-UX (global search)**: No global search changes.
- [x] **Tenant isolation**: The bugfix *improves* tenant isolation by ensuring KPI queries respect the resolved tenant context. No cross-tenant views introduced.
- [x] **Run observability**: No long-running/remote/queued work introduced. All changes are synchronous UI/copy.
- [x] **Automation**: N/A — no queued/scheduled ops.
- [x] **Data minimization**: N/A — no new data storage.
- [x] **Badge semantics (BADGE-001)**: No status badges added or changed. The "Filtered by tenant" indicator is informational copy, not a status badge.
- [x] **Filament UI Action Surface Contract**: See UI Action Matrix in spec.md. No new actions. Removed header actions on Manage pages were informational. Existing destructive actions unchanged.
- [x] **Filament UI UX-001**: AlertRule form adds Section grouping (conforming to "all fields inside Sections/Cards"). No new pages.
**Post-Phase 1 re-check**: All gates remain satisfied. No design decisions introduced violations.
## Project Structure
### Documentation (this feature)
```text
specs/103-ia-scope-filter-semantics/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 output — codebase research findings
├── data-model.md # Phase 1 output — no schema changes documented
├── quickstart.md # Phase 1 output — files to touch + verification commands
├── checklists/
│ └── requirements.md # Spec quality checklist (from /speckit.spec)
└── tasks.md # Phase 2 output (NOT created by /speckit.plan)
```
### Source Code (files to modify)
```text
app/
├── Support/OperateHub/
│ └── OperateHubShell.php # T1: Update scopeLabel() copy
├── Filament/
│ ├── Widgets/Alerts/
│ │ └── AlertsKpiHeader.php # T2: Fix deliveriesQueryForViewer() bug
│ ├── Resources/
│ │ ├── AlertRuleResource.php # T4/T5: Relabel form + add Sections
│ │ └── AlertRuleResource/Pages/
│ │ └── ListAlertRules.php # T3: Remove OperateHubShell header spread
│ └── Resources/AlertDestinationResource/Pages/
│ └── ListAlertDestinations.php # T3: Remove OperateHubShell header spread
tests/
├── Feature/OpsUx/
│ └── OperateHubShellTest.php # T6: Update existing assertions for new copy
└── Feature/Filament/Alerts/
└── AlertRuleCrudTest.php # T6: Verify/update label-dependent assertions
```
**Structure Decision**: Existing Laravel monolith structure. All modifications are to existing files. No new directories or structural changes.
## Implementation Tasks
### T1 — Update `OperateHubShell::scopeLabel()` Copy (FR-001, FR-002, FR-003)
**File**: `app/Support/OperateHub/OperateHubShell.php` (line 2333)
**Current**:
```php
public function scopeLabel(?Request $request = null): string
{
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return 'Scope: Tenant — '.$activeTenant->name;
}
return 'Scope: Workspace — all tenants';
}
```
**Target**:
```php
public function scopeLabel(?Request $request = null): string
{
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return 'Filtered by tenant: '.$activeTenant->name;
}
return 'All tenants';
}
```
**Propagation**: This single change propagates to all consumers:
- `headerActions()` within `OperateHubShell` itself (L61)
- `Operations.php` (L66) — uses `$operateHubShell->scopeLabel(request())` directly
- `TenantlessOperationRunViewer.php` (L47) — uses `$operateHubShell->scopeLabel(request())` directly
- `AuditLog.php`, `Alerts.php`, `ListAlertDeliveries.php` — via `headerActions()`
**FR mapping**: FR-001 (filtered indicator), FR-002 (all-tenants indicator), FR-003 (no "Scope:" text), FR-014 (DB-only renders unchanged).
---
### T2 — Fix `AlertsKpiHeader::deliveriesQueryForViewer()` Bug (FR-004, FR-005)
**File**: `app/Filament/Widgets/Alerts/AlertsKpiHeader.php` (line 101118)
**Current** (buggy):
```php
protected function deliveriesQueryForViewer(): Builder
{
$query = AlertDelivery::query()->where(...);
$tenant = Filament::getTenant(); // ← BUG: null when lastTenantId fallback
if ($tenant) {
$query->where('tenant_id', $tenant->id);
}
return $query;
}
```
**Target**:
```php
protected function deliveriesQueryForViewer(): Builder
{
$query = AlertDelivery::query()->where(...);
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($tenant instanceof Tenant) {
$query->where('tenant_id', $tenant->id);
}
return $query;
}
```
**FR mapping**: FR-004 (consistent tenant resolution), FR-005 (KPI filters by resolved tenant).
---
### T3 — Remove OperateHubShell Header Actions from Manage Pages (FR-006)
**Files**:
- `app/Filament/Resources/AlertRuleResource/Pages/ListAlertRules.php` (L2337)
- `app/Filament/Resources/AlertDestinationResource/Pages/ListAlertDestinations.php` (L1730)
**Change**: Remove `...app(OperateHubShell::class)->headerActions(...)` from `getHeaderActions()`. Keep only the `CreateAction::make()`.
**FR mapping**: FR-006 (no tenant indicator on Manage pages).
---
### T4 — Relabel AlertRule Form Fields (FR-007, FR-008, FR-009, FR-010)
**File**: `app/Filament/Resources/AlertRuleResource.php` (form method, ~L155+)
**Changes**:
| Current | Target |
|---------|--------|
| `Select::make('tenant_scope_mode')` label (auto-generated "Tenant scope mode") | Explicit `->label('Applies to tenants')` |
| Option `'Allowlist'` | `'Selected tenants'` |
| `Select::make('tenant_allowlist')->label('Tenant allowlist')` | `->label('Selected tenants')` |
| No helper texts | `->helperText('This rule is workspace-wide. Use this to limit where it applies.')` on tenant_scope_mode |
| No helper texts | `->helperText('Only these tenants will trigger this rule.')` on tenant_allowlist |
**DB values**: `all` / `allowlist` constants UNCHANGED. Only display labels change.
**FR mapping**: FR-007, FR-008, FR-009, FR-010, FR-012 (no domain behavior changes), FR-013 (no schema changes).
---
### T5 — Add Section Grouping to AlertRule Form (FR-011)
**File**: `app/Filament/Resources/AlertRuleResource.php` (form method)
**Change**: Wrap existing flat fields into three `Filament\Schemas\Components\Section` groups:
1. **"Rule"** section: Name, Enabled, Event type, Minimum severity
2. **"Applies to"** section: Applies to tenants (tenant_scope_mode), Selected tenants (tenant_allowlist, conditional)
3. **"Delivery"** section: Cooldown, Quiet hours, Destinations
**Import**: `use Filament\Schemas\Components\Section;` (verified in 11 existing files).
**FR mapping**: FR-011.
---
### T6 — Update Tests (all FRs)
**Files to update**:
- `tests/Feature/OpsUx/OperateHubShellTest.php` — update all `assertSee('Scope: Workspace — all tenants')``assertSee('All tenants')` and all `assertSee('Scope: Tenant — ')``assertSee('Filtered by tenant: ')`
- `tests/Feature/Filament/Alerts/AlertRuleCrudTest.php` — check for label-dependent assertions, update if needed
**New test coverage** (add to existing test files or create new):
- KPI consistency: Set tenant-context via lastTenantId only → assert `deliveriesQueryForViewer()` includes `where tenant_id = X`
- Manage pages: Visit `/admin/alert-rules` with tenant-context → `assertDontSee('Filtered by tenant')` and `assertDontSee('Scope: Tenant')`
- Form labels: Visit AlertRule edit page → `assertSee('Applies to tenants')`, `assertDontSee('Tenant scope mode')`
- Section headings: Visit AlertRule edit page → `assertSee('Rule')`, `assertSee('Applies to')`, `assertSee('Delivery')`
---
### T7 — (Optional, P3) "Add Current Tenant" Convenience Button (FR: User Story 6)
**File**: `app/Filament/Resources/AlertRuleResource.php`
**Change**: Add a form action button visible when tenant-context is active AND "Selected tenants" mode is visible. On click, append current tenant to `tenant_allowlist` field state.
**Note**: Only implement if T1T6 are complete and PR remains small. Skip if scope exceeds budget.
## Filament v5 Compliance (Agent Output Contract)
1. **Livewire v4.0+ compliance**: All code targets Livewire v4.0+. No v3 references.
2. **Provider registration**: No new providers. Existing panel provider in `bootstrap/providers.php` unchanged.
3. **Globally searchable resources**: AlertRuleResource has `$isScopedToTenant = false` — global search not applicable to workspace-owned resources. No changes to global search behavior.
4. **Destructive actions**: No destructive actions added or modified. Existing destructive actions on these pages retain `->requiresConfirmation()`.
5. **Asset strategy**: No new assets registered. No changes to `filament:assets` deployment.
6. **Testing plan**: OperateHubShellTest updated for new copy. AlertRuleCrudTest verified/updated. New test assertions for KPI consistency, Manage page indicator suppression, and form labels/sections.
## Complexity Tracking
> No constitution violations. No complexity justifications needed.

View File

@ -0,0 +1,37 @@
# Quickstart: 103 — IA Semantics: Scope vs Filter vs Targeting
## Prerequisites
- Sail running (`vendor/bin/sail up -d`)
- Database migrated (`vendor/bin/sail artisan migrate`)
- At least one workspace with 2+ tenants seeded
## Files to Touch
### Core (must change)
1. `app/Support/OperateHub/OperateHubShell.php` — update `scopeLabel()` copy
2. `app/Filament/Widgets/Alerts/AlertsKpiHeader.php` — fix `deliveriesQueryForViewer()` bug
3. `app/Filament/Resources/AlertRuleResource.php` — relabel form fields, add Sections + helper texts
4. `app/Filament/Resources/AlertRuleResource/Pages/ListAlertRules.php` — remove OperateHubShell header spread
5. `app/Filament/Resources/AlertDestinationResource/Pages/ListAlertDestinations.php` — remove OperateHubShell header spread
### Tests (must update/add)
6. `tests/Feature/OpsUx/OperateHubShellTest.php` — update assertions for new copy
7. New test: AlertsKpiHeader consistency (deliveries vs banner)
8. New test: Manage pages have no tenant indicator
9. Update: AlertRuleCrud test for new form labels (if label-dependent assertions exist)
## Verification
```bash
# Run targeted tests
vendor/bin/sail artisan test --compact --filter=OperateHubShell
vendor/bin/sail artisan test --compact --filter=AlertsKpiHeader
vendor/bin/sail artisan test --compact --filter=AlertRuleCrud
# Run full suite
vendor/bin/sail artisan test --compact
```
## Risk Assessment
- **Low risk**: Copy changes are purely presentational, no persistence or business-logic alteration.
- **Medium risk**: The AlertsKpiHeader bugfix changes query filtering — existing deliveries tests should catch regressions.
- **No migration**: Zero schema changes.

View File

@ -0,0 +1,80 @@
# Research: 103 — IA Semantics: Scope vs Filter vs Targeting
**Date**: 2026-02-20
**Status**: Complete — No unknowns remain
## R1 — OperateHubShell::scopeLabel() Is the Single Source of Truth
**Decision**: Update `scopeLabel()` in `OperateHubShell` to emit new copy.
**Rationale**: All Monitoring pages that display the tenant indicator call `scopeLabel()` (directly or via `headerActions()`). Updating this one method propagates the change to:
- Operations index + run detail viewer
- Alert Deliveries list
- Alerts Overview page
- Audit Log page
**Current code** (line 2433 of `OperateHubShell.php`):
```php
public function scopeLabel(?Request $request = null): string
{
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return 'Scope: Tenant — '.$activeTenant->name;
}
return 'Scope: Workspace — all tenants';
}
```
**New copy**:
- With tenant: `"Filtered by tenant: {name}"`
- Without tenant: `"All tenants"`
**Alternatives considered**: Per-page label override — rejected because it would introduce duplication and risk drift.
## R2 — AlertsKpiHeader Bugfix: Filament::getTenant() vs activeEntitledTenant()
**Decision**: Replace `Filament::getTenant()` with `OperateHubShell::activeEntitledTenant(request())` in `AlertsKpiHeader::deliveriesQueryForViewer()`.
**Rationale**: The current code at line 107 uses `Filament::getTenant()` which returns null when tenant-context is set via lastTenantId session fallback only. The indicator (via `scopeLabel`) uses `activeEntitledTenant()` which includes the fallback. This mismatch causes the indicator to say "filtered" while the KPIs show workspace-wide numbers.
**Note**: The AlertRule/AlertDestination count stats are workspace-owned and don't filter by tenant — this is correct and unchanged.
**Alternatives considered**: Making `lastTenantId` set `Filament::setTenant()` globally — rejected because it has broad side effects on Filament's tenant routing.
## R3 — Manage Pages: Remove OperateHubShell Header Actions
**Decision**: Remove the `...app(OperateHubShell::class)->headerActions(...)` spread from `ListAlertRules::getHeaderActions()` and `ListAlertDestinations::getHeaderActions()`.
**Rationale**: These are workspace-owned Manage pages. The tenant indicator is semantically wrong because the data is not filtered by tenant. After removal, only the Create action remains in header actions.
**Alternatives considered**: Showing a different "Workspace configuration" indicator — rejected as unnecessary complexity.
## R4 — AlertRule Form: Label + Section Changes
**Decision**: Update form labels in `AlertRuleResource::form()` and wrap fields in three `Filament\Schemas\Components\Section` groups.
**Rationale**:
- "Tenant scope mode" → "Applies to tenants" (targeting semantics, not ownership)
- "Tenant allowlist" → label removed from the current `Allowlist` option; becomes "Selected tenants"
- Field option values: `all` → "All tenants", `allowlist` → "Selected tenants" (DB values unchanged)
- Helper texts added per spec
**Import verified**: Filament v5 uses `Filament\Schemas\Components\Section` (confirmed in 11 existing files).
**No persistence changes**: The `tenant_scope_mode` column stores `all`/`allowlist` as before.
## R5 — Existing Test Coverage
**Decision**: Update existing `OperateHubShellTest` assertions that check for old copy, and add new tests.
Existing tests that assert old copy (must be updated):
- `OperateHubShellTest`: multiple `assertSee('Scope: Workspace — all tenants')` calls
New tests needed:
- Monitoring indicator copy (with/without tenant)
- KPI consistency bugfix (lastTenantId fallback)
- Manage pages: no tenant indicator
- AlertRule form: new labels
## R6 — Section Import Path (Filament v5)
**Decision**: Use `Filament\Schemas\Components\Section`.
**Rationale**: Confirmed by grep across 11 existing Filament resource files in the codebase. This is the canonical v5 import.
**Alternatives considered**: `Filament\Forms\Components\Section` (v3/v4 path) — confirmed NOT used in this codebase.

View File

@ -0,0 +1,203 @@
# Feature Specification: IA Semantics — Scope vs Filter vs Targeting (Monitoring + Manage)
**Feature Branch**: `103-ia-scope-filter-semantics`
**Created**: 2026-02-20
**Status**: Draft
**Input**: User description: "IA Semantics: Scope vs Filter vs Targeting — Monitoring canonical views use 'Scope' wording but mean 'Filter'; workspace-owned Manage pages incorrectly show tenant indicators; Alerts KPI widget has inconsistent tenant resolution; AlertRule form labels mix scope and targeting semantics."
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view (Monitoring pages) + workspace (Manage pages)
- **Primary Routes**:
- `/admin/operations` (Monitoring → Operations)
- `/admin/alert-deliveries` (Alerts → Alert Deliveries)
- `/admin/audit-log` (Monitoring → Audit Log)
- `/admin/alerts` (Monitoring → Alerts Overview — Landing + KPI Widget)
- `/admin/alert-rules` (Alerts → Alert Rules — Manage)
- `/admin/alert-destinations` (Alerts → Alert Destinations — Manage)
- `/admin/alert-rules/{record}/edit` (AlertRule Edit Form)
- **Data Ownership**:
- Monitoring pages: canonical views across workspace-level data, filtered by tenant-context
- Alert Rules / Alert Destinations / Baselines: workspace-owned records (tenantless configuration)
- Alert Rule `tenant_allowlist`: workspace-owned rule targeting selected tenants
- **RBAC**: Existing RBAC checks remain unchanged. No new capabilities introduced. Existing membership + capability enforcement is preserved.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Monitoring canonical pages prefilter query results to the active tenant resolved via `OperateHubShell::activeEntitledTenant(request())` (Filament tenant + lastTenantId session fallback). If no tenant-context is active, all workspace-level data is shown unfiltered.
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing entitlement checks in `OperateHubShell::activeEntitledTenant()` verify the user is entitled to the resolved tenant. No changes to entitlement logic.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Monitoring Page Tenant Indicator Shows "Filtered by tenant" Instead of "Scope" (Priority: P1)
An admin navigates to a Monitoring canonical page (Operations, Alert Deliveries, Audit Log, or Alerts Overview) while a tenant-context is active. The page indicator reads **"Filtered by tenant: Contoso"** instead of the old "Scope: Tenant — Contoso". When no tenant-context is active, the indicator reads **"All tenants"** instead of "Scope: Workspace — all tenants".
**Why this priority**: This is the core IA semantics fix. The "Scope" label implies ownership, misleading admins into thinking data belongs to the tenant rather than being filtered. Fixing the copy on all Monitoring pages is the highest-value change.
**Independent Test**: Can be fully tested by visiting any Monitoring page with and without tenant-context and asserting the indicator text. Delivers correct mental model of "filter" semantics.
**Acceptance Scenarios**:
1. **Given** a tenant-context is active (via Filament tenant or lastTenantId fallback), **When** the admin visits `/admin/operations`, **Then** the page shows "Filtered by tenant: {TenantName}" and does NOT show "Scope: Tenant".
2. **Given** no tenant-context is active, **When** the admin visits `/admin/operations`, **Then** the page shows "All tenants" and does NOT show "Scope: Workspace".
3. **Given** a tenant-context is active, **When** the admin visits `/admin/alert-deliveries`, **Then** the page shows "Filtered by tenant: {TenantName}".
4. **Given** a tenant-context is active, **When** the admin visits `/admin/alerts` (Alerts Overview), **Then** the indicator reads "Filtered by tenant: {TenantName}".
---
### User Story 2 — Alerts KPI Widget Uses Consistent Tenant Resolution (Bugfix) (Priority: P1)
An admin has tenant-context active via the lastTenantId session fallback (Filament::getTenant() returns null). The Monitoring Alerts Overview page banner shows "Filtered by tenant: Contoso" but the KPI numbers show workspace-wide counts — a data inconsistency. After the fix, the KPI widget's query MUST use the same tenant resolution as the indicator so banner and numbers always agree.
**Why this priority**: This is a correctness bug. Misleading KPI numbers undermine trust in the monitoring dashboard. Co-equal P1 with the copy fix because the two are interdependent: fixing the copy without fixing the filter makes the inconsistency more visible.
**Independent Test**: Can be tested by setting tenant-context via session fallback only (no Filament::setTenant), rendering the KPI widget, and asserting the underlying query includes the correct tenant_id filter.
**Acceptance Scenarios**:
1. **Given** tenant-context is set only via lastTenantId fallback (Filament::getTenant() returns null), **When** the Alerts KPI widget renders, **Then** the KPI query filters by the fallback tenant's ID.
2. **Given** tenant-context is set via Filament::setTenant(), **When** the Alerts KPI widget renders, **Then** the KPI query filters by that tenant's ID.
3. **Given** no tenant-context is active, **When** the Alerts KPI widget renders, **Then** no tenant filter is applied (workspace-wide counts).
---
### User Story 3 — Manage Pages Suppress Tenant Indicator (Priority: P2)
An admin navigates to the Alert Rules list or Alert Destinations list (workspace-owned Manage pages). These pages MUST NOT show any tenant indicator/banner because the records are workspace-owned configuration — showing "Filtered by tenant" would be semantically wrong.
**Why this priority**: After fixing the Monitoring indicator copy, the next most confusing pattern is Manage pages that incorrectly display a tenant indicator. Removing it reinforces the ownership vs filter distinction.
**Independent Test**: Can be tested by visiting Alert Rules list with tenant-context active and asserting the absence of any "Filtered by tenant" or "Scope: Tenant" text.
**Acceptance Scenarios**:
1. **Given** a tenant-context is active, **When** the admin visits `/admin/alert-rules`, **Then** the page does NOT show "Filtered by tenant" nor "Scope: Tenant" nor any tenant indicator.
2. **Given** a tenant-context is active, **When** the admin visits `/admin/alert-destinations`, **Then** the page does NOT show any tenant indicator.
---
### User Story 4 — AlertRule Form Labels Use Targeting Semantics (Priority: P2)
An admin edits an Alert Rule. The form field previously labeled "Tenant scope mode" now reads **"Applies to tenants"** with options "All tenants" / "Selected tenants". The "Tenant allowlist" field now reads **"Selected tenants"** (conditionally visible). Helper texts communicate targeting clearly.
**Why this priority**: Completes the IA semantics cleanup by fixing the last "scope" references in the AlertRule form. Lower priority than Monitoring because it affects fewer interactions.
**Independent Test**: Can be tested by visiting any AlertRule edit page and asserting the new labels/helper texts appear.
**Acceptance Scenarios**:
1. **Given** the admin opens an AlertRule edit form, **Then** the form shows a field labeled "Applies to tenants" with helper text "This rule is workspace-wide. Use this to limit where it applies."
2. **Given** the admin selects "Selected tenants" in "Applies to tenants", **Then** a field labeled "Selected tenants" appears with helper text "Only these tenants will trigger this rule."
3. **Given** the admin opens an AlertRule edit form, **Then** the form does NOT show "Tenant scope mode" or "Tenant allowlist" labels.
---
### User Story 5 — AlertRule Form Organized in Sections (Priority: P3)
An admin edits an Alert Rule. The form is grouped into three clear sections: **Rule** (Name, Enabled, Event type, Minimum severity), **Applies to** (tenant targeting fields), and **Delivery** (Cooldown, Quiet hours, Destinations).
**Why this priority**: Layout improvement that aids form comprehension. Lower priority because the current flat layout is functional, just less organized.
**Independent Test**: Can be tested by loading the edit form and asserting the presence of the three section headings.
**Acceptance Scenarios**:
1. **Given** the admin opens an AlertRule edit form, **Then** the form contains a section titled "Rule" with fields: Name, Enabled, Event type, Minimum severity.
2. **Given** the admin opens an AlertRule edit form, **Then** the form contains a section titled "Applies to" with fields: Applies to tenants, Selected tenants (conditional).
3. **Given** the admin opens an AlertRule edit form, **Then** the form contains a section titled "Delivery" with fields: Cooldown, Quiet hours, Destinations.
---
### User Story 6 — Optional: "Add Current Tenant" Convenience Button (Priority: P3, Optional)
When an admin edits an Alert Rule with tenant-context active and "Selected tenants" mode visible, an optional action button **"Add current tenant to selected tenants"** appears (no auto-prefill, explicit click required).
**Why this priority**: Pure convenience enhancement. Only implemented if core stories are complete and PR remains small.
**Independent Test**: Can be tested by rendering the form with tenant-context active and allowlist visible, clicking the button, and asserting the current tenant is added to the selected list.
**Acceptance Scenarios**:
1. **Given** tenant-context is active AND "Selected tenants" mode is visible, **When** the admin clicks "Add current tenant to selected tenants", **Then** the currently active tenant is added to the selected tenants field.
2. **Given** no tenant-context is active OR "All tenants" mode is selected, **Then** the "Add current tenant" button is NOT visible.
---
### Edge Cases
- What happens when the lastTenantId session value references a tenant the user is no longer entitled to? — The existing `activeEntitledTenant()` method performs entitlement checks; if the user is not entitled, it returns null and the page shows "All tenants" (workspace-wide). No change to this behavior.
- What happens when `Filament::getTenant()` and `lastTenantId` resolve to different tenants? — `activeEntitledTenant()` gives priority to `Filament::getTenant()`; the fallback only activates when Filament tenant is null. No change.
- What happens on a Manage page if the user navigates directly with a tenant in the URL? — Manage pages do not query by tenant, and the indicator is suppressed. No data inconsistency arises; the page simply shows all workspace-owned records.
- What happens on the Alerts Overview page with no alert rules configured? — KPI widget shows zeros for all metrics, regardless of tenant filter. Existing empty-state behavior is preserved.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no new write/change behavior, no queued/scheduled work. It is a UI copy + query filter consistency fix. No contract registry updates needed. The KPI bugfix is a DB-only query change that corrects an existing filter to match the indicator — this is a correctness fix, not a new behavior. No `OperationRun` or `AuditLog` changes required.
**Constitution alignment (RBAC-UX):** This feature does not introduce or change authorization behavior. All existing RBAC enforcement remains unchanged. No new capabilities, no new gates/policies, no changes to 404/403 semantics.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable — no outbound HTTP calls introduced.
**Constitution alignment (BADGE-001):** This feature does not add or change any status badges. The "Filtered by tenant" indicator is informational copy, not a status badge.
**Constitution alignment (Filament Action Surfaces):** This feature modifies Filament pages (copy, layout, conditional rendering). No new actions are added. Existing actions (header actions on Manage pages) are removed from pages where they were semantically incorrect. The action surface contract is satisfied — see UI Action Matrix below.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The AlertRule form changes add Section grouping (conforming to UX-001 "all fields inside Sections/Cards"). Monitoring pages receive copy changes only. No new pages or custom layouts are introduced.
**UX-001 exemption (Main/Aside layout):** UX-001 requires Create/Edit forms to default to a Main/Aside 3-column grid. The AlertRule edit form does not adopt Main/Aside layout in this feature because the scope is limited to copy/label fixes and Section grouping. A full layout refactor is deferred to a dedicated UX spec.
### Functional Requirements
- **FR-001**: Monitoring canonical pages MUST display "Filtered by tenant: {TenantName}" when tenant-context is active (resolved via Filament tenant OR lastTenantId session fallback).
- **FR-002**: Monitoring canonical pages MUST display "All tenants" when no tenant-context is active.
- **FR-003**: Monitoring canonical pages MUST NOT display "Scope: Tenant" or "Scope: Workspace" labels.
- **FR-004**: The Alerts KPI delivery widget (`AlertsKpiHeader`) MUST resolve the active tenant using the same mechanism as the indicator (`activeEntitledTenant()` including lastTenantId fallback). Other Monitoring widgets that display workspace-owned counts (e.g., AlertRule/AlertDestination totals) are correctly unfiltered and are not affected by this requirement.
- **FR-005**: The Alerts KPI widget MUST filter by the resolved tenant when one is active, and show workspace-wide counts when no tenant is active.
- **FR-006**: Workspace-owned Manage pages (Alert Rules list, Alert Destinations list) MUST NOT display any tenant indicator/banner.
- **FR-007**: The AlertRule edit form MUST display the field label "Applies to tenants" (replacing "Tenant scope mode") with options "All tenants" and "Selected tenants".
- **FR-008**: The AlertRule edit form MUST display the field label "Selected tenants" (replacing "Tenant allowlist") when "Selected tenants" mode is active.
- **FR-009**: The AlertRule edit form MUST show helper text "This rule is workspace-wide. Use this to limit where it applies." for the "Applies to tenants" field.
- **FR-010**: The AlertRule edit form MUST show helper text "Only these tenants will trigger this rule." for the "Selected tenants" field.
- **FR-011**: The AlertRule edit form MUST group fields into three sections: "Rule", "Applies to", and "Delivery".
- **FR-012**: No domain behavior changes MUST be introduced except the KPI tenant resolution consistency fix (FR-004/FR-005).
- **FR-013**: No database schema changes, no new tables, no new enums.
- **FR-014**: All monitoring page renders MUST remain DB-only (no Graph/HTTP calls introduced).
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance | Row Actions | Bulk Actions | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
| ----------------- | ------------------------------------ | ------------------------------------------------- | ------------------ | ----------- | ------------ | ------------------- | ------------------- | ----------------------- | ---------- | -------------------------------------------------------------------------------- |
| ListAlertRules | app/Filament/…/ListAlertRules.php | **Removed**: OperateHubShell header actions | Unchanged | Unchanged | Unchanged | Unchanged | N/A | N/A | No | Manage page — tenantless config; indicator was semantically incorrect |
| ListAlertDests | app/Filament/…/ListAlertDestinations | **Removed**: OperateHubShell header actions | Unchanged | Unchanged | Unchanged | Unchanged | N/A | N/A | No | Manage page — tenantless config; indicator was semantically incorrect |
| AlertRuleResource | app/Filament/…/AlertRuleResource.php | Unchanged | Unchanged | Unchanged | Unchanged | Unchanged | Unchanged | Unchanged (Save+Cancel) | No | Form copy + section layout change only; no action changes |
| Monitoring pages | Operations / AlertDeliveries / Alerts | **Changed**: Copy from "Scope: …" to "Filtered…" | Unchanged | Unchanged | Unchanged | Unchanged | N/A | N/A | No | Header action label change; "Show all tenants" / "Back to" actions unchanged |
**Action Surface Contract**: Satisfied. No new actions introduced. Existing destructive actions (if any on these pages) are unchanged. The removed header actions on Manage pages were informational indicators, not mutation actions.
### Key Entities
No new entities. Existing entities affected by copy/layout changes only:
- **Alert Rule**: Workspace-owned rule with `tenant_scope_mode` (persisted value unchanged) and `tenant_allowlist` (persisted value unchanged). Only display labels and helper texts change.
- **Monitoring Indicator**: Not a persisted entity — runtime UI element rendered by `OperateHubShell::scopeLabel()` based on resolved tenant-context.
## Assumptions
- `OperateHubShell::scopeLabel()` is the single source of truth for the Monitoring indicator copy. Updating this method propagates the label change to all Monitoring canonical pages that use it.
- `OperateHubShell::activeEntitledTenant(request())` correctly performs entitlement checks and returns null for non-entitled tenants.
- The AlertRule form's `tenant_scope_mode` and `tenant_allowlist` database column names are NOT changed — only the display labels in the form.
- Baselines list page is already banner-free and requires no changes.
- The "Show all tenants" and "Back to {Tenant}" action labels remain unchanged (they do not use "Scope" wording).
- The Audit Log page uses the same `OperateHubShell` indicator mechanism as other Monitoring pages and will automatically receive the copy change.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Zero instances of "Scope: Tenant" or "Scope: Workspace" visible on any Monitoring canonical page — verified by automated tests.
- **SC-002**: When the Monitoring indicator shows "Filtered by tenant: X", 100% of KPI widgets on the same page filter their queries by tenant X — verified by query assertion tests.
- **SC-003**: Zero instances of tenant indicator/banner on workspace-owned Manage pages (Alert Rules list, Alert Destinations list) — verified by automated tests.
- **SC-004**: Admin can identify within 5 seconds whether a Monitoring page is showing tenant-filtered data or workspace-wide data, based on the indicator copy alone.
- **SC-005**: AlertRule form contains three clearly labeled sections, allowing admin to locate the targeting configuration in under 3 seconds.
- **SC-006**: Full test suite passes with zero regressions.

View File

@ -0,0 +1,272 @@
# Tasks: IA Semantics — Scope vs Filter vs Targeting (Monitoring + Manage)
**Input**: Design documents from `/specs/103-ia-scope-filter-semantics/`
**Prerequisites**: plan.md (loaded), spec.md (loaded), research.md (loaded), data-model.md (loaded), quickstart.md (loaded)
**Tests**: Required (Pest feature tests). This feature changes runtime behavior (copy, query filtering, form layout).
**Operations**: N/A — no long-running/remote/queued/scheduled work introduced.
**RBAC**: N/A — no authorization changes. Existing RBAC enforcement unchanged.
**Filament UI Action Surfaces**: Manage pages have header actions removed (informational indicators, not mutation actions). No new actions added. Action surface contract satisfied.
**Filament UI UX-001**: AlertRule form adds Section grouping (conforming to "all fields inside Sections/Cards"). No new pages or custom layouts.
**Badges**: N/A — no status badges added or changed.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup
**Purpose**: No project setup needed — existing Laravel monolith. This phase validates prerequisites only.
- [x] T001 Verify Sail is running and database is migrated (`vendor/bin/sail up -d && vendor/bin/sail artisan migrate`)
- [x] T002 Run existing test suite baseline (`vendor/bin/sail artisan test --compact`) to confirm green before changes
**Checkpoint**: Baseline green — changes can begin.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: The `OperateHubShell::scopeLabel()` change is the single foundation all other stories depend on. It must be updated first because US2 tests need the new copy, US3 verifies its absence, and US1 tests assert it directly.
**⚠️ CRITICAL**: US2/US3/US4/US5 tests reference the new indicator copy. This phase must complete first.
- [x] T003 Update `scopeLabel()` return values in `app/Support/OperateHub/OperateHubShell.php`: change `'Scope: Tenant — '.$activeTenant->name` to `'Filtered by tenant: '.$activeTenant->name` and `'Scope: Workspace — all tenants'` to `'All tenants'` (line 2333)
**Checkpoint**: `scopeLabel()` emits new copy. All Monitoring pages that call `scopeLabel()` or `headerActions()` now show the new labels automatically. No tests pass yet — existing test assertions reference old copy.
---
## Phase 3: User Story 1 — Monitoring Page Tenant Indicator (Priority: P1) 🎯 MVP
**Goal**: Monitoring canonical pages display "Filtered by tenant: {name}" or "All tenants" instead of "Scope: …" labels.
**Independent Test**: Visit any Monitoring page with/without tenant-context and assert the indicator text.
### Tests for User Story 1
- [x] T004 [US1] Update existing assertions in `tests/Feature/OpsUx/OperateHubShellTest.php`: replace all `assertSee('Scope: Workspace — all tenants')` with `assertSee('All tenants')` and all `assertSee('Scope: Tenant')` patterns with `assertSee('Filtered by tenant:')`. Also add `assertDontSee('Scope: Tenant')` and `assertDontSee('Scope: Workspace')` to existing test cases.
- [x] T005 [US1] Run OperateHubShell tests to confirm they pass with new copy: `vendor/bin/sail artisan test --compact --filter=OperateHubShell`
**Checkpoint**: US1 complete — Monitoring indicator shows correct copy. FR-001, FR-002, FR-003 satisfied.
---
## Phase 4: User Story 2 — Alerts KPI Widget Tenant Resolution Bugfix (Priority: P1)
**Goal**: `AlertsKpiHeader::deliveriesQueryForViewer()` uses `OperateHubShell::activeEntitledTenant()` instead of `Filament::getTenant()`, so KPI numbers always match the indicator banner.
**Independent Test**: Set tenant-context via lastTenantId session fallback only, render KPI widget, assert query includes `tenant_id` filter.
### Implementation for User Story 2
- [x] T006 [US2] Fix `deliveriesQueryForViewer()` in `app/Filament/Widgets/Alerts/AlertsKpiHeader.php`: replace `Filament::getTenant()` with `app(OperateHubShell::class)->activeEntitledTenant(request())` and update the null-check to use `instanceof Tenant` (line 101118). Add `use App\Support\OperateHub\OperateHubShell;` and `use App\Models\Tenant;` imports if not already present.
### Tests for User Story 2
- [x] T007 [US2] Add test in `tests/Feature/OpsUx/OperateHubShellTest.php` (or new test file `tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php`): (a) test that when tenant-context is set only via `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` session (Filament::getTenant() returns null), the KPI widget's delivery query includes a `where tenant_id = ?` clause matching the fallback tenant; (b) test that when tenant-context is set via `Filament::setTenant()`, the KPI widget's delivery query filters by that tenant's ID (regression guard for US2 acceptance scenario 2).
- [x] T008 [US2] Add test: when no tenant-context is active, the KPI widget's delivery query does NOT include a `tenant_id` filter (workspace-wide counts).
- [x] T009 [US2] Run KPI tests: `vendor/bin/sail artisan test --compact --filter=AlertsKpiHeader`
**Checkpoint**: US2 complete — KPI numbers are consistent with indicator banner. FR-004, FR-005 satisfied.
---
## Phase 5: User Story 3 — Manage Pages Suppress Tenant Indicator (Priority: P2)
**Goal**: Alert Rules list and Alert Destinations list no longer show any tenant indicator/banner.
**Independent Test**: Visit `/admin/alert-rules` with tenant-context active, assert absence of "Filtered by tenant" text.
### Implementation for User Story 3
- [x] T010 [P] [US3] Remove `...app(OperateHubShell::class)->headerActions(...)` spread from `getHeaderActions()` in `app/Filament/Resources/AlertRuleResource/Pages/ListAlertRules.php`. Keep only the `CreateAction::make()`. Remove unused `OperateHubShell` import if no other references remain.
- [x] T011 [P] [US3] Remove `...app(OperateHubShell::class)->headerActions(...)` spread from `getHeaderActions()` in `app/Filament/Resources/AlertDestinationResource/Pages/ListAlertDestinations.php`. Keep only the `CreateAction::make()`. Remove unused `OperateHubShell` import if no other references remain.
### Tests for User Story 3
- [x] T012 [US3] Add test: visit `/admin/alert-rules` list page with tenant-context active (via Filament tenant or lastTenantId fallback), assert `assertDontSee('Filtered by tenant')` and `assertDontSee('Scope: Tenant')` and `assertDontSee('Scope: Workspace')`.
- [x] T013 [US3] Add test: visit `/admin/alert-destinations` list page with tenant-context active, assert absence of any tenant indicator text.
- [x] T014 [US3] Run Manage page tests: `vendor/bin/sail artisan test --compact --filter="ListAlertRules|ListAlertDestinations|ManagePage"`
**Checkpoint**: US3 complete — Manage pages show no tenant indicator. FR-006 satisfied.
---
## Phase 6: User Story 4 — AlertRule Form Labels (Priority: P2)
**Goal**: AlertRule edit form uses targeting semantics: "Applies to tenants" with options "All tenants" / "Selected tenants", helper texts, and "Selected tenants" field label.
**Independent Test**: Visit AlertRule edit page, assert new labels/helper texts appear and old labels are absent.
### Implementation for User Story 4
- [x] T015 [US4] In `app/Filament/Resources/AlertRuleResource.php` form method: add explicit `->label('Applies to tenants')` to `Select::make('tenant_scope_mode')`, change option display from `'Allowlist'` to `'Selected tenants'`, add `->helperText('This rule is workspace-wide. Use this to limit where it applies.')`.
- [x] T016 [US4] In `app/Filament/Resources/AlertRuleResource.php` form method: change `Select::make('tenant_allowlist')->label('Tenant allowlist')` to `->label('Selected tenants')`, add `->helperText('Only these tenants will trigger this rule.')`.
### Tests for User Story 4
- [x] T017 [US4] Add test: visit AlertRule edit form, `assertSee('Applies to tenants')`, `assertSee('This rule is workspace-wide')`, `assertDontSee('Tenant scope mode')`, `assertDontSee('Tenant allowlist')`.
- [x] T018 [US4] Update any existing label-dependent assertions in `tests/Feature/Filament/Alerts/AlertRuleCrudTest.php` if they reference old "Tenant scope mode" or "Tenant allowlist" labels.
- [x] T019 [US4] Run AlertRule form tests: `vendor/bin/sail artisan test --compact --filter=AlertRuleCrud`
**Checkpoint**: US4 complete — AlertRule form uses targeting semantics. FR-007, FR-008, FR-009, FR-010 satisfied.
---
## Phase 7: User Story 5 — AlertRule Form Sections (Priority: P3)
**Goal**: AlertRule edit form fields are grouped into three sections: "Rule", "Applies to", "Delivery".
**Independent Test**: Load the edit form and assert the presence of three section headings.
### Implementation for User Story 5
- [x] T020 [US5] In `app/Filament/Resources/AlertRuleResource.php` form method: add `use Filament\Schemas\Components\Section;` import (if not already present). Wrap Name, Enabled, Event type, Minimum severity fields in `Section::make('Rule')`.
- [x] T021 [US5] Wrap tenant_scope_mode and tenant_allowlist fields in `Section::make('Applies to')`.
- [x] T022 [US5] Wrap Cooldown, Quiet hours, Destinations fields in `Section::make('Delivery')`.
### Tests for User Story 5
- [x] T023 [US5] Add test: visit AlertRule edit form, `assertSee('Rule')` (section heading), `assertSee('Applies to')` (section heading), `assertSee('Delivery')` (section heading).
- [x] T024 [US5] Run section tests: `vendor/bin/sail artisan test --compact --filter=AlertRuleCrud`
**Checkpoint**: US5 complete — AlertRule form is organized in sections. FR-011 satisfied.
---
## Phase 8: User Story 6 — "Add Current Tenant" Button (Priority: P3, Optional)
**Goal**: When editing an AlertRule with tenant-context active and "Selected tenants" mode visible, an action button "Add current tenant to selected tenants" appears.
**Independent Test**: Render form with tenant-context + allowlist visible, click button, assert tenant is added.
**⚠️ NOTE**: Only implement if T001T024 are complete and PR scope permits. Skip entirely if budget exceeded.
### Implementation for User Story 6
- [ ] T025 [US6] In `app/Filament/Resources/AlertRuleResource.php` form method: add a form action button on the `tenant_allowlist` field (or as a suffix action) that resolves `OperateHubShell::activeEntitledTenant(request())` and appends its ID to the `tenant_allowlist` field state. Button visible only when tenant-context is active AND `tenant_scope_mode` is `allowlist`.
### Tests for User Story 6
- [ ] T026 [US6] Add test: render AlertRule edit form with tenant-context active + allowlist mode, click "Add current tenant to selected tenants", assert the tenant ID is added to the field state.
- [ ] T027 [US6] Add test: render AlertRule edit form with no tenant-context (or "All tenants" mode), assert the "Add current tenant" button is NOT visible.
- [ ] T028 [US6] Run convenience button tests: `vendor/bin/sail artisan test --compact --filter=AlertRuleCrud`
**Checkpoint**: US6 complete (if implemented). Optional enhancement delivered.
---
## Phase 9: Polish & Cross-Cutting Concerns
**Purpose**: Final validation, formatting, and full-suite regression check.
- [x] T029 Run Pint code formatter: `vendor/bin/sail bin pint --dirty`
- [x] T030 Run full test suite to confirm zero regressions: `vendor/bin/sail artisan test --compact`
- [x] T031 Verify no remaining instances of "Scope: Tenant" or "Scope: Workspace" in source code: `grep -r "Scope: Tenant\|Scope: Workspace" app/ tests/` should return zero matches (excluding comments/docs)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2, T003)**: Depends on Phase 1 — BLOCKS all user stories
- **US1 (Phase 3)**: Depends on Phase 2 (T003) — test-only phase, updates existing assertions
- **US2 (Phase 4)**: Depends on Phase 2 (T003) — can run in parallel with US1
- **US3 (Phase 5)**: Depends on Phase 2 (T003) — can run in parallel with US1/US2
- **US4 (Phase 6)**: No dependency on other user stories — can run in parallel with US1/US2/US3
- **US5 (Phase 7)**: Depends on US4 (T015/T016) completion — same file, must sequence after US4
- **US6 (Phase 8)**: Depends on US4 + US5 — same file, must sequence after US5
- **Polish (Phase 9)**: Depends on all desired user stories being complete
### User Story Dependencies
- **US1 (P1)**: Independent after foundational phase. Can start immediately.
- **US2 (P1)**: Independent after foundational phase. Can run in parallel with US1.
- **US3 (P2)**: Independent. T010 and T011 are parallel (different files).
- **US4 (P2)**: Independent. T015 and T016 edit the same file sequentially.
- **US5 (P3)**: Depends on US4 (same file — `AlertRuleResource.php`).
- **US6 (P3, Optional)**: Depends on US5 (same file — `AlertRuleResource.php`).
### Within Each User Story
- Implementation before tests (tests validate the implementation)
- Exception: US1 is test-only (updates existing assertions for the T003 foundational change)
- Story-specific tests run immediately after implementation
### Parallel Opportunities
- **After Phase 2**: US1 (test updates) + US2 (KPI bugfix) + US3 (Manage page cleanup) can all run in parallel — different files, no dependencies.
- **Within US3**: T010 and T011 are parallel — different files (`ListAlertRules.php` vs `ListAlertDestinations.php`).
- **US4 must precede US5/US6** — all edit `AlertRuleResource.php`.
---
## Parallel Example: After Foundational Phase
```bash
# These three can launch simultaneously after T003 completes:
# Agent A: US1 — Update test assertions
Task T004: Update OperateHubShellTest.php assertions (tests/Feature/OpsUx/)
Task T005: Run OperateHubShell tests
# Agent B: US2 — Fix KPI bug
Task T006: Fix deliveriesQueryForViewer() (app/Filament/Widgets/Alerts/)
Task T007-T009: Add + run KPI tests
# Agent C: US3 — Clean Manage pages
Task T010: Remove header spread from ListAlertRules.php
Task T011: Remove header spread from ListAlertDestinations.php (parallel with T010)
Task T012-T014: Add + run Manage page tests
```
---
## Implementation Strategy
### MVP First (US1 + US2 Only)
1. Complete Phase 1: Setup (verify baseline)
2. Complete Phase 2: Foundational (T003 — `scopeLabel()` copy change)
3. Complete Phase 3: US1 (update test assertions)
4. Complete Phase 4: US2 (KPI bugfix + tests)
5. **STOP and VALIDATE**: All Monitoring pages show correct indicators + KPIs match
6. Deploy/demo if ready — highest-value changes shipped
### Incremental Delivery
1. T003 foundational → scopeLabel copy changed
2. US1 test updates → existing tests green with new copy (MVP ready)
3. US2 KPI bugfix → data consistency fixed (full P1 delivered)
4. US3 Manage pages → no false indicators on workspace-owned pages (P2a)
5. US4 form labels → targeting semantics in AlertRule form (P2b)
6. US5 form sections → improved form organization (P3a)
7. US6 convenience button → optional enhancement (P3b, skip if budget exceeded)
8. Phase 9 polish → Pint + full suite + grep verification
### Single Developer Strategy (Recommended)
Sequential execution in priority order:
1. Phase 1 → Phase 2 → Phase 3 (US1) → Phase 4 (US2) → run full suite
2. Phase 5 (US3) → Phase 6 (US4) → Phase 7 (US5) → run full suite
3. Phase 8 (US6, optional) → Phase 9 (polish)
---
## Notes
- No schema changes — zero migrations needed
- No new files created (except potentially one new test file for US2 KPI tests)
- The `scopeLabel()` method name is NOT renamed — only the return values change (per spec)
- DB column values for `tenant_scope_mode` (`all`, `allowlist`) are UNCHANGED — only display labels
- `Filament\Schemas\Components\Section` is the correct v5 import (confirmed in 11 existing files)
- US6 is explicitly optional — skip without guilt if PR is already large

View File

@ -2,10 +2,12 @@
declare(strict_types=1);
use App\Filament\Resources\AlertRuleResource;
use App\Filament\Resources\AlertRuleResource\Pages\CreateAlertRule;
use App\Filament\Resources\AlertRuleResource\Pages\EditAlertRule;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('creates and edits alert rules with attached destinations', function (): void {
@ -64,3 +66,54 @@
expect($rule->tenant_scope_mode)->toBe('all');
expect($rule->destinations()->pluck('alert_destinations.id')->all())->toBe([(int) $destinationB->getKey()]);
});
it('shows targeting semantics labels and hides old scope labels on alert rule edit form', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create(['workspace_id' => $workspaceId]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'tenant_scope_mode' => 'allowlist',
'tenant_allowlist' => [(int) $tenant->getKey()],
]);
$rule->destinations()->attach((int) $destination->getKey(), ['workspace_id' => $workspaceId]);
$this->actingAs($user);
$this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
])->get(AlertRuleResource::getUrl('edit', ['record' => $rule], panel: 'admin'))
->assertOk()
->assertSee('Applies to tenants')
->assertSee('This rule is workspace-wide. Use this to limit where it applies.')
->assertSee('Selected tenants')
->assertSee('Only these tenants will trigger this rule.')
->assertDontSee('Tenant scope mode')
->assertDontSee('Tenant allowlist');
});
it('shows form section headings on alert rule edit form', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
$destination = AlertDestination::factory()->create(['workspace_id' => $workspaceId]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
]);
$rule->destinations()->attach((int) $destination->getKey(), ['workspace_id' => $workspaceId]);
$this->actingAs($user);
$this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
])->get(AlertRuleResource::getUrl('edit', ['record' => $rule], panel: 'admin'))
->assertOk()
->assertSee('Rule')
->assertSee('Applies to')
->assertSee('Delivery');
});

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Alerts\AlertsKpiHeader;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('filters KPI deliveries by tenant when context is set via lastTenantId fallback only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
$rule = AlertRule::factory()->create(['workspace_id' => $workspaceId]);
$destination = AlertDestination::factory()->create(['workspace_id' => $workspaceId]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now()->subHour(),
]);
$otherTenant = Tenant::factory()->create(['workspace_id' => $workspaceId]);
$user->tenants()->syncWithoutDetaching([
$otherTenant->getKey() => ['role' => 'owner'],
]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $otherTenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now()->subHour(),
]);
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $tenant->getKey(),
]);
Livewire::test(AlertsKpiHeader::class)
->assertSee('Deliveries (24h)')
->assertSee('1');
})->group('ops-ux');
it('filters KPI deliveries by tenant when context is set via Filament setTenant', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
$rule = AlertRule::factory()->create(['workspace_id' => $workspaceId]);
$destination = AlertDestination::factory()->create(['workspace_id' => $workspaceId]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now()->subHour(),
]);
$otherTenant = Tenant::factory()->create(['workspace_id' => $workspaceId]);
$user->tenants()->syncWithoutDetaching([
$otherTenant->getKey() => ['role' => 'owner'],
]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $otherTenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now()->subHour(),
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
Livewire::test(AlertsKpiHeader::class)
->assertSee('Deliveries (24h)')
->assertSee('1');
})->group('ops-ux');
it('shows workspace-wide KPI counts when no tenant context is active', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
$rule = AlertRule::factory()->create(['workspace_id' => $workspaceId]);
$destination = AlertDestination::factory()->create(['workspace_id' => $workspaceId]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now()->subHour(),
]);
$otherTenant = Tenant::factory()->create(['workspace_id' => $workspaceId]);
$user->tenants()->syncWithoutDetaching([
$otherTenant->getKey() => ['role' => 'owner'],
]);
AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $otherTenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'status' => AlertDelivery::STATUS_SENT,
'created_at' => now()->subHour(),
]);
$this->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
Livewire::test(AlertsKpiHeader::class)
->assertSee('Deliveries (24h)')
->assertSee('2');
})->group('ops-ux');

View File

@ -89,7 +89,7 @@
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Tenant — '.$tenantA->name)
->assertSee('Filtered by tenant: '.$tenantA->name)
->assertSee('Policy sync')
->assertSee('TenantA')
->assertDontSee('Inventory sync');

View File

@ -4,6 +4,8 @@
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertRuleResource;
use App\Filament\Resources\RestoreRunResource;
use App\Models\AuditLog;
use App\Models\OperationRun;
@ -40,23 +42,31 @@
$this->withSession($session)
->get(route('admin.operations.index'))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
->assertSee('All tenants')
->assertDontSee('Scope: Tenant')
->assertDontSee('Scope: Workspace');
$this->withSession($session)
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
->assertSee('All tenants')
->assertDontSee('Scope: Tenant')
->assertDontSee('Scope: Workspace');
$this->withSession($session)
->followingRedirects()
->get(AlertsCluster::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
->assertSee('All tenants')
->assertDontSee('Scope: Tenant')
->assertDontSee('Scope: Workspace');
$this->withSession($session)
->get(route('admin.monitoring.audit-log'))
->assertOk()
->assertSee('Scope: Workspace — all tenants');
->assertSee('All tenants')
->assertDontSee('Scope: Tenant')
->assertDontSee('Scope: Workspace');
});
Bus::assertNothingDispatched();
@ -218,7 +228,7 @@
$response->assertSessionHas(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $lastTenantMap);
})->group('ops-ux');
it('shows tenant scope label when tenant context is active', function (): void {
it('shows tenant filter label when tenant context is active', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
@ -228,8 +238,10 @@
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.index'))
->assertOk()
->assertSee('Scope: Tenant — '.$tenant->name)
->assertDontSee('Scope: Workspace — all tenants');
->assertSee('Filtered by tenant: '.$tenant->name)
->assertDontSee('Scope: Tenant')
->assertDontSee('Scope: Workspace')
->assertDontSee('All tenants');
})->group('ops-ux');
it('does not create audit entries when viewing operate hub pages', function (): void {
@ -271,3 +283,51 @@
expect((int) AuditLog::query()->count())->toBe($before);
})->group('ops-ux');
it('suppresses tenant indicator on alert rules list page (manage page)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(AlertRuleResource::getUrl(panel: 'admin'))
->assertOk()
->assertDontSee('Filtered by tenant')
->assertDontSee('Scope: Tenant')
->assertDontSee('Scope: Workspace');
})->group('ops-ux');
it('suppresses tenant indicator on alert destinations list page (manage page)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(AlertDestinationResource::getUrl(panel: 'admin'))
->assertOk()
->assertDontSee('Filtered by tenant')
->assertDontSee('Scope: Tenant')
->assertDontSee('Scope: Workspace');
})->group('ops-ux');
it('suppresses tenant indicator on alert rules list with lastTenantId fallback', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $tenant->workspace_id;
$lastTenantMap = [(string) $workspaceId => (int) $tenant->getKey()];
$this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap,
])->get(AlertRuleResource::getUrl(panel: 'admin'))
->assertOk()
->assertDontSee('Filtered by tenant')
->assertDontSee('Scope: Tenant');
})->group('ops-ux');

View File

@ -38,7 +38,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Workspace — all tenants');
->assertSee('All tenants');
expect(Filament::getTenant())->toBeNull();
});

View File

@ -24,7 +24,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Tenant — '.$tenant->name)
->assertSee('Filtered by tenant: '.$tenant->name)
->assertSee('Back to '.$tenant->name)
->assertSee('Show all tenants');
});
@ -43,7 +43,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Workspace — all tenants')
->assertSee('All tenants')
->assertDontSee('Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name)
->assertDontSee('Show all tenants');
@ -78,6 +78,6 @@
])
->get('/admin/operations')
->assertOk()
->assertSee('Scope: Workspace — all tenants')
->assertDontSee('Scope: Tenant — '.$tenant->name);
->assertSee('All tenants')
->assertDontSee('Filtered by tenant: '.$tenant->name);
});

View File

@ -85,7 +85,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get("/admin/operations/{$run->getKey()}")
->assertOk()
->assertSee('Scope: Workspace — all tenants')
->assertSee('All tenants')
->assertSee('Back to Operations')
->assertDontSee('← Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name)