feat: action-surface contract inspect affordance + clickable rows (#100)

Implements Spec 082 updates to the Filament Action Surface Contract:

- New required list/table slot: InspectAffordance (clickable row via recordUrl preferred; also supports View action or primary link column)
- Retrofit view-only tables to remove lone View row action buttons and use clickable rows
- Update validator + guard tests, add golden regression assertions
- Add docs: docs/ui/action-surface-contract.md

Tests (local via Sail):
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
- vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupSyncRunResourceTest.php

Notes:
- Filament v5 / Livewire v4 compatible.
- No destructive-action behavior changed in this PR.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #100
This commit is contained in:
ahmido 2026-02-08 20:31:36 +00:00
parent 11c73abd1d
commit a770b32e87
44 changed files with 2585 additions and 84 deletions

View File

@ -21,6 +21,7 @@ ## Active Technologies
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin) - PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin) - PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -40,9 +41,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 - 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 - 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->

View File

@ -1,11 +1,10 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.6.0 → 1.7.0 - Version change: 1.7.0 → 1.8.0
- Modified principles: - Modified principles:
- RBAC & UI Enforcement Standards (RBAC-UX) (added Filament action-surface contract gate) - Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added List/Table inspection affordance rule)
- Added sections: - Added sections: None
- Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/plan-template.md
@ -144,6 +143,9 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
Required surfaces Required surfaces
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s). - List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
- View/Detail MUST define Header Actions (Edit + “More” group when applicable). - View/Detail MUST define Header Actions (Edit + “More” group when applicable).
- Create/Edit MUST provide consistent Save/Cancel UX. - Create/Edit MUST provide consistent Save/Cancel UX.
@ -198,4 +200,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08 **Version**: 1.8.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08

View File

@ -43,7 +43,7 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
## Project Structure ## Project Structure

View File

@ -129,9 +129,9 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log. RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | | Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| Resource/Page/RM | e.g. app/Filament/... | | | | | | | | | | Resource/Page/RM | e.g. app/Filament/... | | e.g. `recordUrl()` / View action / linked column | | | | | | | |
### Key Entities *(include if feature involves data)* ### Key Entities *(include if feature involves data)*

View File

@ -27,6 +27,7 @@ # Tasks: [FEATURE NAME]
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include: **Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
- filling the specs “UI Action Matrix” for all changed surfaces, - filling the specs “UI Action Matrix” for all changed surfaces,
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit), - implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule, - enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
- grouping bulk actions via BulkActionGroup, - grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale), - adding confirmations for destructive actions (and typed confirmation where required by scale),

View File

@ -7,8 +7,11 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -32,6 +35,16 @@ class EntraGroupResource extends Resource
protected static ?string $navigationLabel = 'Groups'; protected static ?string $navigationLabel = 'Groups';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -88,15 +101,25 @@ public static function table(Table $table): Table
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
}) })
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->columns([ ->columns([
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(), Tables\Columns\TextColumn::make('display_name')
Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(), ->label('Name')
->searchable(),
Tables\Columns\TextColumn::make('entra_id')
->label('Entra ID')
->copyable()
->toggleable(),
Tables\Columns\TextColumn::make('type') Tables\Columns\TextColumn::make('type')
->label('Type') ->label('Type')
->badge() ->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))) ->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))), ->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'), Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen')
->since(),
]) ])
->filters([ ->filters([
SelectFilter::make('stale') SelectFilter::make('stale')
@ -165,9 +188,7 @@ public static function table(Table $table): Table
}; };
}), }),
]) ])
->actions([ ->actions([])
Actions\ViewAction::make(),
])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -8,8 +8,11 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -34,6 +37,16 @@ class EntraGroupSyncRunResource extends Resource
protected static ?string $navigationLabel = 'Group Sync Runs'; protected static ?string $navigationLabel = 'Group Sync Runs';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -131,9 +144,10 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(), Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(), Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
]) ])
->actions([ ->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
Actions\ViewAction::make(), ? static::getUrl('view', ['record' => $record])
]) : null)
->actions([])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -17,8 +17,11 @@
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType; use App\Support\Enums\RelationshipType;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -42,6 +45,16 @@ class InventoryItemResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory items list intentionally has no header actions; items are populated via sync.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for inventory items.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; inventory items are created by inventory sync.');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -262,9 +275,10 @@ public static function table(Table $table): Table
->options($categoryOptions) ->options($categoryOptions)
->searchable(), ->searchable(),
]) ])
->actions([ ->recordUrl(static fn (Model $record): ?string => static::canView($record)
Actions\ViewAction::make(), ? static::getUrl('view', ['record' => $record])
]) : null)
->actions([])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -12,8 +12,11 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource; use Filament\Resources\Resource;
@ -39,6 +42,16 @@ class InventorySyncRunResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -189,9 +202,10 @@ public static function table(Table $table): Table
->label('Errors') ->label('Errors')
->numeric(), ->numeric(),
]) ])
->actions([ ->recordUrl(static fn (Model $record): ?string => static::canView($record)
Actions\ViewAction::make(), ? static::getUrl('view', ['record' => $record])
]) : null)
->actions([])
->bulkActions([]); ->bulkActions([]);
} }

View File

@ -16,6 +16,11 @@
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -41,12 +46,40 @@ class OperationRunResource extends Resource
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
protected static bool $isGloballySearchable = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Operations'; protected static ?string $navigationLabel = 'Operations';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->withDefaults(new ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->exempt(
ActionSurfaceSlot::ListHeader,
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(
ActionSurfaceSlot::ListBulkMoreGroup,
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
)
->exempt(
ActionSurfaceSlot::ListEmptyState,
'Empty-state action is intentionally omitted; users can adjust filters/date range in-page.',
)
->exempt(
ActionSurfaceSlot::DetailHeader,
'Tenantless detail view is informational and currently has no header actions.',
);
}
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();

View File

@ -23,6 +23,10 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -52,6 +56,17 @@ class PolicyResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -536,7 +551,9 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility() ->preserveVisibility()
->apply(), ->apply(),
])->icon('heroicon-o-ellipsis-vertical'), ])
->label('More')
->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
@ -888,7 +905,7 @@ public static function table(Table $table): Table
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->apply(), ->apply(),
]), ])->label('More'),
]); ]);
} }

View File

@ -22,8 +22,17 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [$this->makeSyncAction()];
UiEnforcement::forAction( }
protected function getTableEmptyStateActions(): array
{
return [$this->makeSyncAction()];
}
private function makeSyncAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('sync') Actions\Action::make('sync')
->label('Sync from Intune') ->label('Sync from Intune')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
@ -86,7 +95,6 @@ protected function getHeaderActions(): array
->requireCapability(Capabilities::TENANT_SYNC) ->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.') ->tooltip('You do not have permission to sync policies.')
->destructive() ->destructive()
->apply(), ->apply();
];
} }
} }

View File

@ -13,6 +13,10 @@
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -24,6 +28,16 @@ class VersionsRelationManager extends RelationManager
{ {
protected static string $relationship = 'versions'; protected static string $relationship = 'versions';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
}
public function table(Table $table): Table public function table(Table $table): Table
{ {
$restoreToIntune = Actions\Action::make('restore_to_intune') $restoreToIntune = Actions\Action::make('restore_to_intune')

View File

@ -23,6 +23,10 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum; use BackedEnum;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Filament\Actions; use Filament\Actions;
@ -51,6 +55,17 @@ class PolicyVersionResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->exempt(ActionSurfaceSlot::ListHeader, 'Policy versions list intentionally has no header actions; versions are created by sync and captures.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; versions appear after policy sync/capture workflows.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.');
}
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema
@ -494,8 +509,10 @@ public static function table(Table $table): Table
->trueLabel('All') ->trueLabel('All')
->falseLabel('Archived'), ->falseLabel('Archived'),
]) ])
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
->actions([ ->actions([
Actions\ViewAction::make(),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
(function (): Actions\Action { (function (): Actions\Action {
$action = Actions\Action::make('restore_via_wizard') $action = Actions\Action::make('restore_via_wizard')

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceDeclaration
{
/**
* @var array<string, ActionSurfaceSlotRequirement>
*/
private array $slots = [];
/**
* @var array<string, ActionSurfaceExemption>
*/
private array $exemptions = [];
public ActionSurfaceDefaults $defaults;
public function __construct(
public readonly int $version,
public readonly ActionSurfaceComponentType $componentType,
public readonly ActionSurfaceProfile $profile,
?ActionSurfaceDefaults $defaults = null,
) {
$this->defaults = $defaults ?? new ActionSurfaceDefaults;
}
public static function make(
ActionSurfaceComponentType $componentType,
ActionSurfaceProfile $profile,
int $version = 1,
): self {
return new self(
version: $version,
componentType: $componentType,
profile: $profile,
);
}
public static function forResource(ActionSurfaceProfile $profile, int $version = 1): self
{
return self::make(ActionSurfaceComponentType::Resource, $profile, $version);
}
public static function forPage(ActionSurfaceProfile $profile, int $version = 1): self
{
return self::make(ActionSurfaceComponentType::Page, $profile, $version);
}
public static function forRelationManager(ActionSurfaceProfile $profile, int $version = 1): self
{
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $version);
}
public function withDefaults(ActionSurfaceDefaults $defaults): self
{
$this->defaults = $defaults;
return $this;
}
public function setSlot(ActionSurfaceSlot $slot, ActionSurfaceSlotRequirement $requirement): self
{
$this->slots[$slot->value] = $requirement;
return $this;
}
public function satisfy(
ActionSurfaceSlot $slot,
?string $details = null,
bool $requiresTypedConfirmation = false,
): self {
return $this->setSlot($slot, ActionSurfaceSlotRequirement::satisfied($details, $requiresTypedConfirmation));
}
public function exempt(
ActionSurfaceSlot $slot,
string $reason,
?string $trackingRef = null,
?string $details = null,
): self {
$this->setSlot($slot, ActionSurfaceSlotRequirement::exempt($details));
$this->exemptions[$slot->value] = new ActionSurfaceExemption($slot, $reason, $trackingRef);
return $this;
}
public function slot(ActionSurfaceSlot $slot): ?ActionSurfaceSlotRequirement
{
return $this->slots[$slot->value] ?? null;
}
public function exemption(ActionSurfaceSlot $slot): ?ActionSurfaceExemption
{
return $this->exemptions[$slot->value] ?? null;
}
/**
* @return array<string, ActionSurfaceSlotRequirement>
*/
public function slots(): array
{
return $this->slots;
}
/**
* @return array<string, ActionSurfaceExemption>
*/
public function exemptions(): array
{
return $this->exemptions;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
final class ActionSurfaceDefaults
{
public function __construct(
public readonly string $moreGroupLabel = 'More',
public readonly bool $exportIsDefaultBulkActionForReadOnly = true,
) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
final class ActionSurfaceDiscoveredComponent
{
/**
* @param array<int, ActionSurfacePanelScope> $panelScopes
*/
public function __construct(
public readonly string $className,
public readonly ActionSurfaceComponentType $componentType,
public readonly array $panelScopes,
) {}
public function hasPanelScope(ActionSurfacePanelScope $scope): bool
{
return in_array($scope, $this->panelScopes, true);
}
}

View File

@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class ActionSurfaceDiscovery
{
private string $basePath;
private string $appPath;
private string $routesPath;
private string $adminPanelProviderPath;
public function __construct(
?string $basePath = null,
?string $appPath = null,
?string $routesPath = null,
?string $adminPanelProviderPath = null,
) {
$this->basePath = $basePath ?? base_path();
$this->appPath = $appPath ?? app_path();
$this->routesPath = $routesPath ?? base_path('routes/web.php');
$this->adminPanelProviderPath = $adminPanelProviderPath ?? app_path('Providers/Filament/AdminPanelProvider.php');
}
/**
* @return array<int, ActionSurfaceDiscoveredComponent>
*/
public function discover(): array
{
$adminScopedClasses = $this->discoverAdminScopedClasses();
/** @var array<string, ActionSurfaceDiscoveredComponent> $components */
$components = [];
foreach ($this->resourceFiles() as $path) {
$className = $this->classNameFromPath($path);
$components[$className] = new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Resource,
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
);
}
foreach ($this->pageFiles() as $path) {
$className = $this->classNameFromPath($path);
$components[$className] = new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Page,
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
);
}
foreach ($this->relationManagerFiles() as $path) {
$className = $this->classNameFromPath($path);
$components[$className] = new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::RelationManager,
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
);
}
ksort($components);
return array_values($components);
}
/**
* @param array<int, string> $adminScopedClasses
* @return array<int, ActionSurfacePanelScope>
*/
private function panelScopesFor(string $className, array $adminScopedClasses): array
{
$scopes = [ActionSurfacePanelScope::Tenant];
if (in_array($className, $adminScopedClasses, true)) {
$scopes[] = ActionSurfacePanelScope::Admin;
}
return $scopes;
}
/**
* @return array<int, string>
*/
private function resourceFiles(): array
{
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
if (! str_ends_with($path, 'Resource.php')) {
return false;
}
if (str_contains($path, '/Pages/')) {
return false;
}
if (str_contains($path, '/RelationManagers/')) {
return false;
}
return true;
});
}
/**
* @return array<int, string>
*/
private function pageFiles(): array
{
return $this->collectPhpFiles($this->appPath.'/Filament/Pages', static function (string $path): bool {
return str_ends_with($path, '.php');
});
}
/**
* @return array<int, string>
*/
private function relationManagerFiles(): array
{
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
if (! str_contains($path, '/RelationManagers/')) {
return false;
}
return str_ends_with($path, 'RelationManager.php');
});
}
/**
* @param callable(string): bool $filter
* @return array<int, string>
*/
private function collectPhpFiles(string $directory, callable $filter): array
{
if (! is_dir($directory)) {
return [];
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
);
$paths = [];
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = str_replace('\\', '/', $file->getPathname());
if (! $filter($path)) {
continue;
}
$paths[] = $path;
}
sort($paths);
return $paths;
}
/**
* @return array<int, string>
*/
private function discoverAdminScopedClasses(): array
{
$classes = array_merge(
$this->parseFilamentClassReferences($this->adminPanelProviderPath),
$this->parseFilamentClassReferences($this->routesPath),
);
$classes = array_values(array_unique(array_filter($classes, static function (string $className): bool {
return str_starts_with($className, 'App\\Filament\\');
})));
sort($classes);
return $classes;
}
/**
* @return array<int, string>
*/
private function parseFilamentClassReferences(string $filePath): array
{
if (! is_file($filePath)) {
return [];
}
$contents = file_get_contents($filePath);
if (! is_string($contents) || $contents === '') {
return [];
}
$imports = $this->parseUseStatements($contents);
preg_match_all('/\\\\?([A-Z][A-Za-z0-9_\\\\]*)::(?:class|registerRoutes)\b/', $contents, $matches);
$classes = [];
foreach ($matches[1] as $token) {
$resolved = $this->resolveClassToken($token, $imports);
if ($resolved === null) {
continue;
}
$classes[] = $resolved;
}
return $classes;
}
/**
* @return array<string, string>
*/
private function parseUseStatements(string $contents): array
{
preg_match_all('/^use\s+([^;]+);/m', $contents, $matches);
$imports = [];
foreach ($matches[1] as $importExpression) {
$normalized = trim($importExpression);
if (! str_contains($normalized, '\\')) {
continue;
}
$parts = preg_split('/\s+as\s+/i', $normalized);
$fqcn = ltrim($parts[0], '\\');
$alias = $parts[1] ?? null;
if (! is_string($alias) || trim($alias) === '') {
$segments = explode('\\', $fqcn);
$alias = end($segments);
}
if (! is_string($alias) || trim($alias) === '') {
continue;
}
$imports[trim($alias)] = $fqcn;
}
return $imports;
}
/**
* @param array<string, string> $imports
*/
private function resolveClassToken(string $token, array $imports): ?string
{
$token = ltrim(trim($token), '\\');
if ($token === '') {
return null;
}
if (str_contains($token, '\\')) {
return $token;
}
return $imports[$token] ?? null;
}
private function classNameFromPath(string $path): string
{
$normalizedPath = str_replace('\\', '/', $path);
$normalizedAppPath = str_replace('\\', '/', $this->appPath);
$relative = ltrim(substr($normalizedPath, strlen($normalizedAppPath)), '/');
return 'App\\'.str_replace('/', '\\', substr($relative, 0, -4));
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceExemption
{
public function __construct(
public readonly ActionSurfaceSlot $slot,
public readonly string $reason,
public readonly ?string $trackingRef = null,
) {}
public function hasReason(): bool
{
return trim($this->reason) !== '';
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
final class ActionSurfaceExemptions
{
/**
* @param array<string, string> $componentReasons
*/
public function __construct(
private readonly array $componentReasons,
) {}
public static function baseline(): self
{
return new self([
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\DriftLanding' => 'Drift landing retrofit deferred to drift-focused UI spec.',
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\InventoryLanding' => 'Inventory landing page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\AuditLog' => 'Monitoring audit-log page retrofit deferred; no action-surface declaration yet.',
'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.',
'App\\Filament\\Pages\\NoAccess' => 'No-access page has no actionable surface by design.',
'App\\Filament\\Pages\\Operations\\TenantlessOperationRunViewer' => 'Tenantless run viewer retrofit deferred; run-link semantics are covered by monitoring tests.',
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
'App\\Filament\\Pages\\TenantDiagnostics' => 'Diagnostics page retrofit deferred to tenant-RBAC diagnostics spec.',
'App\\Filament\\Pages\\TenantRequiredPermissions' => 'Permissions page retrofit deferred; capability checks already enforced by dedicated tests.',
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
'App\\Filament\\Resources\\BackupScheduleResource' => 'Backup schedule resource retrofit deferred to backup scheduling track.',
'App\\Filament\\Resources\\BackupScheduleResource\\RelationManagers\\BackupScheduleRunsRelationManager' => 'Backup schedule runs relation manager retrofit deferred to backup scheduling track.',
'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.',
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
'App\\Filament\\Resources\\FindingResource' => 'Finding resource retrofit deferred to drift track.',
'App\\Filament\\Resources\\ProviderConnectionResource' => 'Provider connection resource retrofit deferred to provider-connection track.',
'App\\Filament\\Resources\\RestoreRunResource' => 'Restore run resource retrofit deferred to restore track.',
'App\\Filament\\Resources\\TenantResource' => 'Tenant resource retrofit deferred to tenant administration track.',
'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.',
'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.',
'App\\Filament\\Resources\\Workspaces\\WorkspaceResource' => 'Workspace resource retrofit deferred to workspace management track.',
]);
}
/**
* @return array<string, string>
*/
public function all(): array
{
return $this->componentReasons;
}
public function reasonForClass(string $className): ?string
{
return $this->componentReasons[$className] ?? null;
}
public function hasClass(string $className): bool
{
return array_key_exists($className, $this->componentReasons);
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceProfileDefinition
{
/**
* @return array<int, ActionSurfaceSlot>
*/
public function requiredSlots(ActionSurfaceProfile $profile): array
{
return match ($profile) {
ActionSurfaceProfile::CrudListAndEdit,
ActionSurfaceProfile::CrudListAndView => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListRowMoreMenu,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
ActionSurfaceSlot::DetailHeader,
],
ActionSurfaceProfile::ListOnlyReadOnly => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListRowMoreMenu,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
],
ActionSurfaceProfile::RunLog => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
ActionSurfaceSlot::DetailHeader,
],
ActionSurfaceProfile::RelationManager => [
ActionSurfaceSlot::ListHeader,
ActionSurfaceSlot::InspectAffordance,
ActionSurfaceSlot::ListRowMoreMenu,
ActionSurfaceSlot::ListBulkMoreGroup,
ActionSurfaceSlot::ListEmptyState,
],
};
}
public function requiresExportDefaultBulk(ActionSurfaceProfile $profile): bool
{
return in_array($profile, [
ActionSurfaceProfile::ListOnlyReadOnly,
ActionSurfaceProfile::RunLog,
], true);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlotStatus;
final class ActionSurfaceSlotRequirement
{
public function __construct(
public readonly ActionSurfaceSlotStatus $status,
public readonly ?string $details = null,
public readonly bool $requiresTypedConfirmation = false,
) {}
public static function satisfied(?string $details = null, bool $requiresTypedConfirmation = false): self
{
return new self(
status: ActionSurfaceSlotStatus::Satisfied,
details: $details,
requiresTypedConfirmation: $requiresTypedConfirmation,
);
}
public static function exempt(?string $details = null): self
{
return new self(
status: ActionSurfaceSlotStatus::Exempt,
details: $details,
);
}
public function isExempt(): bool
{
return $this->status === ActionSurfaceSlotStatus::Exempt;
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidationIssue
{
public function __construct(
public readonly string $className,
public readonly string $message,
public readonly ?ActionSurfaceSlot $slot = null,
public readonly ?string $hint = null,
) {}
public function format(): string
{
$line = $this->className;
if ($this->slot !== null) {
$line .= ' ['.$this->slot->value.']';
}
$line .= ': '.$this->message;
if ($this->hint !== null && trim($this->hint) !== '') {
$line .= ' (hint: '.$this->hint.')';
}
return $line;
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
final class ActionSurfaceValidationResult
{
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
public function __construct(
private readonly array $issues,
private readonly int $componentCount,
) {}
public function hasIssues(): bool
{
return $this->issues !== [];
}
/**
* @return array<int, ActionSurfaceValidationIssue>
*/
public function issues(): array
{
return $this->issues;
}
public function componentCount(): int
{
return $this->componentCount;
}
public function formatForAssertion(): string
{
if (! $this->hasIssues()) {
return sprintf('Validated %d action-surface components with no issues.', $this->componentCount);
}
$lines = array_map(
static fn (ActionSurfaceValidationIssue $issue): string => '- '.$issue->format(),
$this->issues,
);
return sprintf(
"Action Surface Contract violations (%d/%d):\n%s",
count($this->issues),
$this->componentCount,
implode("\n", $lines),
);
}
}

View File

@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidator
{
private ActionSurfaceDiscovery $discovery;
private ActionSurfaceProfileDefinition $profileDefinition;
private ActionSurfaceExemptions $exemptions;
public function __construct(
?ActionSurfaceDiscovery $discovery = null,
?ActionSurfaceProfileDefinition $profileDefinition = null,
?ActionSurfaceExemptions $exemptions = null,
) {
$this->discovery = $discovery ?? new ActionSurfaceDiscovery;
$this->profileDefinition = $profileDefinition ?? new ActionSurfaceProfileDefinition;
$this->exemptions = $exemptions ?? ActionSurfaceExemptions::baseline();
}
/**
* @return array<int, ActionSurfaceDiscoveredComponent>
*/
public function discoveredComponents(): array
{
return $this->discovery->discover();
}
public static function withBaselineExemptions(): self
{
return new self(
discovery: new ActionSurfaceDiscovery,
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: ActionSurfaceExemptions::baseline(),
);
}
public function validate(): ActionSurfaceValidationResult
{
return $this->validateComponents($this->discoveredComponents());
}
/**
* @param array<int, ActionSurfaceDiscoveredComponent> $components
*/
public function validateComponents(array $components): ActionSurfaceValidationResult
{
$issues = [];
foreach ($components as $component) {
if (! class_exists($component->className)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: 'Discovered class does not exist or is not autoloadable.',
hint: 'Verify namespace/path and run composer dump-autoload if needed.',
);
continue;
}
$declaration = $this->resolveDeclarationForComponent($component, $issues);
if ($declaration === null) {
continue;
}
if ($declaration->componentType !== $component->componentType) {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: sprintf(
'Declaration component type mismatch (%s declared, %s discovered).',
$declaration->componentType->name,
$component->componentType->name,
),
hint: 'Use ActionSurfaceDeclaration::forResource/forPage/forRelationManager consistently.',
);
}
if ($declaration->defaults->moreGroupLabel !== 'More') {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: sprintf(
'Invalid more-group label "%s".',
$declaration->defaults->moreGroupLabel,
),
hint: 'Set ActionSurfaceDefaults->moreGroupLabel to "More".',
);
}
$this->validateRequiredSlots($component->className, $declaration, $issues);
$this->validateExemptions($component->className, $declaration, $issues);
$this->validateExportDefaults($component->className, $declaration, $issues);
}
return new ActionSurfaceValidationResult(
issues: $issues,
componentCount: count($components),
);
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function resolveDeclarationForComponent(
ActionSurfaceDiscoveredComponent $component,
array &$issues,
): ?ActionSurfaceDeclaration {
$className = $component->className;
if (! method_exists($className, 'actionSurfaceDeclaration')) {
$this->validateClassExemptionOrFail($className, $issues);
return null;
}
try {
$declaration = $className::actionSurfaceDeclaration();
} catch (\Throwable $throwable) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'actionSurfaceDeclaration() threw an exception: '.$throwable->getMessage(),
hint: 'Ensure actionSurfaceDeclaration() is static and does not depend on request state.',
);
return null;
}
if (! $declaration instanceof ActionSurfaceDeclaration) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'actionSurfaceDeclaration() must return ActionSurfaceDeclaration.',
hint: 'Return ActionSurfaceDeclaration::forResource/forPage/forRelationManager(...).',
);
return null;
}
return $declaration;
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateClassExemptionOrFail(string $className, array &$issues): void
{
$reason = $this->exemptions->reasonForClass($className);
if ($reason === null) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Missing action-surface declaration and no component exemption exists.',
hint: 'Add actionSurfaceDeclaration() or register a baseline exemption with a non-empty reason.',
);
return;
}
if (trim($reason) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Component exemption reason must be non-empty.',
hint: 'Provide a concrete, non-empty justification in ActionSurfaceExemptions::baseline().',
);
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateRequiredSlots(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
foreach ($this->profileDefinition->requiredSlots($declaration->profile) as $slot) {
$requirement = $declaration->slot($slot);
if ($requirement === null) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: $slot,
message: 'Required slot is not declared.',
hint: 'Declare slot as satisfied or exempt with a reason.',
);
continue;
}
if (! $requirement->isExempt()) {
if ($slot === ActionSurfaceSlot::InspectAffordance) {
$this->validateInspectAffordanceSlot($className, $requirement, $issues);
}
continue;
}
$exemption = $declaration->exemption($slot);
if ($exemption === null || ! $exemption->hasReason()) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: $slot,
message: 'Slot is marked exempt but exemption reason is missing or empty.',
hint: 'Use ->exempt(slot, "reason") with a non-empty reason.',
);
}
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateInspectAffordanceSlot(
string $className,
ActionSurfaceSlotRequirement $requirement,
array &$issues,
): void {
$mode = $requirement->details;
if (! is_string($mode) || trim($mode) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: 'Inspect affordance must declare how inspection is provided (clickable_row, view_action, or primary_link_column).',
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
);
return;
}
if (ActionSurfaceInspectAffordance::tryFrom($mode) instanceof ActionSurfaceInspectAffordance) {
return;
}
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
);
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateExemptions(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
foreach ($declaration->exemptions() as $slotValue => $exemption) {
if (! $exemption->hasReason()) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::from($slotValue),
message: 'Exemption reason must be non-empty.',
hint: 'Provide a concise reason for each exempted slot.',
);
}
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateExportDefaults(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
if (! $this->profileDefinition->requiresExportDefaultBulk($declaration->profile)) {
return;
}
if ($declaration->defaults->exportIsDefaultBulkActionForReadOnly) {
return;
}
$bulkExemption = $declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup);
if ($bulkExemption instanceof ActionSurfaceExemption && $bulkExemption->hasReason()) {
return;
}
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::ListBulkMoreGroup,
message: 'ReadOnly/RunLog profile disables Export default but no bulk-slot exemption reason was provided.',
hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.',
);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceComponentType: string
{
case Resource = 'resource';
case Page = 'page';
case RelationManager = 'relation_manager';
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceInspectAffordance: string
{
case ClickableRow = 'clickable_row';
case ViewAction = 'view_action';
case PrimaryLinkColumn = 'primary_link_column';
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfacePanelScope: string
{
case Tenant = 'tenant';
case Admin = 'admin';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceProfile: string
{
case CrudListAndEdit = 'crud_list_and_edit';
case CrudListAndView = 'crud_list_and_view';
case ListOnlyReadOnly = 'list_only_read_only';
case RunLog = 'run_log';
case RelationManager = 'relation_manager';
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceSlot: string
{
case ListHeader = 'ListHeader';
case InspectAffordance = 'InspectAffordance';
case ListRowMoreMenu = 'ListRowMoreMenu';
case ListBulkMoreGroup = 'ListBulkMoreGroup';
case ListEmptyState = 'ListEmptyState';
case DetailHeader = 'DetailHeader';
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface\Enums;
enum ActionSurfaceSlotStatus: string
{
case Satisfied = 'Satisfied';
case Exempt = 'Exempt';
}

View File

@ -0,0 +1,26 @@
# Action surface contract
This project enforces a small “action surface contract” for Filament Resources / Pages / RelationManagers to keep table UIs consistent, quiet, and safe.
## Inspect affordance (required)
Any list-style surface that exposes records must provide an **inspect affordance** so an admin can open a record.
Accepted implementations:
- **Clickable rows** (preferred): set `recordUrl()` for the table.
- **View action**: a `ViewAction` in the row actions.
- **Primary link column**: a column that is clearly the primary affordance to open the record.
### Rule: no lone “View” button
Avoid rendering a table that only has a single `View` row action. This creates visual noise and adds an unnecessary Actions column.
Preferred approach:
- Make the row clickable via `recordUrl()` and set `actions([])` so no Actions column is rendered.
## RBAC / safety
- If the current user cannot inspect a record, `recordUrl()` must return `null` for that record.
- UI visibility is not authorization; always enforce permissions at the policy / resource level.

View File

@ -0,0 +1,37 @@
# Specification Quality Checklist: Action Surface Contract + CI Enforcement
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-08
**Feature**: specs/082-action-surface-contract/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
- Validation run: 2026-02-08
- No placeholders remain; no [NEEDS CLARIFICATION] markers.
- Requirements include explicit RBAC semantics (not-found vs forbidden), safety confirmations, and exemption rules.
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@ -0,0 +1,102 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantatlas.local/schemas/action-surface-declaration.schema.json",
"title": "ActionSurfaceDeclaration",
"type": "object",
"additionalProperties": false,
"required": ["version", "componentType", "profile", "defaults", "slots"],
"properties": {
"version": {
"type": "integer",
"minimum": 1
},
"componentType": {
"type": "string",
"enum": ["Resource", "Page", "RelationManager"]
},
"profile": {
"type": "string",
"enum": [
"CrudListAndEdit",
"CrudListAndView",
"ListOnlyReadOnly",
"RunLog"
]
},
"defaults": {
"type": "object",
"additionalProperties": false,
"required": ["moreGroupLabel"],
"properties": {
"moreGroupLabel": {
"type": "string",
"const": "More"
},
"exportIsDefaultBulkActionForReadOnly": {
"type": "boolean"
}
}
},
"slots": {
"type": "object",
"additionalProperties": false,
"properties": {
"ListHeader": { "$ref": "#/$defs/slotRequirement" },
"ListRowPrimary": { "$ref": "#/$defs/slotRequirement" },
"ListRowMoreMenu": { "$ref": "#/$defs/slotRequirement" },
"ListBulkMoreGroup": { "$ref": "#/$defs/slotRequirement" },
"ListEmptyState": { "$ref": "#/$defs/slotRequirement" },
"DetailHeader": { "$ref": "#/$defs/slotRequirement" }
}
},
"exemptions": {
"type": "array",
"items": { "$ref": "#/$defs/exemption" }
}
},
"$defs": {
"slotRequirement": {
"type": "object",
"additionalProperties": false,
"required": ["status"],
"properties": {
"status": {
"type": "string",
"enum": ["Satisfied", "Exempt"]
},
"details": {
"type": "string"
},
"requiresTypedConfirmation": {
"type": "boolean",
"default": false
}
}
},
"exemption": {
"type": "object",
"additionalProperties": false,
"required": ["slot", "reason"],
"properties": {
"slot": {
"type": "string",
"enum": [
"ListHeader",
"ListRowPrimary",
"ListRowMoreMenu",
"ListBulkMoreGroup",
"ListEmptyState",
"DetailHeader"
]
},
"reason": {
"type": "string",
"minLength": 1
},
"trackingRef": {
"type": "string"
}
}
}
}
}

View File

@ -0,0 +1,81 @@
# Data Model — Action Surface Contract
This feature introduces a small, explicit model for declaring and validating action surfaces.
## Entities
### ActionSurfaceDeclaration
Represents the contract declaration attached to a Filament component.
**Fields**:
- `version` (int): schema version for forward compatibility (start at `1`).
- `componentType` (enum): `Resource | Page | RelationManager`.
- `className` (string): fully qualified class name (filled by validator when reporting).
- `panelScope` (enum): `Tenant | Admin` (filled by validator based on discovery context).
- `profile` (enum): contract profile (see below).
- `slots` (map<ActionSurfaceSlot, SlotRequirement>): required and optional slots for this component.
- `exemptions` (list<ActionSurfaceExemption>): explicit exemptions with reason.
- `defaults` (ActionSurfaceDefaults): shared UX defaults (e.g., `moreGroupLabel`).
### ActionSurfaceSlot (enum)
Discrete UI locations that may contain actions.
Proposed baseline slots:
- `ListHeader`
- `ListRowPrimary`
- `ListRowMoreMenu`
- `ListBulkMoreGroup`
- `ListEmptyState`
- `DetailHeader`
(Profiles determine which slots are required.)
### SlotRequirement
Requirement status for a single slot.
**Fields**:
- `status` (enum): `Satisfied | Exempt`.
- `details` (optional string): freeform explanation or expected actions.
- `requiresTypedConfirmation` (bool, default false): indicates the actions in this slot require typed confirmation.
### ActionSurfaceExemption
Represents a justified opt-out for a required slot.
**Fields**:
- `slot` (ActionSurfaceSlot)
- `reason` (string, required, non-empty)
- `trackingRef` (optional string)
### ActionSurfaceDefaults
Shared defaults enforced/checked by CI.
**Fields**:
- `moreGroupLabel` (string, required): must be `More`.
- `exportIsDefaultBulkActionForReadOnly` (bool): default expectation for ReadOnly + RunLog profiles.
## Profiles (enum)
Profiles map component archetypes to required slots.
Proposed profiles (initial set):
- `CrudListAndEdit`: list + edit page exists.
- `CrudListAndView`: list + view page exists.
- `ListOnlyReadOnly`: list-only, no edit/view.
- `RunLog`: operational log list (read-only, expects Export bulk action by default).
## Validation rules (CI)
For each in-scope class:
- Must define an action surface declaration.
- Must select a profile.
- For every slot required by that profile:
- slot must be present with `Satisfied`, OR
- slot must be marked `Exempt` and there must be a matching exemption with non-empty reason.
- Defaults must include `moreGroupLabel = "More"`.
Note: The validator is intentionally declarative; it validates the presence and completeness of the declaration (and exemptions), not the exact runtime Filament action configuration.

View File

@ -0,0 +1,108 @@
# Implementation Plan: Action Surface Contract + CI Enforcement
**Branch**: `082-action-surface-contract` | **Date**: 2026-02-08 | **Spec**: specs/082-action-surface-contract/spec.md
**Input**: Feature specification from specs/082-action-surface-contract/spec.md
## Summary
Introduce a declarative “Action Surface Declaration” for every in-scope admin UI component (Resources, Pages, embedded relationship sub-lists), enforce it via a deterministic Pest guard in CI, and add representative runtime tests for constitution-critical action behavior.
Key behavior:
- Contract profiles define minimum required action slots (list header/row/bulk/empty-state, detail header).
- Declarations must satisfy each required slot or explicitly exempt it with a reason.
- CI fails on missing declarations, missing required slots, or empty exemption reasons.
- Representative runtime tests verify grouping/confirmation/RBAC/canonical-run-link behavior on selected surfaces.
## Technical Context
**Language/Version**: PHP 8.4.x
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4 (PHPUnit 12 runner)
**Target Platform**: Web application (Laravel Sail for local; Dokploy for deploy)
**Project Type**: Laravel monolith (admin console via Filament panels)
**Performance Goals**: Contract validation completes in < 1s locally/CI (pure PHP, no network)
**Constraints**:
- Deterministic CI enforcement (no Livewire browser interactions)
- No new external calls introduced
- Must preserve RBAC semantics (non-member 404; member missing capability 403)
- Must not change panel architecture (tenant/admin/system panels remain)
- No deep runtime introspection of every Filament action in CI for this iteration
**Scale/Scope**: ~100 Filament classes (Resources/Pages/RelationManagers); enforcement is repo-wide within scope
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: N/A (this feature is UI governance; no inventory model changes)
- Read/write separation: N/A (no new domain writes introduced by the validator)
- Graph contract path: PASS (no new Graph calls)
- Deterministic capabilities: PASS (capability rules stay registry-driven)
- RBAC-UX: PASS (enforcement continues to use central UI helpers; server-side remains authoritative)
- Run observability: PASS (validator runs in CI only; no operations started)
- Data minimization: PASS (no new storage)
- Badge semantics (BADGE-001): N/A
- Filament UI Action Surface Contract: PASS (this feature implements the CI gate itself)
## Plan Phases
### Phase 0 — Research (output: research.md)
Decide and document:
- How to enumerate in-scope classes for tenant + admin panels (excluding widgets)
- How declarations are attached (static method vs attributes vs config)
- What “slots” exist and how exemptions are represented (reason required; tracking optional)
- How the Pest guard reports actionable failures
### Phase 1 — Design (outputs: data-model.md, contracts/*, quickstart.md)
Design artifacts:
- Data model for declarations, slots, exemptions, and profiles
- JSON schema contract for the declaration payload (for future tooling)
- Quickstart explaining how to add a declaration + how to run the guard
### Phase 2 — Planning (output: tasks.md later via /speckit.tasks)
Implementation plan will include:
- New support classes under `app/Support/Ui/ActionSurface/**`
- Pest guard test under `tests/Feature/Guards/**`
- Minimal retrofit for existing “bulk none” surfaces via declarations + targeted exemptions (repo-green first)
- Representative runtime assertions for action grouping, confirmation, and canonical run links
## Project Structure
### Documentation (this feature)
```text
specs/082-action-surface-contract/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Resources/
│ ├── Pages/
│ └── System/ # out-of-scope for this features validator
├── Providers/Filament/ # panel providers (tenant/admin/system)
└── Support/
├── Rbac/ # UiEnforcement + WorkspaceUiEnforcement (existing)
└── Ui/ActionSurface/ # NEW: profiles, slots, declarations, validator
tests/
└── Feature/
└── Guards/ # NEW: Action surface contract CI gate
```
**Structure Decision**: Laravel monolith. Action-surface enforcement ships as support-domain classes + a Pest guard.
## Complexity Tracking
No constitution violations required.

View File

@ -0,0 +1,34 @@
# Quickstart — Action Surface Contract
This quickstart describes how developers will satisfy the Spec 082 contract once the validator is implemented.
## Add a declaration to a Filament component
For an in-scope class (Resource, Page, RelationManager), add a static declaration method.
Example (shape only; exact namespaces/classes defined in implementation):
- Add `public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration`
- Select a `profile`
- Declare required slots as satisfied
- Keep defaults aligned (group label `More`)
## Exempt a slot
If a required slot is intentionally not present:
- Mark the slot as `Exempt`
- Add an exemption entry with a non-empty `reason`
- Optionally provide a `trackingRef`
## Run locally
- Run the contract guard test:
- `vendor/bin/sail artisan test --compact --filter=ActionSurfaceContract`
## Interpreting failures
Failures will point to:
- the class missing a declaration
- which required slots are missing
- which exemptions are invalid (missing/empty reason)
The failure output is intended to be actionable, similar to existing guard tests in `tests/Feature/Guards/*`.

View File

@ -0,0 +1,104 @@
# Research — Action Surface Contract + CI Enforcement
This document resolves planning unknowns for Spec 082 and records key design decisions.
## Decision 1 — Scope enumeration (Tenant + Admin panels; exclude Widgets)
**Decision**: The validator enumerates in-scope Filament classes by scanning `app/Filament/**` for Resources, Pages, and RelationManagers and then filtering to:
- `app/Filament/Resources/**` (tenant panel discovery)
- `app/Filament/Pages/**` (tenant panel discovery)
- `app/Filament/RelationManagers/**` (used by resources)
- plus any admin-panel explicit resources/pages registered via `app/Providers/Filament/AdminPanelProvider.php`
Widgets are explicitly excluded.
**Rationale**:
- Matches clarified spec scope (Tenant + Admin panels; Resources/Pages/RelationManagers; exclude Widgets).
- Deterministic and fast (filesystem + reflection; no UI runtime required).
- Aligns with existing repo patterns (guard tests that scan filesystem and fail with actionable output).
**Alternatives considered**:
- Discovering via Filament panel runtime discovery: rejected (adds runtime coupling and can be non-deterministic in CI).
- Enumerating only by filesystem: accepted as baseline, but we still need a small allowlist for admin explicit registrations.
## Decision 2 — Declaration attachment mechanism
**Decision**: Each in-scope class provides a declaration via a static method (e.g., `public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration`).
**Rationale**:
- PHP-first, simple, no attribute parsing or config conventions.
- Easy to require/validate by reflection.
- Plays well with existing Filament static patterns (Resources already use statics like `$recordTitleAttribute`).
**Alternatives considered**:
- PHP 8 attributes: rejected for now (slower adoption, harder to keep structured without additional tooling).
- Central registry config: rejected (drifts away from the component, discourages ownership).
## Decision 3 — Contract enforcement: “declare or exempt” + representative runtime checks
**Decision**: CI enforces that each in-scope class has a declaration and that every required slot is either marked satisfied or has an explicit exemption with a non-empty reason.
The validator does not attempt to prove that every underlying Filament `table()` / `getHeaderActions()` actually contains those actions. Instead, representative runtime tests cover constitution-critical behavior (grouping, confirmation, RBAC semantics, canonical run links).
**Rationale**:
- Deterministic and robust across Filament internal changes.
- Avoids brittle source parsing and avoids needing Livewire/Filament runtime contexts.
- Still forces explicit human intent on each surface, which is the governance goal.
**Alternatives considered**:
- AST/static parsing of PHP to verify `->actions()` / `->bulkActions()` usage: rejected (high complexity and brittleness).
- Instantiating Filament objects and introspecting action definitions for every surface in CI: rejected initially (risk of boot/DI coupling). Representative runtime tests are used instead for critical guarantees.
## Decision 4 — Profiles and required “slots”
**Decision**: Model “profiles” that map to component archetypes (e.g., list-only, list+detail, read-only list, run-log list). Each profile defines required slots:
- List header actions
- List row actions (max 2 visible; rest in “More”)
- List bulk actions (grouped; “More” label)
- List empty-state actions
- Detail header actions (if the component has a detail view/edit/view page)
**Rationale**:
- Keeps enforcement simple and consistent.
- Allows targeted exceptions by profile without exploding per-component logic.
**Alternatives considered**:
- One giant universal profile: rejected (too many exemptions, low signal).
## Decision 5 — Exemption policy
**Decision**: Exemptions require:
- a non-empty reason string
- optional tracking reference (issue/PR link)
- no deadline requirement
**Rationale**:
- Matches clarified spec.
- Keeps CI deterministic (no time-based expiry).
**Alternatives considered**:
- Required expiry dates: rejected (creates “time bombs” in CI).
## Decision 6 — Standard grouping label and safe-default Export
**Decision**:
- The canonical group label for secondary row actions and bulk action groups is `More`.
- For `ReadOnly` + `RunLog` profiles, the default expected safe bulk action is `Export` (otherwise exemption required).
**Rationale**:
- Matches clarified spec.
- Provides a consistent, predictable UX baseline.
**Alternatives considered**:
- Allowing arbitrary labels: rejected (inconsistent UX; harder to audit).
## Decision 7 — Typed confirmation threshold
**Decision**: The contract model includes a `requiresTypedConfirmation` flag for actions that are destructive/irreversible, affect > 25 records, or change access/credentials/safety gates.
**Rationale**:
- Matches clarified spec.
- Keeps enforcement declarative and reviewable.
**Alternatives considered**:
- Always requiring typed confirmations: rejected (too heavy for routine admin workflows).

View File

@ -0,0 +1,191 @@
# Feature Specification: Action Surface Contract + CI Enforcement
**Feature Branch**: `082-action-surface-contract`
**Created**: 2026-02-08
**Status**: Draft
**Input**: User description: "Spec 082 — Action Surface Contract + CI Enforcement"
## Clarifications
### Session 2026-02-08
- Q: Group label standard: “More” vs “Actions” → A: Use “More” as the standard label for secondary row actions and bulk action groups.
- Q: Standard safe bulk action for ReadOnly/RunLog: allow Export everywhere? → A: Yes — Export is the default safe bulk action for ReadOnly and RunLog surfaces when data exists; otherwise an explicit exemption with reason is required.
- Q: Exemption policy: time-boxed vs reason-only? → A: Exemptions must include a non-empty reason; an optional tracking link/reference may be included; no deadline is required.
- Q: Audit policy: destructive-only vs all mutations? → A: Audit events are required for all mutations and for operation-start triggers (run/sync/verify/dispatch).
- Q: Validator scope/discovery: which components are in-scope for CI enforcement? → A: Tenant + Admin panels; validate Resources, Pages, and embedded relationship sub-lists; exclude Widgets.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Prevent incomplete UI action surfaces (Priority: P1)
As a developer, when I add or modify an admin-console UI component (list, detail, embedded relationship sub-list), I must explicitly declare its expected action surface (or explicitly exempt parts with a reason), so that automated checks prevent regressions before merge.
**Why this priority**: This is the regression gate. Without it, incomplete action surfaces ship repeatedly and create inconsistent UX and RBAC drift.
**Independent Test**: Add a minimal new UI component without an action surface declaration and confirm the automated gate fails with a precise message; then add a declaration (or exemption) and confirm the gate passes.
**Acceptance Scenarios**:
1. **Given** a new or modified in-scope UI component without an action surface declaration, **When** validation runs, **Then** it fails with a message naming the component and the missing required slots.
2. **Given** a component with a complete declaration (or exemptions with reasons), **When** validation runs, **Then** it passes.
3. **Given** a component uses an exemption, **When** validation runs, **Then** it fails if the exemption reason is missing or empty.
---
### User Story 2 - Consistent, enterprise-grade actions and empty-state guidance (Priority: P2)
As an admin-console user, I see predictable and consistent actions across lists and details: primary actions are obvious, secondary actions are grouped, bulk actions exist where appropriate, and empty states provide an actionable next step.
**Why this priority**: Reduces training cost and operational mistakes; improves speed and confidence in admin workflows.
**Independent Test**: Pick one CRUD-style list and one run-log style list; verify each has the expected action areas (header/row/bulk/empty-state; detail header actions) and that ordering/grouping conventions are consistent.
**Acceptance Scenarios**:
1. **Given** a list view with records, **When** a user inspects available actions, **Then** they see at most two visible row actions and all secondary actions are grouped under a consistent label.
2. **Given** a list view with zero records, **When** the page is shown, **Then** it includes at least one CTA that helps resolve the empty state.
3. **Given** a list view that supports selection, **When** the user selects one or more records, **Then** at least one bulk action is available or the UI explicitly documents why bulk is intentionally not offered.
---
### User Story 3 - Predictable RBAC behavior for members vs non-members (Priority: P3)
As a security reviewer, I need consistent authorization semantics across all in-scope actions:
- A non-member must not discover tenant/workspace surfaces (deny-as-not-found).
- A member without the required capability must be prevented from completing the action (deny-as-forbidden), and the UI must communicate why.
**Why this priority**: Prevents information disclosure and capability drift; keeps the product aligned with least privilege.
**Independent Test**: Use a representative surface and verify (a) non-member receives not-found, and (b) member without capability receives forbidden and sees disabled UI with a helpful explanation.
**Acceptance Scenarios**:
1. **Given** a user who is not a member of the relevant tenant/workspace scope, **When** they attempt to access a related page or record, **Then** the response is not-found.
2. **Given** a user who is a member but lacks capability for a specific action, **When** they attempt to execute the action, **Then** the response is forbidden.
3. **Given** a member without capability viewing the UI, **When** they inspect the action surface, **Then** disallowed actions are visible-but-disabled with a clear explanation (not silently missing).
### Edge Cases
- A surface is intentionally read-only and has no meaningful bulk actions (must be explicitly exempted with a reason).
- A user loses membership/capability between page load and action execution (server-side enforcement must still apply).
- Bulk mutations above a risk threshold must require stronger confirmation (typed confirmation) to reduce accidental damage.
- Empty states that cannot be resolved from within the current surface (CTA should route users to the correct “next step” surface).
- Global search and cross-scope links must not reveal existence of tenant/workspace data to non-members.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature is primarily a UI governance and authorization-consistency feature. It MUST not introduce new external calls or long-running operations as part of the enforcement gate.
**Constitution alignment (RBAC-UX):** This feature formalizes 404 vs 403 semantics and requires both UI signaling and server-side enforcement for in-scope actions.
**Constitution alignment (Action Surfaces):** This feature defines and enforces an Action Surface Contract, and introduces a mandatory UI Action Matrix for future admin UI-related specs.
### Functional Requirements
- **FR-001 (Contract profiles)**: The system MUST define a small set of action-surface profiles (e.g., CRUD, ReadOnly, RunLog, embedded relationship sub-lists) that describe minimum expected action areas for list and detail surfaces.
- **FR-002 (Required action slots)**: For each profile, the system MUST define required action slots for:
- list/table header actions,
- row actions,
- bulk actions,
- empty-state CTA(s),
- detail header actions,
- create/edit save/cancel conventions (where applicable).
- **FR-003 (Exemptions)**: The system MUST allow explicit exemptions for specific slots, and each exemption MUST include:
- a non-empty reason, and
- an optional tracking reference (issue/PR link or identifier).
- **FR-004 (Mandatory declaration)**: For every in-scope admin-console UI component that is new or modified, a declaration MUST exist that:
- identifies the profile,
- declares which slots are satisfied,
- lists any exemptions with reasons.
- **FR-005 (Automated gate)**: The system MUST automatically validate contract compliance for all in-scope UI components in the code review pipeline, and MUST fail with actionable messages when requirements are not met.
- **FR-006 (Consistency rules)**: The system MUST enforce the following UX conventions through (a) declaration validation for all in-scope surfaces and (b) runtime tests on representative surfaces:
- no more than two visible row actions (typically View/Edit),
- secondary actions grouped under the standard label “More”,
- bulk actions grouped under the standard label “More”,
- destructive actions are never primary.
- **FR-006a (Default safe bulk action)**: For ReadOnly and RunLog profiles, the default bulk action MUST be “Export” when the surface contains data; if “Export” is not offered, an explicit exemption with a reason is required.
- **FR-007 (Safety + confirmation)**: Any mutating or destructive action MUST require confirmation. For this feature, compliance is enforced via declaration checks plus representative runtime tests for touched surfaces.
- Mutation classes for FR-007:
- data mutation: create/update/delete/archive/restore/attach/detach,
- operation-start trigger: enqueue/dispatch/run/sync/verify/retry actions,
- access/safety mutation: role or membership changes, credential or permission updates, safety gate toggles.
- High-risk bulk actions MUST require typed confirmation when any of the following is true:
- the action is destructive/irreversible, or
- the action affects more than 25 records, or
- the action changes tenant/workspace access, credentials, or operational safety gates.
- **FR-008 (Auditability)**: All mutations and operation-start triggers MUST use existing audit sinks and MUST write an audit event that records at minimum: actor, scope (tenant/workspace), action type, target(s), timestamp, and outcome.
- Audit sinks for FR-008:
- tenant-scoped actions: `App\Services\Intune\AuditLogger` (writes `audit_logs`),
- workspace/platform-scoped actions: `App\Services\Audit\WorkspaceAuditLogger` (writes `audit_logs`).
- Operation-start triggers MAY satisfy FR-008 via canonical `OperationRun` creation when the action is run-oriented and existing architecture uses `OperationRun` as the observability record.
- For this feature, FR-008 is enforced as: no regressions on touched representative surfaces and no new ad-hoc audit sink introduced.
- **FR-009 (RBAC UI gating standard)**: UI gating MUST follow the projects standard semantics:
- non-member → deny-as-not-found behavior,
- member without capability → deny-as-forbidden behavior,
- UI communicates “forbidden” as visible-but-disabled actions with a tooltip/explanation.
- **FR-010 (Server-side authorization)**: Server-side authorization MUST be enforced for every action execution (not just UI visibility) and MUST align with FR-009 semantics (see constitution RBAC-UX-001..004).
- **FR-011 (Canonical run links)**: Any “View run” deep link MUST use the canonical tenantless operations path as the primary link; tenant-scoped convenience links may exist but must not be primary.
- **FR-012 (Scope)**: The contract MUST cover both the tenant-scoped admin console and the platform/admin console surfaces for resources, pages, and embedded relationship sub-lists.
- **FR-012a (Explicit CI scope)**: The automated gate MUST validate Resources, Pages, and embedded relationship sub-lists in both tenant-scoped and platform/admin consoles, and MUST exclude Widgets from enforcement.
### Non-Goals
- This feature does not redesign visual layout or page styling.
- This feature does not change domain workflows (sync/restore/verify), except to ensure action surfaces are complete or explicitly exempted.
- This feature does not change panel architecture or audience boundaries.
### Assumptions
- A safe, broadly applicable bulk action exists for read-only/run-log surfaces (“Export”) where data volume and permissions allow.
- Some existing surfaces will require temporary exemptions to keep the repository in a releasable state while retrofit work is completed.
### Enforcement Boundaries
- The CI validator for this feature is declaration-driven and deterministic (filesystem + reflection only).
- Runtime behavior is verified via representative tests for each profile and each critical rule family (grouping, confirmation, RBAC semantics, canonical run links).
- Deep runtime introspection of every Filament action in CI is explicitly out of scope for this iteration.
## UI Action Matrix *(mandatory when admin UI components are changed)*
_Note: This matrix applies whenever admin UI components (resources, pages, or embedded relationship sub-lists) are added or changed._
This feature introduces a contract that applies broadly. The matrix below documents the minimum action surface expectations by profile.
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|
| CRUD list + detail | Tenant panel + Admin panel | Primary: Create (if allowed). Optional: domain CTA (e.g., Sync/Run) | Primary: View, Edit (if allowed). Secondary: grouped under “More” | At least 1 bulk action (e.g., Archive/Restore/Export) | Primary CTA: Create or domain CTA | Primary: Edit (if allowed). Secondary grouped under “More” | Consistent Save + Cancel; no destructive primary | Yes (for mutations + operation-start) | Exempt bulk only with explicit reason (e.g., no safe bulk operation exists) |
| ReadOnly list + detail | Tenant panel + Admin panel | At least 1 CTA that provides value (e.g., Export/Refresh) | Primary: View. Secondary grouped under “More” | Bulk: Export (default) | CTA that resolves empty state (e.g., Refresh now) | “More” group for secondary actions | N/A | Maybe (typically for operation-start actions) | Exempt Export only with explicit reason (e.g., legal/security constraints) |
| RunLog list + detail | Tenant panel + Admin panel | Optional CTA routing to Operations hub | Primary: View | Bulk: Export (default) or Prune if retention exists; otherwise exempt with reason | Optional CTA routing to Operations hub | “View run” canonical link; optional Export | N/A | Maybe (for prune/retention changes) | “View run” must be canonical tenantless as primary deep link |
| Embedded relationship sub-list | Under parent detail | Header: Add/Attach/Create/Refresh depending on context | Primary: View and one context action (e.g., Detach/Remove). Secondary grouped under “More” | At least 1 bulk action or exemption with reason | CTA: next step (Add/Attach/Refresh) | N/A | N/A | Yes (for mutations) | Bulk may be exempted if relationship action is inherently single-record |
### Key Entities *(include if feature involves data)*
- **Action Surface Declaration**: A structured, human-reviewed statement of what actions a surface offers (and why some may be exempt).
- **Action Surface Profile**: A category of surfaces with shared minimum action expectations (CRUD, ReadOnly, RunLog, embedded relationship sub-list).
- **Action Slot**: A required location/type of action (header, row, bulk, empty-state, detail header, save/cancel).
- **Exemption**: A documented, explicit deviation from the contract for a specific slot.
- **Capability**: The permission concept used to decide whether an action is allowed.
- **Audit Event**: A record that an action was attempted/executed and its outcome.
### Dependencies
- A single, canonical capability registry exists and is used consistently for authorization decisions.
- Standard UI gating helpers exist for tenant/workspace scopes and are the only allowed approach for UI enable/disable behavior.
- A centralized audit-event sink exists to record user-triggered mutations and operation starts.
### Acceptance Notes (verification approach)
- Contract compliance is verified via an automated validation gate that enumerates in-scope UI components and checks required slots/exemptions.
- Authorization correctness is verified via representative automated tests that prove not-found vs forbidden behavior across at least one surface per profile.
- Consistency/confirmation/canonical-link behavior is verified on representative runtime surfaces; declaration checks remain the repo-wide completeness gate.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001 (Coverage)**: 100% of in-scope UI components are contract-compliant or have explicit exemptions with reasons.
- **SC-002 (Regression prevention)**: Any new/modified in-scope UI component without a declaration fails validation in the review pipeline.
- **SC-003 (Action completeness)**: For all CRUD and ReadOnly list surfaces, the selection state always presents at least one bulk action or an explicit exemption.
- **SC-004 (Authorization correctness)**: At least one representative surface per profile has automated tests proving not-found vs forbidden behavior (non-member vs member without capability).
- **SC-005 (Developer experience)**: Validation failures provide actionable messages that identify the component and missing slot(s), enabling a fix without additional investigation.

View File

@ -0,0 +1,127 @@
---
description: "Task list for Spec 082 implementation"
---
# Tasks: Action Surface Contract + CI Enforcement
**Input**: Design documents from `/specs/082-action-surface-contract/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/, quickstart.md
**Tests**: Required (Pest) — this feature adds/changes CI enforcement.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Introduce the new support namespace and a guard-test entry point.
- [X] T001 Create ActionSurface namespaces in app/Support/Ui/ActionSurface/
- [X] T002 [P] Add guard test skeleton in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T003 [P] Add validator test skeleton in tests/Feature/Guards/ActionSurfaceValidatorTest.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core contract model + discovery + validation that all user stories rely on.
- [X] T004 Create enums for component/profile/slot in app/Support/Ui/ActionSurface/Enums/ActionSurfaceComponentType.php
- [X] T005 [P] Create slot + exemption value objects in app/Support/Ui/ActionSurface/ActionSurfaceExemption.php
- [X] T006 [P] Create declaration + defaults DTOs in app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php
- [X] T007 Implement profile definitions (required slots per profile) in app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php
- [X] T008 Implement in-scope discovery (tenant discovery roots + admin explicit registrations/routes; exclude Widgets and System panel) in app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php
- [X] T009 Implement declaration contract validation (declare-or-exempt; reason required; More label; Export default) in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- [X] T010 Add fast unit-style tests for validator rules in tests/Feature/Guards/ActionSurfaceValidatorTest.php (pure validation logic, no DB or Filament boot)
**Checkpoint**: Validator can enumerate + validate in-scope components deterministically.
---
## Phase 3: User Story 1 — Prevent incomplete UI action surfaces (Priority: P1) 🎯 MVP
**Goal**: CI fails if an in-scope Filament component lacks a declaration (or an explicit exemption with a non-empty reason).
**Independent Test**: Add a new in-scope Filament component without a declaration; run the guard and confirm it fails with an actionable message; add a declaration/exemption and confirm it passes.
- [X] T011 [US1] Implement the CI guard that runs validation in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T012 [US1] Add baseline exemptions registry in app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php (reason required, explicit class allowlist with shrink-over-time rule)
- [X] T013 [US1] Wire exemptions into validator output in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- [X] T014 [US1] Make guard output actionable (class + slot + hint) in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T015 [US1] Add tests proving widgets are excluded from scope in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T031 [US1] Add tests proving unknown classes are not auto-exempted and every exemption reason is non-empty in tests/Feature/Guards/ActionSurfaceContractTest.php
**Checkpoint**: Guard passes on main branch state, but blocks missing declarations going forward.
---
## Phase 4: User Story 2 — Consistent, enterprise-grade actions and empty-state guidance (Priority: P2)
**Goal**: Provide consistent action-surface conventions (More grouping, Export default for RunLog/ReadOnly, empty-state CTA) and demonstrate them on representative surfaces.
**Independent Test**: Pick one CRUD-style list and one run-log style list; verify each has expected action areas (header/row/bulk/empty-state; detail header actions) and consistent ordering/grouping.
- [X] T016 [P] [US2] Define required slots for CRUD/ReadOnly/RunLog/RelationManager profiles in app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php
- [X] T017 [P] [US2] Enforce defaults: More label + Export expectation for ReadOnly/RunLog in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- [X] T018 [US2] Add a declaration to a representative CRUD surface in app/Filament/Resources/PolicyResource.php
- [X] T019 [US2] Ensure list empty-state CTA exists for the CRUD surface in app/Filament/Resources/PolicyResource/Pages/ListPolicies.php
- [X] T020 [US2] Add a declaration to a representative RunLog surface in app/Filament/Resources/OperationRunResource.php
- [X] T021 [US2] Ensure Export bulk action is present (or exempted with reason) for the RunLog surface in app/Filament/Resources/OperationRunResource.php
- [X] T022 [US2] Add a declaration to one embedded relationship sub-list in app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php
- [X] T023 [US2] Add tests asserting the representative declarations satisfy required slots in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T032 [US2] Add representative runtime test asserting PolicyResource row/bulk grouping uses “More” conventions in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T033 [US2] Add representative runtime test asserting canonical tenantless “View run” link usage in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T035 [US2] Add golden regression tests for “view-only lists use clickable rows without row actions” in tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T036 [US2] Document action-surface contract rules (including “no lone View button; prefer clickable rows”) in docs/ui/action-surface-contract.md
**Checkpoint**: At least one CRUD + one RunLog + one embedded sub-list visibly follow the conventions.
---
## Phase 5: User Story 3 — Predictable RBAC behavior for members vs non-members (Priority: P3)
**Goal**: Validate consistent 404 vs 403 semantics and disabled-UI behavior for capability-gated actions.
**Independent Test**: Use a representative surface and verify (a) non-member receives not-found, and (b) member without capability receives forbidden and sees disabled UI with a helpful explanation.
- [X] T024 [P] [US3] Add non-member 404 route test on representative operations detail route in tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
- [X] T025 [US3] Add member-without-capability 403 server-side test by attempting to execute ListEntraGroupSyncRuns::sync_groups as a readonly member in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
- [X] T026 [US3] In that 403 test, assert no EntraGroupSyncJob is pushed and no EntraGroupSyncRun is created for the tenant in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
- [X] T027 [US3] Ensure disabled-UI semantics are covered for the same action (visible + disabled + standard tooltip) in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
- [X] T034 [US3] Add representative observability/audit regression assertion for an operation-start action (OperationRun record present with scope + actor) in tests/Feature/Guards/ActionSurfaceContractTest.php
**Checkpoint**: One surface per profile has coverage for 404 vs 403 semantics.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T028 [P] Run formatter on changed files via vendor/bin/sail bin pint --dirty
- [X] T029 Run focused guard tests via vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
- [X] T030 Run RBAC semantics test via vendor/bin/sail artisan test --compact tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
---
## Dependencies & Execution Order
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2/US3 (Phases 45 can proceed in parallel) → Polish (Phase 6)
## Parallel Opportunities
- Phase 2: DTOs/enums (T004T006) can be done in parallel.
- US2: Profile definition + validator rules (T016T017) can be done in parallel.
- US3: Test scaffolding (T024) can be done in parallel with US2 retrofit tasks.
## Parallel Example: US2
- T018 (PolicyResource declaration) can be implemented in parallel with T020 (OperationRunResource declaration).
## Implementation Strategy
### MVP First (US1 only)
1. Complete Phase 1 + Phase 2.
2. Implement Phase 3 (US1) guard + exemptions.
3. Validate guard behavior by adding/removing a declaration locally.
### Incremental Delivery
- Add US2 representative surfaces next (PolicyResource + OperationRunResource + one RelationManager).
- Add US3 RBAC semantics tests last (keeps scope tight while contract infrastructure stabilizes).

View File

@ -13,6 +13,7 @@
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -98,6 +99,41 @@
->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); ->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
}); });
test('sync groups action is forbidden for readonly members when disabled check is bypassed', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListEntraGroupSyncRuns::class)->instance();
$action = $component->getAction([['name' => 'sync_groups']]);
expect($action)->not->toBeNull();
$thrown = null;
try {
$action->callBefore();
$action->call();
} catch (HttpException $exception) {
$thrown = $exception;
}
expect($thrown)->not->toBeNull();
expect($thrown?->getStatusCode())->toBe(403);
Queue::assertNothingPushed();
$runCount = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
expect($runCount)->toBe(0);
});
test('sync groups action is disabled for readonly users with standard tooltip', function () { test('sync groups action is disabled for readonly users with standard tooltip', function () {
Queue::fake(); Queue::fake();

View File

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\InventorySyncRunResource;
use App\Filament\Resources\InventorySyncRunResource\Pages\ListInventorySyncRuns;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\SyncPoliciesJob;
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('passes the action surface contract guard for current repository state', function (): void {
$result = ActionSurfaceValidator::withBaselineExemptions()->validate();
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('excludes widgets from action surface discovery scope', function (): void {
$classes = array_map(
static fn ($component): string => $component->className,
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
);
$widgetClasses = array_values(array_filter($classes, static function (string $className): bool {
return str_starts_with($className, 'App\\Filament\\Widgets\\');
}));
expect($widgetClasses)->toBeEmpty();
});
it('keeps baseline exemptions explicit and does not auto-exempt unknown classes', function (): void {
$exemptions = ActionSurfaceExemptions::baseline();
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
});
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$tenantResource = $components->get(\App\Filament\Resources\TenantResource::class);
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
expect($tenantResource)->not->toBeNull();
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
expect($policyResource)->not->toBeNull();
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::Tenant))->toBeTrue();
});
it('requires non-empty reasons for every baseline exemption', function (): void {
$reasons = ActionSurfaceExemptions::baseline()->all();
foreach ($reasons as $className => $reason) {
expect(trim($reason))->not->toBe('', "Baseline exemption reason is empty for {$className}");
}
});
it('ensures representative declarations satisfy required slots', function (): void {
$profiles = new ActionSurfaceProfileDefinition;
$declarations = [
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
];
foreach ($declarations as $className => $declaration) {
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
expect($declaration->slot($slot))
->not->toBeNull("Missing required slot {$slot->value} in declaration for {$className}");
}
}
});
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$livewire = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync']);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$rowGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
expect($rowGroup)->toBeInstanceOf(ActionGroup::class);
expect($rowGroup?->getLabel())->toBe('More');
$primaryRowActionCount = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->count();
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
$bulkActions = $table->getBulkActions();
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class);
expect($bulkGroup?->getLabel())->toBe('More');
});
it('uses canonical tenantless View run links on representative operation links', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
]);
expect(OperationRunLinks::view($run, $tenant))
->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()]));
});
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$livewire = Livewire::test(ListInventoryItems::class);
$table = $livewire->instance()->getTable();
expect($table->getActions())->toBeEmpty();
$recordUrl = $table->getRecordUrl($item);
expect($recordUrl)->not->toBeNull();
expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item]));
});
it('removes lone View buttons and uses clickable rows on the inventory sync runs list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$livewire = Livewire::test(ListInventorySyncRuns::class);
$table = $livewire->instance()->getTable();
expect($table->getActions())->toBeEmpty();
$recordUrl = $table->getRecordUrl($run);
expect($recordUrl)->not->toBeNull();
expect($recordUrl)->toBe(InventorySyncRunResource::getUrl('view', ['record' => $run]));
});
it('keeps representative operation-start actions observable with actor and scope metadata', function (): void {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(SyncPoliciesJob::class);
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect((int) $run?->tenant_id)->toBe((int) $tenant->getKey());
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
expect((string) $run?->initiator_name)->toBe((string) $user->name);
});

View File

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceSlotRequirement;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidatorCompleteStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorMissingSlotStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value);
}
}
final class ActionSurfaceValidatorRunLogNoExportStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorNoDeclarationStub {}
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
{
return new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Resource,
panelScopes: [ActionSurfacePanelScope::Tenant],
);
}
it('passes when all required slots are declared', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorCompleteStub::class)]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('fails when a required slot is missing', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSlotStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Required slot is not declared');
});
it('fails missing declarations when no baseline exemption exists', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
});
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([
ActionSurfaceValidatorNoDeclarationStub::class => 'Retrofit intentionally deferred in validator unit test.',
]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('requires a bulk exemption reason when run-log export default is disabled', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorRunLogNoExportStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('ReadOnly/RunLog profile disables Export default');
});
it('fails when slot exemption reason is missing', function (): void {
$validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: new ActionSurfaceExemptions([]),
);
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorExemptSlotWithoutReasonStub::class)]);
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('returns 404 for non-members on representative action-surface route', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
]);
$runB = OperationRun::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $workspaceB->getKey(),
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
->assertNotFound();
});