Compare commits

...

3 Commits

Author SHA1 Message Date
a2a42d4e5f Spec 196: finalize hard Filament nativity cleanup artifacts (#231)
## Summary
- add the complete Spec 196 artifact set for hard Filament nativity cleanup
- include spec, requirements checklist, plan, research, data model, logical contract, quickstart, and executable tasks
- update agent context after planning
- resolve all cross-artifact consistency issues so the feature package is implementation-ready

## Included artifacts
- specs/196-hard-filament-nativity-cleanup/spec.md
- specs/196-hard-filament-nativity-cleanup/checklists/requirements.md
- specs/196-hard-filament-nativity-cleanup/plan.md
- specs/196-hard-filament-nativity-cleanup/research.md
- specs/196-hard-filament-nativity-cleanup/data-model.md
- specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml
- specs/196-hard-filament-nativity-cleanup/quickstart.md
- specs/196-hard-filament-nativity-cleanup/tasks.md

## Notes
- no runtime code paths were changed
- no application tests were run because this change set is spec and planning documentation only
- the artifact set was re-analyzed until no consistency issues remained

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #231
2026-04-13 10:26:27 +00:00
1c291fb9fe feat: close spec 195 action surface residuals (#230)
## Summary
- add the full Spec 195 residual action-surface design package under `specs/195-action-surface-closure`
- implement residual surface inventory and validator enforcement for uncatalogued system and special Filament pages
- add focused regression coverage for residual guards, system directory pages, managed-tenants landing, and readonly register-tenant / tenant-dashboard access
- fix the system workspace detail surface by loading tenant route keys and disabling lazy system database notifications to avoid the Livewire 404 on `/system/directory/workspaces/{workspace}`

## Testing
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/Filament/DatabaseNotificationsPollingTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- branch: `195-action-surface-closure`
- target: `dev`
- no new assets, migrations, or provider-registration changes

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #230
2026-04-13 07:47:58 +00:00
acc8947384 feat: harden governance action semantics (#229)
## Summary
- add the Spec 194 governance action catalog, friction classes, reason policies, and regression guards
- align exception, review, evidence, finding, tenant, provider connection, and system run actions to the shared semantics model
- add focused feature, RBAC, audit, unit, and browser coverage, including the tenant detail triage header consistency update

## Verification
- ran the focused Spec 194 verification pack from the quickstart and task plan
- ran targeted tenant triage coverage after the detail-header update
- ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Filament Notes
- Filament v5 / Livewire v4 compliance preserved
- provider registration remains in `apps/platform/bootstrap/providers.php`
- globally searchable resources were not changed
- destructive actions remain confirmation-gated and server-authorized
- no new Filament assets were introduced; the existing `cd apps/platform && php artisan filament:assets` deploy step stays unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #229
2026-04-12 21:21:44 +00:00
102 changed files with 9977 additions and 2296 deletions

View File

@ -173,6 +173,11 @@ ## Active Technologies
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline) - PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy)
- PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy) - PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`) (194-governance-friction-hardening)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers (195-action-surface-closure)
- PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned (195-action-surface-closure)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers (196-hard-filament-nativity-cleanup)
- PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned (196-hard-filament-nativity-cleanup)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -207,8 +212,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 193-monitoring-action-hierarchy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders - 196-hard-filament-nativity-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers
- 192-record-header-discipline: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders - 195-action-surface-closure: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns - 195-action-surface-closure: Added PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -289,7 +289,9 @@ public function refreshMatrix(): void
{ {
$user = auth()->user(); $user = auth()->user();
abort_unless($user instanceof User, 403); if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */ /** @var BaselineProfile $profile */
$profile = $this->getRecord(); $profile = $this->getRecord();

View File

@ -25,6 +25,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -191,10 +192,12 @@ protected function getHeaderActions(): array
$selectedDecisionActions = [ $selectedDecisionActions = [
Action::make('approve_selected_exception') Action::make('approve_selected_exception')
->label('Approve exception') ->label(GovernanceActionCatalog::rule('approve_exception')->canonicalLabel)
->color('success') ->color('success')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false) ->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('approve_exception')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('approve_exception')->modalDescription)
->form([ ->form([
DateTimePicker::make('effective_from') DateTimePicker::make('effective_from')
->label('Effective from') ->label('Effective from')
@ -207,6 +210,7 @@ protected function getHeaderActions(): array
Textarea::make('approval_reason') Textarea::make('approval_reason')
->label('Approval reason') ->label('Approval reason')
->rows(3) ->rows(3)
->required()
->maxLength(2000), ->maxLength(2000),
]) ])
->action(function (array $data, FindingExceptionService $service): void { ->action(function (array $data, FindingExceptionService $service): void {
@ -223,16 +227,18 @@ protected function getHeaderActions(): array
$this->resetTable(); $this->resetTable();
Notification::make() Notification::make()
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved') ->title($wasRenewalRequest ? 'Exception renewed' : GovernanceActionCatalog::rule('approve_exception')->successTitle)
->success() ->success()
->send(); ->send();
}), }),
Action::make('reject_selected_exception') Action::make('reject_selected_exception')
->label('Reject exception') ->label(GovernanceActionCatalog::rule('reject_exception')->canonicalLabel)
->color('danger') ->color('warning')
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false) ->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('reject_exception')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('reject_exception')->modalDescription)
->form([ ->form([
Textarea::make('rejection_reason') Textarea::make('rejection_reason')
->label('Rejection reason') ->label('Rejection reason')
@ -254,7 +260,7 @@ protected function getHeaderActions(): array
$this->resetTable(); $this->resetTable();
Notification::make() Notification::make()
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected') ->title($wasRenewalRequest ? 'Renewal rejected' : GovernanceActionCatalog::rule('reject_exception')->successTitle)
->success() ->success()
->send(); ->send();
}), }),

View File

@ -69,7 +69,7 @@ private function captureAction(): Action
->label($label) ->label($label)
->icon('heroicon-o-camera') ->icon('heroicon-o-camera')
->color('primary') ->color('primary')
->hidden(fn (): bool => $this->profileHasConsumableSnapshot()) ->hidden(fn (): bool => $this->shouldHideCaptureAction())
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($label) ->modalHeading($label)
->modalDescription($modalDescription) ->modalDescription($modalDescription)
@ -469,6 +469,15 @@ private function profileHasConsumableSnapshot(): bool
return $profile->resolveCurrentConsumableSnapshot() !== null; return $profile->resolveCurrentConsumableSnapshot() !== null;
} }
private function shouldHideCaptureAction(): bool
{
if (! $this->profileHasConsumableSnapshot()) {
return false;
}
return $this->getEligibleCompareTenantOptions() !== [];
}
private function compareAssignedTenantsDisabledReason(): ?string private function compareAssignedTenantsDisabledReason(): ?string
{ {
/** @var BaselineProfile $profile */ /** @var BaselineProfile $profile */

View File

@ -31,11 +31,13 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Textarea;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry; use Filament\Infolists\Components\ViewEntry;
@ -314,21 +316,36 @@ public static function table(Table $table): Table
Actions\ActionGroup::make([ Actions\ActionGroup::make([
UiEnforcement::forTableAction( UiEnforcement::forTableAction(
Actions\Action::make('expire') Actions\Action::make('expire')
->label('Expire snapshot') ->label(GovernanceActionCatalog::rule('expire_snapshot')->canonicalLabel)
->color('danger') ->color('danger')
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record)) ->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
->requiresConfirmation() ->requiresConfirmation()
->action(function (EvidenceSnapshot $record): void { ->modalHeading(GovernanceActionCatalog::rule('expire_snapshot')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('expire_snapshot')->modalDescription)
->form([
Textarea::make('expiration_reason')
->label('Expiry reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (EvidenceSnapshot $record, array $data): void {
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
} }
app(EvidenceSnapshotService::class)->expire($record, $user); app(EvidenceSnapshotService::class)->expire(
$record,
$user,
(string) ($data['expiration_reason'] ?? ''),
);
static::truthEnvelope($record->refresh(), fresh: true); static::truthEnvelope($record->refresh(), fresh: true);
Notification::make()->success()->title('Snapshot expired')->send(); Notification::make()->success()->title(
GovernanceActionCatalog::rule('expire_snapshot')->successTitle,
)->send();
}), }),
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record, fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
) )

View File

@ -9,7 +9,9 @@
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -25,14 +27,19 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
return [ return [
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('refresh_snapshot') Actions\Action::make('refresh_evidence')
->label('Refresh evidence') ->label($refreshRule->canonicalLabel)
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->modalHeading($refreshRule->modalHeading)
->modalDescription($refreshRule->modalDescription)
->action(function () use ($refreshRule): void {
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
@ -41,29 +48,42 @@ protected function getHeaderActions(): array
app(EvidenceSnapshotService::class)->refresh($this->record, $user); app(EvidenceSnapshotService::class)->refresh($this->record, $user);
Notification::make()->success()->title('Refresh evidence queued')->send(); Notification::make()->success()->title($refreshRule->successTitle)->send();
}), }),
) )
->requireCapability(Capabilities::EVIDENCE_MANAGE) ->requireCapability(Capabilities::EVIDENCE_MANAGE)
->apply(), ->apply(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('expire_snapshot') Actions\Action::make('expire_snapshot')
->label('Expire snapshot') ->label($expireRule->canonicalLabel)
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('danger') ->color('danger')
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record)) ->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->modalHeading($expireRule->modalHeading)
->modalDescription($expireRule->modalDescription)
->form([
Textarea::make('expiration_reason')
->label('Expiry reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (array $data) use ($expireRule): void {
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
} }
app(EvidenceSnapshotService::class)->expire($this->record, $user); app(EvidenceSnapshotService::class)->expire(
$this->record,
$user,
(string) ($data['expiration_reason'] ?? ''),
);
$this->refreshFormData(['status', 'expires_at']); $this->refreshFormData(['status', 'expires_at']);
Notification::make()->success()->title('Snapshot expired')->send(); Notification::make()->success()->title($expireRule->successTitle)->send();
}), }),
) )
->requireCapability(Capabilities::EVIDENCE_MANAGE) ->requireCapability(Capabilities::EVIDENCE_MANAGE)

View File

@ -10,6 +10,7 @@
use App\Models\User; use App\Models\User;
use App\Services\Findings\FindingExceptionService; use App\Services\Findings\FindingExceptionService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Repeater;
@ -32,9 +33,12 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$renewRule = GovernanceActionCatalog::rule('renew_exception');
$revokeRule = GovernanceActionCatalog::rule('revoke_exception');
return [ return [
Action::make('renew_exception') Action::make('renew_exception')
->label('Renew exception') ->label($renewRule->canonicalLabel)
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('primary')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed()) ->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
@ -42,6 +46,8 @@ protected function getHeaderActions(): array
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null, 'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
]) ])
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($renewRule->modalHeading)
->modalDescription($renewRule->modalDescription)
->form([ ->form([
Select::make('owner_user_id') Select::make('owner_user_id')
->label('Owner') ->label('Owner')
@ -84,7 +90,7 @@ protected function getHeaderActions(): array
->defaultItems(0) ->defaultItems(0)
->collapsed(), ->collapsed(),
]) ])
->action(function (array $data, FindingExceptionService $service): void { ->action(function (array $data, FindingExceptionService $service) use ($renewRule): void {
$record = $this->getRecord(); $record = $this->getRecord();
$user = auth()->user(); $user = auth()->user();
@ -105,18 +111,20 @@ protected function getHeaderActions(): array
} }
Notification::make() Notification::make()
->title('Renewal request submitted') ->title($renewRule->successTitle)
->success() ->success()
->send(); ->send();
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']); $this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
}), }),
Action::make('revoke_exception') Action::make('revoke_exception')
->label('Revoke exception') ->label($revokeRule->canonicalLabel)
->icon('heroicon-o-no-symbol') ->icon('heroicon-o-no-symbol')
->color('danger') ->color('danger')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked()) ->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($revokeRule->modalHeading)
->modalDescription($revokeRule->modalDescription)
->form([ ->form([
Textarea::make('revocation_reason') Textarea::make('revocation_reason')
->label('Revocation reason') ->label('Revocation reason')
@ -124,7 +132,7 @@ protected function getHeaderActions(): array
->required() ->required()
->maxLength(2000), ->maxLength(2000),
]) ])
->action(function (array $data, FindingExceptionService $service): void { ->action(function (array $data, FindingExceptionService $service) use ($revokeRule): void {
$record = $this->getRecord(); $record = $this->getRecord();
$user = auth()->user(); $user = auth()->user();
@ -145,7 +153,7 @@ protected function getHeaderActions(): array
} }
Notification::make() Notification::make()
->title('Exception revoked') ->title($revokeRule->successTitle)
->success() ->success()
->send(); ->send();

View File

@ -33,6 +33,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
@ -1121,8 +1122,10 @@ public static function table(Table $table): Table
BulkAction::make('close_selected') BulkAction::make('close_selected')
->label('Close selected') ->label('Close selected')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('danger') ->color('warning')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
->form([ ->form([
Textarea::make('closed_reason') Textarea::make('closed_reason')
->label('Close reason') ->label('Close reason')
@ -1441,13 +1444,17 @@ public static function resolveAction(): Actions\Action
public static function closeAction(): Actions\Action public static function closeAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('close_finding');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('close') Actions\Action::make('close')
->label('Close') ->label($rule->canonicalLabel)
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('danger') ->color('warning')
->visible(fn (Finding $record): bool => $record->hasOpenStatus()) ->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([ ->form([
Textarea::make('closed_reason') Textarea::make('closed_reason')
->label('Close reason') ->label('Close reason')
@ -1455,10 +1462,10 @@ public static function closeAction(): Actions\Action
->required() ->required()
->maxLength(255), ->maxLength(255),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
record: $record, record: $record,
successTitle: 'Finding closed', successTitle: $rule->successTitle,
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close( callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->close(
$finding, $finding,
$tenant, $tenant,
@ -1537,16 +1544,20 @@ public static function requestExceptionAction(): Actions\Action
public static function renewExceptionAction(): Actions\Action public static function renewExceptionAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('renew_exception');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('renew_exception') Actions\Action::make('renew_exception')
->label('Renew exception') ->label($rule->canonicalLabel)
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('primary')
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false) ->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false)
->fillForm(fn (Finding $record): array => [ ->fillForm(fn (Finding $record): array => [
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id, 'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
]) ])
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([ ->form([
Select::make('owner_user_id') Select::make('owner_user_id')
->label('Owner') ->label('Owner')
@ -1601,13 +1612,17 @@ public static function renewExceptionAction(): Actions\Action
public static function revokeExceptionAction(): Actions\Action public static function revokeExceptionAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('revoke_exception');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('revoke_exception') Actions\Action::make('revoke_exception')
->label('Revoke exception') ->label($rule->canonicalLabel)
->icon('heroicon-o-no-symbol') ->icon('heroicon-o-no-symbol')
->color('danger') ->color('danger')
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false) ->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([ ->form([
Textarea::make('revocation_reason') Textarea::make('revocation_reason')
->label('Revocation reason') ->label('Revocation reason')
@ -1627,18 +1642,34 @@ public static function revokeExceptionAction(): Actions\Action
public static function reopenAction(): Actions\Action public static function reopenAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('reopen_finding');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('reopen') Actions\Action::make('reopen')
->label('Reopen') ->label($rule->canonicalLabel)
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->color('warning') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status)) ->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
->action(function (Finding $record, FindingWorkflowService $workflow): void { ->form([
Textarea::make('reopen_reason')
->label('Reopen reason')
->rows(3)
->required()
->maxLength(255),
])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
static::runWorkflowMutation( static::runWorkflowMutation(
record: $record, record: $record,
successTitle: 'Finding reopened', successTitle: $rule->successTitle,
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen($finding, $tenant, $user), callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->reopen(
$finding,
$tenant,
$user,
(string) ($data['reopen_reason'] ?? ''),
),
); );
}) })
) )

View File

@ -199,738 +199,26 @@ protected function getHeaderActions(): array
->tooltip('You do not have permission to view provider connections.') ->tooltip('You do not have permission to view provider connections.')
->preserveVisibility() ->preserveVisibility()
->apply(), ->apply(),
ProviderConnectionResource::makeCheckConnectionAction(),
UiEnforcement::forAction( ProviderConnectionResource::makeInventorySyncAction(),
Action::make('check_connection') ProviderConnectionResource::makeComplianceSnapshotAction(),
->label('Check connection') ProviderConnectionResource::makeSetDefaultAction(),
->icon('heroicon-o-check-badge') ProviderConnectionResource::makeEnableDedicatedOverrideAction(
->color('success') source: 'provider_connection.edit_page',
->visible(function (ProviderConnection $record): bool { modalDescription: 'Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.',
$tenant = $this->currentTenant(); ),
$user = auth()->user(); ProviderConnectionResource::makeRotateDedicatedCredentialAction(
modalDescription: 'Stores a replacement dedicated client secret and refreshes dedicated identity state.',
return $tenant instanceof Tenant ),
&& $user instanceof User ProviderConnectionResource::makeDeleteDedicatedCredentialAction(
&& $user->canAccessTenant($tenant) modalDescription: 'Deletes the dedicated credential and leaves the connection blocked until a replacement is added or the type is reverted.',
&& (bool) $record->is_enabled; ),
}) ProviderConnectionResource::makeRevertToPlatformAction(
->action(function (ProviderConnection $record, StartVerification $verification): void { source: 'provider_connection.edit_page',
$tenant = $this->currentTenant(); modalDescription: 'Reverts the connection to the platform-managed identity and removes any dedicated credential.',
$user = auth()->user(); ),
ProviderConnectionResource::makeEnableConnectionAction(),
if (! $tenant instanceof Tenant) { ProviderConnectionResource::makeDisableConnectionAction(),
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $verification->providerConnectionCheck(
tenant: $tenant,
connection: $record,
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope busy')
->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Action::make('manage_connections')
->label('Manage Provider Connections')
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Action::make('manage_connections')
->label('Manage Provider Connections')
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Connection check blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Action::make('manage_connections')
->label('Manage Provider Connections')
->url(ProviderConnectionResource::getUrl('index', tenant: $tenant)),
])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Action::make('enable_dedicated_override')
->label('Enable dedicated override')
->icon('heroicon-o-key')
->color('primary')
->requiresConfirmation()
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type !== ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->enableDedicatedOverride(
connection: $record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'],
'source' => 'provider_connection.edit_page',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Dedicated override enabled')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('rotate_dedicated_credential')
->label('Rotate dedicated credential')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalDescription('Stores a replacement dedicated client secret and refreshes dedicated identity state.')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type === ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->default(function (ProviderConnection $record): string {
$payload = $record->credential?->payload;
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
})
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->enableDedicatedOverride(
connection: $record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
Notification::make()
->title('Dedicated credential rotated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('delete_dedicated_credential')
->label('Delete dedicated credential')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Deletes the dedicated credential and leaves the connection blocked until a replacement is added or the type is reverted.')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type === ProviderConnectionType::Dedicated
&& $record->credential()->exists())
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->deleteDedicatedCredential($record);
Notification::make()
->title('Dedicated credential deleted')
->warning()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('revert_to_platform')
->label('Revert to platform')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->requiresConfirmation()
->modalDescription('Reverts the connection to the platform-managed identity and removes any dedicated credential.')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->connection_type === ProviderConnectionType::Dedicated)
->action(function (ProviderConnection $record, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->revertToPlatform($record);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => 'provider_connection.edit_page',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Connection reverted to platform')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('set_default')
->label('Set as default')
->icon('heroicon-o-star')
->color('primary')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& (bool) $record->is_enabled
&& ! $record->is_default
&& ProviderConnection::query()
->where('tenant_id', $tenant->getKey())
->where('provider', $record->provider)
->count() > 1)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$record->makeDefault();
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Default connection updated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('inventory_sync')
->label('Inventory sync')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = $this->currentTenant();
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $user->canAccessTenant($tenant)
&& (bool) $record->is_enabled;
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'inventory_sync',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Inventory sync blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('compliance_snapshot')
->label('Compliance snapshot')
->icon('heroicon-o-shield-check')
->color('info')
->visible(function (ProviderConnection $record): bool {
$tenant = $this->currentTenant();
$user = auth()->user();
return $tenant instanceof Tenant
&& $user instanceof User
&& $user->canAccessTenant($tenant)
&& (bool) $record->is_enabled;
})
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$initiator = $user;
$result = $gate->start(
tenant: $tenant,
connection: $record,
operationType: 'compliance.snapshot',
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
ProviderComplianceSnapshotJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $initiator->getKey(),
providerConnectionId: (int) $record->getKey(),
operationRun: $operationRun,
);
},
initiator: $initiator,
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Compliance snapshot blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::PROVIDER_RUN)
->tooltip('You do not have permission to run provider operations.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('enable_connection')
->label('Enable connection')
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
return;
}
$hadCredentials = $record->credential()->exists();
$previousLifecycle = (bool) $record->is_enabled;
$verificationStatus = $hadCredentials ? \App\Support\Providers\ProviderVerificationStatus::Unknown : \App\Support\Providers\ProviderVerificationStatus::Blocked;
$errorReasonCode = null;
$errorMessage = null;
if (! $hadCredentials) {
$errorReasonCode = \App\Support\Providers\ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = 'Provider connection credentials are missing.';
}
$record->update([
'is_enabled' => true,
'verification_status' => $verificationStatus->value,
'last_health_check_at' => null,
'last_error_reason_code' => $errorReasonCode,
'last_error_message' => $errorMessage,
]);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.enabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials,
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
if (! $hadCredentials) {
Notification::make()
->title('Connection enabled (credentials missing)')
->body('Add credentials before running checks or operations.')
->warning()
->send();
return;
}
Notification::make()
->title('Provider connection enabled')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Action::make('disable_connection')
->label('Disable connection')
->icon('heroicon-o-archive-box-x-mark')
->color('danger')
->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant();
if (! $tenant instanceof Tenant) {
return;
}
$previousLifecycle = (bool) $record->is_enabled;
$record->update([
'is_enabled' => false,
]);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.disabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $record->getKey(),
status: 'success',
);
Notification::make()
->title('Provider connection disabled')
->warning()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->tooltip('You do not have permission to manage provider connections.')
->preserveVisibility()
->apply(),
]) ])
->label('Actions') ->label('Actions')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')

View File

@ -3,17 +3,12 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages; namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Links\RequiredPermissionsLinks; use App\Support\Links\RequiredPermissionsLinks;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
class ViewProviderConnection extends ViewRecord class ViewProviderConnection extends ViewRecord
@ -22,228 +17,59 @@ class ViewProviderConnection extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$tenant = $this->currentTenant();
return [ return [
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('grant_admin_consent') Actions\Action::make('grant_admin_consent')
->label('Grant admin consent') ->label('Grant admin consent')
->icon('heroicon-o-clipboard-document') ->icon('heroicon-o-clipboard-document')
->url(function (): ?string { ->url(function () use ($tenant): ?string {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
return $tenant instanceof Tenant return $tenant instanceof Tenant
? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant) ? RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant)
: null; : null;
}) })
->visible(function (): bool { ->visible(fn (): bool => $tenant instanceof Tenant)
return ProviderConnectionResource::resolveTenantForRecord($this->record) instanceof Tenant;
})
->openUrlInNewTab() ->openUrlInNewTab()
) )
->requireCapability(Capabilities::PROVIDER_MANAGE) ->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(), ->apply(),
UiEnforcement::forAction( Actions\ActionGroup::make($this->sharedConnectionActions())
Actions\Action::make('edit') ->label('More')
->label('Edit') ->icon('heroicon-o-ellipsis-vertical')
->icon('heroicon-o-pencil-square')
->url(fn (): string => ProviderConnectionResource::getUrl('edit', ['record' => $this->record]))
)
->requireCapability(Capabilities::PROVIDER_MANAGE)
->apply(),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('enable_dedicated_override')
->label('Enable dedicated override')
->icon('heroicon-o-key')
->color('primary')
->requiresConfirmation()
->modalDescription('Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.')
->visible(fn (): bool => $this->record->connection_type !== ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->enableDedicatedOverride(
connection: $this->record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $this->record->getKey(),
'provider' => $this->record->provider,
'entra_tenant_id' => $this->record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'],
'source' => 'provider_connection.view_page',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $this->record->getKey(),
status: 'success',
);
Notification::make()
->title('Dedicated override enabled')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('rotate_dedicated_credential')
->label('Rotate dedicated credential')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
->form([
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->default(function (): string {
$payload = $this->record->credential?->payload;
return is_array($payload) ? (string) ($payload['client_id'] ?? '') : '';
})
->required()
->maxLength(255),
TextInput::make('client_secret')
->label('Dedicated client secret')
->password()
->required()
->maxLength(255),
])
->action(function (array $data, ProviderConnectionMutationService $mutations): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->enableDedicatedOverride(
connection: $this->record,
clientId: (string) $data['client_id'],
clientSecret: (string) $data['client_secret'],
);
Notification::make()
->title('Dedicated credential rotated')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('delete_dedicated_credential')
->label('Delete dedicated credential')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated
&& $this->record->credential()->exists())
->action(function (ProviderConnectionMutationService $mutations): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->deleteDedicatedCredential($this->record);
Notification::make()
->title('Dedicated credential deleted')
->warning()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('revert_to_platform')
->label('Revert to platform')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->requiresConfirmation()
->visible(fn (): bool => $this->record->connection_type === ProviderConnectionType::Dedicated)
->action(function (ProviderConnectionMutationService $mutations, AuditLogger $auditLogger): void {
$tenant = ProviderConnectionResource::resolveTenantForRecord($this->record);
if (! $tenant instanceof Tenant) {
abort(404);
}
$mutations->revertToPlatform($this->record);
$user = auth()->user();
$actorId = $user instanceof User ? (int) $user->getKey() : null;
$actorEmail = $user instanceof User ? $user->email : null;
$actorName = $user instanceof User ? $user->name : null;
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $this->record->getKey(),
'provider' => $this->record->provider,
'entra_tenant_id' => $this->record->entra_tenant_id,
'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => 'provider_connection.view_page',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $this->record->getKey(),
status: 'success',
);
Notification::make()
->title('Connection reverted to platform')
->success()
->send();
})
)
->requireCapability(Capabilities::PROVIDER_MANAGE_DEDICATED)
->preserveVisibility()
->apply(),
])
->label('Manage dedicated override')
->icon('heroicon-o-cog-6-tooth')
->color('gray'), ->color('gray'),
]; ];
} }
/**
* @return array<int, Actions\Action>
*/
private function sharedConnectionActions(): array
{
return [
ProviderConnectionResource::makeEditNavigationAction(),
ProviderConnectionResource::makeCheckConnectionAction(),
ProviderConnectionResource::makeInventorySyncAction(),
ProviderConnectionResource::makeComplianceSnapshotAction(),
ProviderConnectionResource::makeSetDefaultAction(),
ProviderConnectionResource::makeEnableDedicatedOverrideAction(
source: 'provider_connection.view_page',
modalDescription: 'Dedicated credentials are stored encrypted and reset consent to the dedicated app registration.',
),
ProviderConnectionResource::makeRotateDedicatedCredentialAction(),
ProviderConnectionResource::makeDeleteDedicatedCredentialAction(),
ProviderConnectionResource::makeRevertToPlatformAction(source: 'provider_connection.view_page'),
ProviderConnectionResource::makeEnableConnectionAction(),
ProviderConnectionResource::makeDisableConnectionAction(),
];
}
private function currentTenant(): ?Tenant
{
if (! $this->record instanceof ProviderConnection) {
return null;
}
return ProviderConnectionResource::resolveTenantForRecord($this->record);
}
} }

View File

@ -25,8 +25,8 @@
use App\Services\Intune\RbacOnboardingService; use App\Services\Intune\RbacOnboardingService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\AdminConsentUrlFactory;
use App\Services\PortfolioTriage\TenantTriageReviewService; use App\Services\PortfolioTriage\TenantTriageReviewService;
use App\Services\Providers\AdminConsentUrlFactory;
use App\Services\Tenants\TenantActionPolicySurface; use App\Services\Tenants\TenantActionPolicySurface;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
@ -61,6 +61,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -178,7 +179,423 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; overflow keeps helpers first, workflow actions next, and destructive actions last.') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; overflow keeps helpers first, workflow actions next, and destructive actions last.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view remains the workflow-heavy special type: pure navigation moves into contextual related content while header actions stay grouped into external-link, setup, and lifecycle buckets.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view remains the workflow-heavy special type: shared administrative actions stay grouped into external-link, setup, triage, and lifecycle buckets, while navigation-only context stays outside the header action strip.');
}
public static function makeAdminConsentAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('admin_consent')
->label('Grant admin consent')
->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record): string => static::adminConsentUrl($record) ?? '#')
->visible(fn (Tenant $record): bool => static::adminConsentUrl($record) !== null)
->openUrlInNewTab(),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
}
public static function makeOpenInEntraAction(): Actions\Action
{
return Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): string => static::entraUrl($record) ?? '#')
->visible(fn (Tenant $record): bool => static::entraUrl($record) !== null)
->openUrlInNewTab();
}
public static function makeSyncTenantAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('syncTenant')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => static::syncActionVisible($record))
->action(function (Tenant $record, AuditLogger $auditLogger, $livewire = null): void {
static::handleSyncTenantAction($record, $auditLogger, $livewire);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
}
public static function makeVerifyConfigurationAction(string $surfaceKind = 'tenant_list_row'): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('verify')
->label('Verify configuration')
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
->action(function (Tenant $record, StartVerification $verification, $livewire = null) use ($surfaceKind): void {
static::handleVerifyConfigurationAction($record, $verification, $livewire, $surfaceKind);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply();
}
/**
* @return array<string, mixed>
*/
public static function tenantViewTriageState(): array
{
return static::portfolioReturnFiltersFromRequest(request()->query());
}
public static function tenantViewTriageGroupVisible(Tenant $tenant): bool
{
return static::selectedActionTriageReviewRowForTenant($tenant, static::tenantViewTriageState()) !== null
&& static::userCanSeeTriageReviewAction($tenant);
}
public static function makeTenantViewMarkReviewedAction(): Actions\Action
{
return Actions\Action::make('markReviewed')
->label('Mark reviewed')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Mark reviewed')
->modalDescription(fn (Tenant $record): string => static::triageReviewActionModalDescription(
$record,
static::tenantViewTriageState(),
TenantTriageReview::STATE_REVIEWED,
))
->visible(fn (Tenant $record): bool => static::selectedActionTriageReviewRowForTenant(
$record,
static::tenantViewTriageState(),
) !== null && static::userCanSeeTriageReviewAction($record))
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
->before(function (Tenant $record): void {
static::authorizeTriageReviewAction($record);
})
->action(function (Tenant $record, TenantTriageReviewService $service): void {
static::handleTriageReviewMutation(
tenant: $record,
triageState: static::tenantViewTriageState(),
targetManualState: TenantTriageReview::STATE_REVIEWED,
service: $service,
);
});
}
public static function makeTenantViewMarkFollowUpNeededAction(): Actions\Action
{
return Actions\Action::make('markFollowUpNeeded')
->label('Mark follow-up needed')
->icon('heroicon-o-exclamation-circle')
->color('warning')
->requiresConfirmation()
->modalHeading('Mark follow-up needed')
->modalDescription(fn (Tenant $record): string => static::triageReviewActionModalDescription(
$record,
static::tenantViewTriageState(),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
))
->visible(fn (Tenant $record): bool => static::selectedActionTriageReviewRowForTenant(
$record,
static::tenantViewTriageState(),
) !== null && static::userCanSeeTriageReviewAction($record))
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
->before(function (Tenant $record): void {
static::authorizeTriageReviewAction($record);
})
->action(function (Tenant $record, TenantTriageReviewService $service): void {
static::handleTriageReviewMutation(
tenant: $record,
triageState: static::tenantViewTriageState(),
targetManualState: TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
service: $service,
);
});
}
public static function makeRestoreTenantAction(TenantActionSurface $surface, ?string $permissionTooltip = null): Actions\Action
{
$builder = UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (): string => GovernanceActionCatalog::rule('restore_tenant')->canonicalLabel)
->color('success')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, $surface)?->icon ?? 'heroicon-o-arrow-uturn-left')
->successNotificationTitle(fn (): string => GovernanceActionCatalog::rule('restore_tenant')->successTitle)
->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('restore_tenant')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('restore_tenant')->modalDescription)
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, $surface)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
static::restoreTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE);
if ($permissionTooltip !== null && $permissionTooltip !== '') {
$builder->tooltip($permissionTooltip);
}
return $builder->apply();
}
public static function makeArchiveTenantAction(TenantActionSurface $surface, ?string $permissionTooltip = null): Actions\Action
{
$builder = UiEnforcement::forAction(
Actions\Action::make('archive')
->label(fn (): string => GovernanceActionCatalog::rule('archive_tenant')->canonicalLabel)
->color('danger')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, $surface)?->icon ?? 'heroicon-o-archive-box-x-mark')
->successNotificationTitle(fn (): string => GovernanceActionCatalog::rule('archive_tenant')->successTitle)
->requiresConfirmation()
->modalHeading(GovernanceActionCatalog::rule('archive_tenant')->modalHeading)
->modalDescription(GovernanceActionCatalog::rule('archive_tenant')->modalDescription)
->form([
Forms\Components\Textarea::make('archive_reason')
->label('Archive reason')
->rows(4)
->required()
->maxLength(2000),
])
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, $surface)?->key === 'archive')
->action(function (Tenant $record, array $data, WorkspaceAuditLogger $auditLogger): void {
static::archiveTenant($record, $auditLogger, (string) ($data['archive_reason'] ?? ''));
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE);
if ($permissionTooltip !== null && $permissionTooltip !== '') {
$builder->tooltip($permissionTooltip);
}
return $builder->apply();
}
private static function syncActionVisible(Tenant $record): bool
{
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->canAccessTenant($record);
}
private static function handleSyncTenantAction(Tenant $record, AuditLogger $auditLogger, mixed $livewire = null): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
abort(403);
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$supportedTypes = config('tenantpilot.supported_policy_types', []);
$typeNames = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
$supportedTypes,
);
sort($typeNames);
$inputs = [
'scope' => 'full',
'types' => $typeNames,
];
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
$opService->failStaleQueuedRun(
$opRun,
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
);
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: $user,
);
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
});
$auditLogger->log(
tenant: $record,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
}
private static function handleVerifyConfigurationAction(
Tenant $record,
StartVerification $verification,
mixed $livewire = null,
string $surfaceKind = 'tenant_list_row',
): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
$result = $verification->providerConnectionCheckForTenant(
tenant: $record,
initiator: $user,
extraContext: [
'surface' => [
'kind' => $surfaceKind,
],
],
);
$runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'blocked') {
$actions = [
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Actions\Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
} }
private static function userCanManageAnyTenant(User $user): bool private static function userCanManageAnyTenant(User $user): bool
@ -341,11 +758,13 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('policies_count') Tables\Columns\TextColumn::make('policies_count')
->label('Policies') ->label('Policies')
->numeric() ->numeric()
->sortable(), ->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('last_policy_sync_at') Tables\Columns\TextColumn::make('last_policy_sync_at')
->label('Last Sync') ->label('Last Sync')
->since() ->since()
->sortable(), ->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('domain') Tables\Columns\TextColumn::make('domain')
->copyable() ->copyable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
@ -464,260 +883,10 @@ public static function table(Table $table): Table
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->apply(), ->apply(),
UiEnforcement::forAction( static::makeAdminConsentAction(),
Actions\Action::make('admin_consent') static::makeOpenInEntraAction(),
->label('Grant admin consent') static::makeSyncTenantAction(),
->icon('heroicon-o-clipboard-document') static::makeVerifyConfigurationAction(),
->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
->openUrlInNewTab(),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record) => static::entraUrl($record))
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('syncTenant')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (Tenant $record): bool {
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->canAccessTenant($record);
})
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
abort(403);
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$supportedTypes = config('tenantpilot.supported_policy_types', []);
$typeNames = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
$supportedTypes,
);
sort($typeNames);
$inputs = [
'scope' => 'full',
'types' => $typeNames,
];
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: auth()->user()
);
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
$opService->failStaleQueuedRun(
$opRun,
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
);
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: auth()->user()
);
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
});
$auditLogger->log(
tenant: $record,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_SYNC)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('verify')
->label('Verify configuration')
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
->action(function (
Tenant $record,
StartVerification $verification,
\Filament\Tables\Contracts\HasTable $livewire,
): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
$result = $verification->providerConnectionCheckForTenant(
tenant: $record,
initiator: $user,
extraContext: [
'surface' => [
'kind' => 'tenant_list_row',
],
],
);
$runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Actions\Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
Actions\Action::make('markReviewed') Actions\Action::make('markReviewed')
->label('Mark reviewed') ->label('Mark reviewed')
->icon('heroicon-o-check-circle') ->icon('heroicon-o-check-circle')
@ -782,23 +951,7 @@ public static function table(Table $table): Table
service: $service, service: $service,
); );
}), }),
UiEnforcement::forAction( static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
Actions\Action::make('restore')
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
static::restoreTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
static::rbacAction(), static::rbacAction(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('forceDelete') Actions\Action::make('forceDelete')
@ -856,23 +1009,7 @@ public static function table(Table $table): Table
->preserveVisibility() ->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE) ->requireCapability(Capabilities::TENANT_DELETE)
->apply(), ->apply(),
UiEnforcement::forAction( static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
Actions\Action::make('archive')
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
->color('danger')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
static::archiveTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]) ])
->label('More') ->label('More')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
@ -2482,9 +2619,10 @@ private static function viewerHasTenantCapability(Tenant $tenant, string $capabi
&& $resolver->can($user, $tenant, $capability); && $resolver->can($user, $tenant, $capability);
} }
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger, string $reason): void
{ {
$user = auth()->user(); $user = auth()->user();
$reason = static::validatedLifecycleReason($reason, 'archive_reason');
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
@ -2519,11 +2657,11 @@ public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $audit
tenant: $record, tenant: $record,
action: AuditActionId::TenantArchived, action: AuditActionId::TenantArchived,
actor: $user, actor: $user,
context: ['metadata' => ['tenant_id' => $record->tenant_id]] context: ['metadata' => ['tenant_id' => $record->tenant_id, 'reason' => $reason]]
); );
Notification::make() Notification::make()
->title($descriptor->successNotificationTitle ?? 'Tenant archived') ->title(GovernanceActionCatalog::rule('archive_tenant')->successTitle)
->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.') ->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.')
->success() ->success()
->send(); ->send();
@ -2570,12 +2708,27 @@ public static function restoreTenant(Tenant $record, WorkspaceAuditLogger $audit
); );
Notification::make() Notification::make()
->title($descriptor->successNotificationTitle ?? 'Tenant restored') ->title(GovernanceActionCatalog::rule('restore_tenant')->successTitle)
->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.') ->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.')
->success() ->success()
->send(); ->send();
} }
private static function validatedLifecycleReason(string $reason, string $field): string
{
$reason = trim($reason);
if ($reason === '') {
throw new \InvalidArgumentException(sprintf('%s is required.', $field));
}
if (mb_strlen($reason) > 2000) {
throw new \InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
}
return $reason;
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -4,12 +4,8 @@
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionSurface; use App\Support\Tenants\TenantActionSurface;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditTenant extends EditRecord class EditTenant extends EditRecord
@ -20,42 +16,14 @@ protected function getHeaderActions(): array
{ {
return array_values(array_filter([ return array_values(array_filter([
Actions\ActionGroup::make([ Actions\ActionGroup::make([
UiEnforcement::forAction( TenantResource::makeRestoreTenantAction(
Action::make('restore') TenantActionSurface::TenantEditHeader,
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore') 'You do not have permission to restore tenants.',
->color('success') ),
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left') TenantResource::makeArchiveTenantAction(
->requiresConfirmation() TenantActionSurface::TenantEditHeader,
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant') 'You do not have permission to archive tenants.',
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.') ),
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::restoreTenant($record, $auditLogger);
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to restore tenants.')
->preserveVisibility()
->destructive()
->apply(),
UiEnforcement::forAction(
Action::make('archive')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
->color('danger')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::archiveTenant($record, $auditLogger);
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to archive tenants.')
->preserveVisibility()
->destructive()
->apply(),
]) ])
->label('Lifecycle') ->label('Lifecycle')
->icon('heroicon-o-archive-box') ->icon('heroicon-o-archive-box')

View File

@ -10,9 +10,7 @@
use App\Jobs\RefreshTenantRbacHealthJob; use App\Jobs\RefreshTenantRbacHealthJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType; use App\Support\OperationRunType;
@ -57,18 +55,8 @@ protected function getHeaderActions(): array
{ {
return array_values(array_filter([ return array_values(array_filter([
Actions\ActionGroup::make([ Actions\ActionGroup::make([
Actions\Action::make('admin_consent') TenantResource::makeAdminConsentAction(),
->label('Grant admin consent') TenantResource::makeOpenInEntraAction(),
->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record))
->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null)
->openUrlInNewTab(),
Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
->openUrlInNewTab(),
]) ])
->label('External links') ->label('External links')
->icon('heroicon-o-arrow-top-right-on-square') ->icon('heroicon-o-arrow-top-right-on-square')
@ -76,126 +64,8 @@ protected function getHeaderActions(): array
->visible(fn (): bool => $this->getRecord() instanceof Tenant ->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())), && TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
UiEnforcement::forAction( TenantResource::makeSyncTenantAction(),
Actions\Action::make('verify') TenantResource::makeVerifyConfigurationAction('tenant_view_header'),
->label(self::verificationHeaderActionLabel())
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
->action(function (
Tenant $record,
StartVerification $verification,
): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
$result = $verification->providerConnectionCheckForTenant(
tenant: $record,
initiator: $user,
extraContext: [
'surface' => [
'kind' => 'tenant_view_header',
],
],
);
$runUrl = OperationRunLinks::tenantlessView($result->run);
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
if ($result->status === 'blocked') {
$actions = [
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Actions\Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
TenantResource::rbacAction(), TenantResource::rbacAction(),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('refresh_rbac') Actions\Action::make('refresh_rbac')
@ -271,40 +141,17 @@ protected function getHeaderActions(): array
->visible(fn (): bool => $this->getRecord() instanceof Tenant ->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())), && TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
UiEnforcement::forAction( TenantResource::makeTenantViewMarkReviewedAction(),
Actions\Action::make('restore') TenantResource::makeTenantViewMarkFollowUpNeededAction(),
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore') ])
->color('success') ->label('Triage')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-arrow-uturn-left') ->icon('heroicon-o-check-circle')
->requiresConfirmation() ->color('gray')
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Restore tenant') ->visible(fn (): bool => $this->getRecord() instanceof Tenant
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.') && TenantResource::tenantViewTriageGroupVisible($this->getRecord())),
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'restore') Actions\ActionGroup::make([
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { TenantResource::makeRestoreTenantAction(TenantActionSurface::TenantViewHeader),
TenantResource::restoreTenant($record, $auditLogger); TenantResource::makeArchiveTenantAction(TenantActionSurface::TenantViewHeader),
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
->color('danger')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Archive tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::archiveTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
]) ])
->label('Lifecycle') ->label('Lifecycle')
->icon('heroicon-o-archive-box') ->icon('heroicon-o-archive-box')

View File

@ -13,7 +13,9 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -146,14 +148,18 @@ private function secondaryLifecycleActionNames(): array
private function refreshReviewAction(): Actions\Action private function refreshReviewAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('refresh_review');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('refresh_review') Actions\Action::make('refresh_review')
->label('Refresh review') ->label($rule->canonicalLabel)
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable()) ->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->action(function () use ($rule): void {
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
@ -168,7 +174,7 @@ private function refreshReviewAction(): Actions\Action
return; return;
} }
Notification::make()->success()->title('Refresh review queued')->send(); Notification::make()->success()->title($rule->successTitle)->send();
}), }),
) )
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE) ->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
@ -178,14 +184,25 @@ private function refreshReviewAction(): Actions\Action
private function publishReviewAction(): Actions\Action private function publishReviewAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('publish_review');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('publish_review') Actions\Action::make('publish_review')
->label('Publish review') ->label($rule->canonicalLabel)
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('primary') ->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable()) ->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([
Textarea::make('publish_reason')
->label('Publication reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (array $data) use ($rule): void {
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
@ -193,7 +210,11 @@ private function publishReviewAction(): Actions\Action
} }
try { try {
app(TenantReviewLifecycleService::class)->publish($this->record, $user); app(TenantReviewLifecycleService::class)->publish(
$this->record,
$user,
(string) ($data['publish_reason'] ?? ''),
);
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send(); Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
@ -201,7 +222,7 @@ private function publishReviewAction(): Actions\Action
} }
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']); $this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
Notification::make()->success()->title('Review published')->send(); Notification::make()->success()->title($rule->successTitle)->send();
}), }),
) )
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE) ->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
@ -259,24 +280,39 @@ private function createNextReviewAction(): Actions\Action
private function archiveReviewAction(): Actions\Action private function archiveReviewAction(): Actions\Action
{ {
$rule = GovernanceActionCatalog::rule('archive_review');
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make('archive_review') Actions\Action::make('archive_review')
->label('Archive review') ->label($rule->canonicalLabel)
->icon('heroicon-o-archive-box') ->icon('heroicon-o-archive-box')
->color('danger') ->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal()) ->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([
Textarea::make('archive_reason')
->label('Archive reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (array $data) use ($rule): void {
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
} }
app(TenantReviewLifecycleService::class)->archive($this->record, $user); app(TenantReviewLifecycleService::class)->archive(
$this->record,
$user,
(string) ($data['archive_reason'] ?? ''),
);
$this->refreshFormData(['status', 'archived_at']); $this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title('Review archived')->send(); Notification::make()->success()->title($rule->successTitle)->send();
}), }),
) )
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE) ->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)

View File

@ -49,7 +49,7 @@ public function workspaceTenants(): Collection
->where('workspace_id', (int) $this->workspace->getKey()) ->where('workspace_id', (int) $this->workspace->getKey())
->orderBy('name') ->orderBy('name')
->limit(10) ->limit(10)
->get(['id', 'name', 'status', 'workspace_id']); ->get(['id', 'name', 'status', 'workspace_id', 'external_id']);
} }
/** /**

View File

@ -10,6 +10,7 @@
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -55,6 +56,10 @@ public function getTitle(): string|Htmlable
*/ */
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$retryRule = GovernanceActionCatalog::rule('retry_run');
$cancelRule = GovernanceActionCatalog::rule('cancel_run');
$investigatedRule = GovernanceActionCatalog::rule('mark_investigated');
return [ return [
Action::make('show_all_operations') Action::make('show_all_operations')
->label('Show all operations') ->label('Show all operations')
@ -63,8 +68,11 @@ protected function getHeaderActions(): array
->label('Go to runbooks') ->label('Go to runbooks')
->url(Runbooks::getUrl(panel: 'system')), ->url(Runbooks::getUrl(panel: 'system')),
Action::make('retry') Action::make('retry')
->label('Retry') ->label($retryRule->canonicalLabel)
->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($retryRule->modalHeading)
->modalDescription($retryRule->modalDescription)
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($this->run)) ->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($this->run))
->action(function (OperationRunTriageService $triageService): void { ->action(function (OperationRunTriageService $triageService): void {
$user = $this->requireManageUser(); $user = $this->requireManageUser();
@ -79,37 +87,50 @@ protected function getHeaderActions(): array
->send(); ->send();
}), }),
Action::make('cancel') Action::make('cancel')
->label('Cancel') ->label($cancelRule->canonicalLabel)
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($cancelRule->modalHeading)
->modalDescription($cancelRule->modalDescription)
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($this->run)) ->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($this->run))
->action(function (OperationRunTriageService $triageService): void {
$user = $this->requireManageUser();
$triageService->cancel($this->run, $user);
Notification::make()
->title('Run cancelled')
->success()
->send();
}),
Action::make('mark_investigated')
->label('Mark investigated')
->requiresConfirmation()
->visible(fn (): bool => $this->canManageOperations())
->form([ ->form([
Textarea::make('reason') Textarea::make('reason')
->label('Reason') ->label('Cancellation reason')
->required() ->required()
->minLength(5) ->minLength(5)
->maxLength(500) ->maxLength(500)
->rows(4), ->rows(4),
]) ])
->action(function (array $data, OperationRunTriageService $triageService): void { ->action(function (array $data, OperationRunTriageService $triageService) use ($cancelRule): void {
$user = $this->requireManageUser();
$triageService->cancel($this->run, $user, (string) ($data['reason'] ?? ''));
Notification::make()
->title($cancelRule->successTitle)
->success()
->send();
}),
Action::make('mark_investigated')
->label($investigatedRule->canonicalLabel)
->color('warning')
->requiresConfirmation()
->modalHeading($investigatedRule->modalHeading)
->modalDescription($investigatedRule->modalDescription)
->visible(fn (): bool => $this->canManageOperations())
->form([
Textarea::make('reason')
->label('Investigation reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, OperationRunTriageService $triageService) use ($investigatedRule): void {
$user = $this->requireManageUser(); $user = $this->requireManageUser();
$triageService->markInvestigated($this->run, $user, (string) ($data['reason'] ?? '')); $triageService->markInvestigated($this->run, $user, (string) ($data['reason'] ?? ''));
Notification::make() Notification::make()
->title('Run marked as investigated') ->title($investigatedRule->successTitle)
->success() ->success()
->send(); ->send();
}), }),

View File

@ -37,6 +37,24 @@ class TenantTriageArrivalContinuity extends Widget implements HasActions, HasSch
*/ */
public ?array $arrivalState = null; public ?array $arrivalState = null;
private ?PortfolioArrivalContext $cachedArrivalContext = null;
private ?int $cachedArrivalContextTenantId = null;
private bool $hasCachedArrivalContext = false;
/**
* @var array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array<string, mixed>}|null
*/
private ?array $cachedConcernTruth = null;
private ?int $cachedConcernTruthTenantId = null;
/**
* @var array<int, array<string, array<string, mixed>|null>>
*/
private array $cachedReviewStates = [];
protected static bool $isLazy = false; protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full'; protected int|string|array $columnSpan = 'full';
@ -197,23 +215,22 @@ private function handleReviewMutation(string $targetManualState, TenantTriageRev
return; return;
} }
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant); $concernTruth = $this->concernTruthFor($tenant);
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
$actor = auth()->user(); $actor = auth()->user();
$review = match ($targetManualState) { $review = match ($targetManualState) {
TenantTriageReview::STATE_REVIEWED => $service->markReviewed( TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
tenant: $tenant, tenant: $tenant,
concernFamily: $context->concernFamily, concernFamily: $context->concernFamily,
backupHealth: $backupHealth, backupHealth: $concernTruth['backupHealth'],
recoveryEvidence: $recoveryEvidence, recoveryEvidence: $concernTruth['recoveryEvidence'],
actor: $actor instanceof User ? $actor : null, actor: $actor instanceof User ? $actor : null,
), ),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded( TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
tenant: $tenant, tenant: $tenant,
concernFamily: $context->concernFamily, concernFamily: $context->concernFamily,
backupHealth: $backupHealth, backupHealth: $concernTruth['backupHealth'],
recoveryEvidence: $recoveryEvidence, recoveryEvidence: $concernTruth['recoveryEvidence'],
actor: $actor instanceof User ? $actor : null, actor: $actor instanceof User ? $actor : null,
), ),
default => null, default => null,
@ -223,6 +240,8 @@ private function handleReviewMutation(string $targetManualState, TenantTriageRev
return; return;
} }
$this->clearConcernCachesFor($tenant);
Notification::make() Notification::make()
->title('Review state updated') ->title('Review state updated')
->body(sprintf( ->body(sprintf(
@ -240,15 +259,59 @@ private function handleReviewMutation(string $targetManualState, TenantTriageRev
*/ */
private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array
{ {
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant); $tenantId = (int) $tenant->getKey();
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
return app(TenantTriageReviewStateResolver::class)->resolveMany( if (array_key_exists($tenantId, $this->cachedReviewStates)
&& array_key_exists($concernFamily, $this->cachedReviewStates[$tenantId])) {
return $this->cachedReviewStates[$tenantId][$concernFamily];
}
$concernTruth = $this->concernTruthFor($tenant);
$reviewState = app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: (int) $tenant->workspace_id, workspaceId: (int) $tenant->workspace_id,
tenantIds: [(int) $tenant->getKey()], tenantIds: [$tenantId],
backupHealthByTenant: [(int) $tenant->getKey() => $backupHealth], backupHealthByTenant: [$tenantId => $concernTruth['backupHealth']],
recoveryEvidenceByTenant: [(int) $tenant->getKey() => $recoveryEvidence], recoveryEvidenceByTenant: [$tenantId => $concernTruth['recoveryEvidence']],
)['rows'][(int) $tenant->getKey()][$concernFamily] ?? null; )['rows'][$tenantId][$concernFamily] ?? null;
$this->cachedReviewStates[$tenantId][$concernFamily] = $reviewState;
return $reviewState;
}
/**
* @return array{backupHealth: \App\Support\BackupHealth\TenantBackupHealthAssessment, recoveryEvidence: array<string, mixed>}
*/
private function concernTruthFor(Tenant $tenant): array
{
$tenantId = (int) $tenant->getKey();
if ($this->cachedConcernTruthTenantId === $tenantId && is_array($this->cachedConcernTruth)) {
return $this->cachedConcernTruth;
}
$this->cachedConcernTruthTenantId = $tenantId;
$this->cachedConcernTruth = [
'backupHealth' => app(TenantBackupHealthResolver::class)->assess($tenant),
'recoveryEvidence' => app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant),
];
return $this->cachedConcernTruth;
}
private function clearConcernCachesFor(Tenant $tenant): void
{
$tenantId = (int) $tenant->getKey();
if ($this->cachedConcernTruthTenantId === $tenantId) {
$this->cachedConcernTruthTenantId = null;
$this->cachedConcernTruth = null;
}
if (array_key_exists($tenantId, $this->cachedReviewStates)) {
unset($this->cachedReviewStates[$tenantId]);
}
} }
private function concernFamilyLabel(string $concernFamily): string private function concernFamilyLabel(string $concernFamily): string
@ -262,6 +325,31 @@ private function concernFamilyLabel(string $concernFamily): string
private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext
{ {
return app(PortfolioArrivalContextResolver::class)->resolveState($tenant, $this->arrivalState); $tenantId = (int) $tenant->getKey();
if ($this->arrivalState === null) {
$this->cachedArrivalContextTenantId = $tenantId;
$this->cachedArrivalContext = null;
$this->hasCachedArrivalContext = true;
return null;
}
if ($this->hasCachedArrivalContext && $this->cachedArrivalContextTenantId === $tenantId) {
return $this->cachedArrivalContext;
}
$concernTruth = $this->concernTruthFor($tenant);
$this->cachedArrivalContextTenantId = $tenantId;
$this->cachedArrivalContext = app(PortfolioArrivalContextResolver::class)->resolveStateWithTruth(
$tenant,
$this->arrivalState,
$concernTruth['backupHealth'],
$concernTruth['recoveryEvidence'],
);
$this->hasCachedArrivalContext = true;
return $this->cachedArrivalContext;
} }
} }

View File

@ -29,6 +29,10 @@ public function __invoke(Request $request): RedirectResponse
return redirect()->route('admin.operations.index'); return redirect()->route('admin.operations.index');
} }
if ($this->isTenantScopedEvidencePath($previousPath)) {
return redirect()->route('admin.evidence.overview');
}
if (TenantPageCategory::fromPath($previousPath) === TenantPageCategory::TenantBound) { if (TenantPageCategory::fromPath($previousPath) === TenantPageCategory::TenantBound) {
$workspace = $workspaceContext->currentWorkspace($request); $workspace = $workspaceContext->currentWorkspace($request);
@ -45,4 +49,17 @@ public function __invoke(Request $request): RedirectResponse
return redirect()->to((string) $previousUrl); return redirect()->to((string) $previousUrl);
} }
private function isTenantScopedEvidencePath(string $previousPath): bool
{
if ($previousPath === '/admin/evidence') {
return true;
}
if (! str_starts_with($previousPath, '/admin/evidence/')) {
return false;
}
return ! str_starts_with($previousPath, '/admin/evidence/overview');
}
} }

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Barryvdh\Debugbar\LaravelDebugbar;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SuppressDebugbarForSmokeRequests
{
public const COOKIE_NAME = 'tp_smoke_test';
public const COOKIE_VALUE = 'ok';
public const SESSION_KEY = 'tp_smoke_test';
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! $this->shouldSuppressDebugbar($request)) {
return $next($request);
}
$debugbar = app()->bound('debugbar') ? app('debugbar') : null;
config(['debugbar.enabled' => false]);
if ($debugbar instanceof LaravelDebugbar && $debugbar->isEnabled()) {
$debugbar->disable();
}
return $next($request);
}
private function shouldSuppressDebugbar(Request $request): bool
{
if ($request->cookie(self::COOKIE_NAME) === self::COOKIE_VALUE) {
return true;
}
if (! $request->hasSession()) {
return false;
}
return $request->session()->get(self::SESSION_KEY) === self::COOKIE_VALUE;
}
}

View File

@ -36,7 +36,7 @@ public function panel(Panel $panel): Panel
->colors([ ->colors([
'primary' => Color::Blue, 'primary' => Color::Blue,
]) ])
->databaseNotifications() ->databaseNotifications(isLazy: false)
->databaseNotificationsPolling(null) ->databaseNotificationsPolling(null)
->renderHook( ->renderHook(
PanelsRenderHook::BODY_START, PanelsRenderHook::BODY_START,

View File

@ -125,8 +125,10 @@ public function refresh(EvidenceSnapshot $snapshot, User $user): EvidenceSnapsho
return $refreshed; return $refreshed;
} }
public function expire(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot public function expire(EvidenceSnapshot $snapshot, User $user, string $reason): EvidenceSnapshot
{ {
$reason = $this->validatedReason($reason, 'expiration_reason');
$snapshot->forceFill([ $snapshot->forceFill([
'status' => EvidenceSnapshotStatus::Expired->value, 'status' => EvidenceSnapshotStatus::Expired->value,
'expires_at' => now(), 'expires_at' => now(),
@ -142,6 +144,7 @@ public function expire(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
'metadata' => [ 'metadata' => [
'before_status' => EvidenceSnapshotStatus::Active->value, 'before_status' => EvidenceSnapshotStatus::Active->value,
'after_status' => EvidenceSnapshotStatus::Expired->value, 'after_status' => EvidenceSnapshotStatus::Expired->value,
'reason' => $reason,
], ],
], ],
actor: $user, actor: $user,
@ -242,6 +245,25 @@ public function computeFingerprint(Tenant $tenant): string
return $this->buildSnapshotPayload($tenant)['fingerprint']; return $this->buildSnapshotPayload($tenant)['fingerprint'];
} }
private function validatedReason(mixed $reason, string $field): string
{
if (! is_string($reason)) {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
$resolved = trim($reason);
if ($resolved === '') {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
if (mb_strlen($resolved) > 2000) {
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
}
return $resolved;
}
public function checkActiveRun(Tenant $tenant): bool public function checkActiveRun(Tenant $tenant): bool
{ {
return $this->operationRuns->findCanonicalRunWithIdentity( return $this->operationRuns->findCanonicalRunWithIdentity(

View File

@ -166,11 +166,10 @@ public function approve(FindingException $exception, User $actor, array $payload
$effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from'); $effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from');
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true); $expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true);
$approvalReason = $this->validatedOptionalReason($payload['approval_reason'] ?? null, 'approval_reason');
$approvedAt = CarbonImmutable::now(); $approvedAt = CarbonImmutable::now();
/** @var FindingException $approvedException */ /** @var FindingException $approvedException */
$approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $effectiveFrom, $expiresAt, $approvalReason, $approvedAt): FindingException { $approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $effectiveFrom, $expiresAt, $approvedAt): FindingException {
/** @var FindingException $lockedException */ /** @var FindingException $lockedException */
$lockedException = FindingException::query() $lockedException = FindingException::query()
->with(['finding', 'tenant', 'requester', 'currentDecision']) ->with(['finding', 'tenant', 'requester', 'currentDecision'])
@ -186,6 +185,8 @@ public function approve(FindingException $exception, User $actor, array $payload
throw new InvalidArgumentException('Requesters cannot approve their own exception requests.'); throw new InvalidArgumentException('Requesters cannot approve their own exception requests.');
} }
$approvalReason = $this->validatedReason($payload['approval_reason'] ?? null, 'approval_reason');
$isRenewalApproval = $lockedException->isPendingRenewal(); $isRenewalApproval = $lockedException->isPendingRenewal();
$before = $this->exceptionSnapshot($lockedException); $before = $this->exceptionSnapshot($lockedException);
@ -234,7 +235,7 @@ public function approve(FindingException $exception, User $actor, array $payload
finding: $finding, finding: $finding,
tenant: $tenant, tenant: $tenant,
actor: $actor, actor: $actor,
reason: $this->findingRiskAcceptedReason($lockedException, $approvalReason), reason: $this->findingRiskAcceptedReason($approvalReason),
); );
} }
@ -695,15 +696,6 @@ private function validatedReason(mixed $reason, string $field): string
return $resolved; return $resolved;
} }
private function validatedOptionalReason(mixed $reason, string $field): ?string
{
if ($reason === null || $reason === '') {
return null;
}
return $this->validatedReason($reason, $field);
}
private function validatedDate(mixed $value, string $field): CarbonImmutable private function validatedDate(mixed $value, string $field): CarbonImmutable
{ {
try { try {
@ -842,13 +834,9 @@ private function evidenceSummary(array $references): array
]; ];
} }
private function findingRiskAcceptedReason(FindingException $exception, ?string $approvalReason): string private function findingRiskAcceptedReason(string $approvalReason): string
{ {
if (is_string($approvalReason) && $approvalReason !== '') { return mb_substr($approvalReason, 0, 255);
return mb_substr($approvalReason, 0, 255);
}
return 'Governed by approved exception #'.$exception->getKey();
} }
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable

View File

@ -228,7 +228,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
); );
} }
public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{ {
$this->authorize($finding, $tenant, $actor, [ $this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,
@ -239,6 +239,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding
throw new InvalidArgumentException('Only terminal findings can be reopened.'); throw new InvalidArgumentException('Only terminal findings can be reopened.');
} }
$reason = $this->validatedReason($reason, 'reopen_reason');
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
@ -251,6 +252,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding
context: [ context: [
'metadata' => [ 'metadata' => [
'reopened_at' => $now->toIso8601String(), 'reopened_at' => $now->toIso8601String(),
'reopened_reason' => $reason,
'sla_days' => $slaDays, 'sla_days' => $slaDays,
'due_at' => $dueAt->toIso8601String(), 'due_at' => $dueAt->toIso8601String(),
], ],

View File

@ -108,17 +108,19 @@ public function retry(OperationRun $run, PlatformUser $actor): OperationRun
return $retryRun; return $retryRun;
} }
public function cancel(OperationRun $run, PlatformUser $actor): OperationRun public function cancel(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
{ {
if (! $this->canCancel($run)) { if (! $this->canCancel($run)) {
throw new InvalidArgumentException('Operation run is not cancelable.'); throw new InvalidArgumentException('Operation run is not cancelable.');
} }
$reason = $this->validatedReason($reason, 'reason');
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge( $context['triage'] = array_merge(
is_array($context['triage'] ?? null) ? $context['triage'] : [], is_array($context['triage'] ?? null) ? $context['triage'] : [],
[ [
'cancelled_at' => now()->toISOString(), 'cancelled_at' => now()->toISOString(),
'cancel_reason' => $reason,
'cancelled_by' => [ 'cancelled_by' => [
'platform_user_id' => (int) $actor->getKey(), 'platform_user_id' => (int) $actor->getKey(),
'name' => $actor->name, 'name' => $actor->name,
@ -141,6 +143,7 @@ public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
[ [
'code' => 'run.cancelled', 'code' => 'run.cancelled',
'message' => 'Run cancelled by platform operator triage action.', 'message' => 'Run cancelled by platform operator triage action.',
'reason' => $reason,
], ],
], ],
); );
@ -150,6 +153,7 @@ public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
action: 'platform.system_console.cancel', action: 'platform.system_console.cancel',
metadata: [ metadata: [
'operation_type' => (string) $run->type, 'operation_type' => (string) $run->type,
'reason' => $reason,
], ],
run: $cancelledRun, run: $cancelledRun,
); );
@ -159,11 +163,7 @@ public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
{ {
$reason = trim($reason); $reason = $this->validatedReason($reason, 'reason');
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
throw new InvalidArgumentException('Investigation reason must be between 5 and 500 characters.');
}
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
$context['triage'] = array_merge( $context['triage'] = array_merge(
@ -199,4 +199,15 @@ public function markInvestigated(OperationRun $run, PlatformUser $actor, string
return $run; return $run;
} }
private function validatedReason(string $reason, string $field): string
{
$reason = trim($reason);
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
throw new InvalidArgumentException(sprintf('%s must be between 5 and 500 characters.', $field));
}
return $reason;
}
} }

View File

@ -26,10 +26,11 @@ public function __construct(
private readonly RequestScopedDerivedStateStore $derivedStateStore, private readonly RequestScopedDerivedStateStore $derivedStateStore,
) {} ) {}
public function publish(TenantReview $review, User $user): TenantReview public function publish(TenantReview $review, User $user, string $reason): TenantReview
{ {
$review->loadMissing(['tenant', 'sections', 'currentExportReviewPack']); $review->loadMissing(['tenant', 'sections', 'currentExportReviewPack']);
$tenant = $review->tenant; $tenant = $review->tenant;
$reason = $this->validatedReason($reason, 'publish_reason');
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Review tenant could not be resolved.'); throw new InvalidArgumentException('Review tenant could not be resolved.');
@ -59,6 +60,7 @@ public function publish(TenantReview $review, User $user): TenantReview
'review_id' => (int) $review->getKey(), 'review_id' => (int) $review->getKey(),
'before_status' => $beforeStatus, 'before_status' => $beforeStatus,
'after_status' => TenantReviewStatus::Published->value, 'after_status' => TenantReviewStatus::Published->value,
'reason' => $reason,
], ],
], ],
actor: $user, actor: $user,
@ -73,10 +75,11 @@ public function publish(TenantReview $review, User $user): TenantReview
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']); return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
} }
public function archive(TenantReview $review, User $user): TenantReview public function archive(TenantReview $review, User $user, string $reason): TenantReview
{ {
$review->loadMissing('tenant'); $review->loadMissing('tenant');
$tenant = $review->tenant; $tenant = $review->tenant;
$reason = $this->validatedReason($reason, 'archive_reason');
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Review tenant could not be resolved.'); throw new InvalidArgumentException('Review tenant could not be resolved.');
@ -101,6 +104,7 @@ public function archive(TenantReview $review, User $user): TenantReview
'review_id' => (int) $review->getKey(), 'review_id' => (int) $review->getKey(),
'before_status' => $beforeStatus, 'before_status' => $beforeStatus,
'after_status' => TenantReviewStatus::Archived->value, 'after_status' => TenantReviewStatus::Archived->value,
'reason' => $reason,
], ],
], ],
actor: $user, actor: $user,
@ -171,6 +175,25 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
return $nextReview; return $nextReview;
} }
private function validatedReason(mixed $reason, string $field): string
{
if (! is_string($reason)) {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
$resolved = trim($reason);
if ($resolved === '') {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
if (mb_strlen($resolved) > 2000) {
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
}
return $resolved;
}
private function invalidateArtifactTruthCache(TenantReview $review): void private function invalidateArtifactTruthCache(TenantReview $review): void
{ {
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review'); $this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review');

View File

@ -73,12 +73,34 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
* }|null $state * }|null $state
*/ */
public function resolveState(Tenant $tenant, ?array $state): ?PortfolioArrivalContext public function resolveState(Tenant $tenant, ?array $state): ?PortfolioArrivalContext
{
return $this->resolveStateWithTruth($tenant, $state);
}
/**
* @param array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* }|null $state
* @param array<string, mixed>|null $recoveryEvidence
*/
public function resolveStateWithTruth(
Tenant $tenant,
?array $state,
?TenantBackupHealthAssessment $backupHealth = null,
?array $recoveryEvidence = null,
): ?PortfolioArrivalContext
{ {
if ($state === null || ! $this->matchesTenantScope($tenant, $state)) { if ($state === null || ! $this->matchesTenantScope($tenant, $state)) {
return null; return null;
} }
return $this->buildContext($tenant, $state); return $this->buildContext($tenant, $state, $backupHealth, $recoveryEvidence);
} }
/** /**
@ -149,10 +171,18 @@ private function matchesTenantScope(Tenant $tenant, array $state): bool
* returnFilters: array<string, mixed>|null * returnFilters: array<string, mixed>|null
* } $state * } $state
*/ */
private function buildContext(Tenant $tenant, array $state): PortfolioArrivalContext /**
* @param array<string, mixed>|null $recoveryEvidence
*/
private function buildContext(
Tenant $tenant,
array $state,
?TenantBackupHealthAssessment $backupHealth = null,
?array $recoveryEvidence = null,
): PortfolioArrivalContext
{ {
$backupHealth = $this->tenantBackupHealthResolver->assess($tenant); $backupHealth ??= $this->tenantBackupHealthResolver->assess($tenant);
$recoveryEvidence = $this->restoreSafetyResolver->dashboardRecoveryEvidence($tenant); $recoveryEvidence ??= $this->restoreSafetyResolver->dashboardRecoveryEvidence($tenant);
return new PortfolioArrivalContext( return new PortfolioArrivalContext(
sourceSurface: $state['sourceSurface'], sourceSurface: $state['sourceSurface'],

View File

@ -4,6 +4,9 @@
namespace App\Support\Ui\ActionSurface; namespace App\Support\Ui\ActionSurface;
use App\Filament\Pages\BreakGlassRecovery;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\BaselineCompareMatrix; use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Pages\Monitoring\Alerts; use App\Filament\Pages\Monitoring\Alerts;
@ -13,7 +16,11 @@
use App\Filament\Pages\Monitoring\Operations; use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Pages\Operations\TenantlessOperationRunViewer; use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\Reviews\ReviewRegister; use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Pages\TenantDiagnostics; use App\Filament\Pages\TenantDiagnostics;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Filament\Pages\Workspaces\ManagedTenantsLanding;
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries; use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination; use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet; use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet;
@ -29,6 +36,12 @@
use App\Filament\Resources\TenantResource\Pages\ViewTenant; use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview; use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace; use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
use App\Filament\System\Pages\Dashboard as SystemDashboard;
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
use App\Filament\System\Pages\Ops\Runbooks;
use App\Filament\System\Pages\Ops\ViewRun;
use App\Filament\System\Pages\RepairWorkspaceOwners;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
final class ActionSurfaceExemptions final class ActionSurfaceExemptions
@ -46,7 +59,6 @@ public static function baseline(): self
// Baseline allowlist for legacy surfaces. Keep shrinking this list. // Baseline allowlist for legacy surfaces. Keep shrinking this list.
// Declared system table pages are discovered directly; deferred system tooling stays out of scope by not opting in. // Declared system table pages are discovered directly; deferred system tooling stays out of scope by not opting in.
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.', '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\\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\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.', 'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
@ -541,4 +553,400 @@ public static function spec193MonitoringSurface(string $className): ?array
{ {
return self::spec193MonitoringSurfaceInventory()[$className] ?? null; return self::spec193MonitoringSurfaceInventory()[$className] ?? null;
} }
/**
* @return array<string, array{
* surfaceKey: string,
* surfaceName: string,
* pageClass: string,
* panelPlane: string,
* surfaceKind: string,
* discoveryState: string,
* closureDecision: string,
* reasonCategory: ?string,
* explicitReason: string,
* evidence: array<int, array{
* kind: string,
* reference: string,
* proves: string
* }>,
* followUpAction: string,
* mustRemainBaselineExempt: bool,
* mustNotRemainBaselineExempt: bool
* }>
*/
public static function spec195ResidualSurfaceInventory(): array
{
return [
SystemDashboard::class => [
'surfaceKey' => 'system_dashboard',
'surfaceName' => 'System Console Dashboard',
'pageClass' => SystemDashboard::class,
'panelPlane' => 'system',
'surfaceKind' => 'dashboard_shell',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'The system dashboard keeps its console-window and break-glass controls under dedicated system and recovery tests instead of the generic declaration-backed contract.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec114/ControlTowerDashboardTest.php',
'proves' => 'The control-tower shell keeps its window action and dashboard rendering behavior under focused system coverage.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Auth/BreakGlassModeTest.php',
'proves' => 'Break-glass entry and exit remain confirmed, audited dashboard actions rather than silent utility links.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
ViewRun::class => [
'surfaceKey' => 'system_ops_view_run',
'surfaceName' => 'System Ops View Run',
'pageClass' => ViewRun::class,
'panelPlane' => 'system',
'surfaceKind' => 'system_detail',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'system_triage_surface',
'explicitReason' => 'Run triage remains a dedicated decision surface with confirmed retry, cancel, and investigate behavior instead of fitting the generic declaration-backed list/detail shape.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec114/OpsTriageActionsTest.php',
'proves' => 'The view-run surface keeps explicit navigation, triage actions, and capability-sensitive visibility.',
],
[
'kind' => 'guard_test',
'reference' => 'tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php',
'proves' => 'The retry, cancel, and investigate actions remain part of the governed system action semantics inventory.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
Runbooks::class => [
'surfaceKey' => 'system_ops_runbooks',
'surfaceName' => 'System Ops Runbooks',
'pageClass' => Runbooks::class,
'panelPlane' => 'system',
'surfaceKind' => 'system_utility',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php',
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
],
[
'kind' => 'guard_test',
'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php',
'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
RepairWorkspaceOwners::class => [
'surfaceKey' => 'repair_workspace_owners',
'surfaceName' => 'Repair Workspace Owners',
'pageClass' => RepairWorkspaceOwners::class,
'panelPlane' => 'system',
'surfaceKind' => 'system_utility',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'break_glass_repair_utility',
'explicitReason' => 'Emergency owner repair stays under dedicated break-glass and table guard coverage instead of the generic declaration-backed system-table contract.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php',
'proves' => 'The repair utility requires break-glass context and records audited recovery behavior.',
],
[
'kind' => 'guard_test',
'reference' => 'tests/Feature/Guards/FilamentTableStandardsGuardTest.php',
'proves' => 'The table shell keeps explicit empty-state and table-standard coverage even while remaining outside the primary declaration path.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
SystemDirectoryViewTenant::class => [
'surfaceKey' => 'system_directory_view_tenant',
'surfaceName' => 'System Directory View Tenant',
'pageClass' => SystemDirectoryViewTenant::class,
'panelPlane' => 'system',
'surfaceKind' => 'read_mostly_context',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'read_mostly_context_detail',
'explicitReason' => 'The tenant directory detail page is a read-mostly drilldown that links outward to canonical admin and run surfaces without introducing its own mutating controls.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
'proves' => 'The detail page renders contextual connectivity and recent-run information while staying read-mostly and capability-gated.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/System/Spec114/DirectoryTenantsTest.php',
'proves' => 'Directory-view capability remains required before the detail route becomes visible.',
],
],
'followUpAction' => 'add_focused_test',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
SystemDirectoryViewWorkspace::class => [
'surfaceKey' => 'system_directory_view_workspace',
'surfaceName' => 'System Directory View Workspace',
'pageClass' => SystemDirectoryViewWorkspace::class,
'panelPlane' => 'system',
'surfaceKind' => 'read_mostly_context',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'read_mostly_context_detail',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/System/Spec114/DirectoryWorkspacesTest.php',
'proves' => 'Directory-view capability remains required before workspace directory routes become available.',
],
],
'followUpAction' => 'add_focused_test',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
BreakGlassRecovery::class => [
'surfaceKey' => 'break_glass_recovery',
'surfaceName' => 'Break Glass Recovery',
'pageClass' => BreakGlassRecovery::class,
'panelPlane' => 'admin',
'surfaceKind' => 'recovery_flow',
'discoveryState' => 'primary_discovered',
'closureDecision' => 'retired_no_longer_relevant',
'reasonCategory' => 'disabled_or_actionless_surface',
'explicitReason' => 'The page currently denies access and exposes no header actions, so it should not remain a live baseline exemption.',
'evidence' => [
[
'kind' => 'audit_test',
'reference' => 'app/Filament/Pages/BreakGlassRecovery.php',
'proves' => 'The page returns false from canAccess() and exposes no header actions.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php',
'proves' => 'The active recovery path now lives on the system dashboard and repair utility instead of this retired page shell.',
],
],
'followUpAction' => 'tighten_reason',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
ChooseWorkspace::class => [
'surfaceKey' => 'choose_workspace',
'surfaceName' => 'Choose Workspace',
'pageClass' => ChooseWorkspace::class,
'panelPlane' => 'admin',
'surfaceKind' => 'selector',
'discoveryState' => 'primary_discovered_baseline_exempt',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'selector_routing_only',
'explicitReason' => 'The workspace chooser is a routing-only selector with explicit membership checks and audit logging, not a declaration-backed action table.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Workspaces/ChooseWorkspacePageTest.php',
'proves' => 'The chooser keeps membership-scoped selection, redirect behavior, and deny-as-not-found semantics.',
],
[
'kind' => 'audit_test',
'reference' => 'tests/Feature/Workspaces/WorkspaceAuditTrailTest.php',
'proves' => 'Manual workspace selection remains explicitly audited.',
],
],
'followUpAction' => 'none',
'mustRemainBaselineExempt' => true,
'mustNotRemainBaselineExempt' => false,
],
ChooseTenant::class => [
'surfaceKey' => 'choose_tenant',
'surfaceName' => 'Choose Tenant',
'pageClass' => ChooseTenant::class,
'panelPlane' => 'tenant',
'surfaceKind' => 'selector',
'discoveryState' => 'primary_discovered_baseline_exempt',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'selector_routing_only',
'explicitReason' => 'The tenant chooser is a selector-only surface that filters operable tenants and routes to the tenant dashboard without its own contract-style action surface.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Auth/TenantChooserSelectionTest.php',
'proves' => 'The chooser redirects only for active selectable tenants and rejects non-operable selections with 404.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php',
'proves' => 'Selector eligibility remains narrower than global tenant discoverability and stays tenant-scope aware.',
],
],
'followUpAction' => 'none',
'mustRemainBaselineExempt' => true,
'mustNotRemainBaselineExempt' => false,
],
RegisterTenant::class => [
'surfaceKey' => 'register_tenant',
'surfaceName' => 'Register Tenant',
'pageClass' => RegisterTenant::class,
'panelPlane' => 'admin',
'surfaceKind' => 'wizard',
'discoveryState' => 'primary_discovered_baseline_exempt',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'registration_form_with_dedicated_rbac',
'explicitReason' => 'Tenant registration is a dedicated creation workflow with its own visibility rules, bootstrap membership side effects, and audit logging.',
'evidence' => [
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/Rbac/RegisterTenantAuthorizationTest.php',
'proves' => 'Registration visibility remains explicitly capability-sensitive for owner versus readonly members.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php',
'proves' => 'Registration still bootstraps tenant ownership and audit behavior through the dedicated flow.',
],
],
'followUpAction' => 'none',
'mustRemainBaselineExempt' => true,
'mustNotRemainBaselineExempt' => false,
],
ManagedTenantOnboardingWizard::class => [
'surfaceKey' => 'managed_tenant_onboarding_wizard',
'surfaceName' => 'Managed Tenant Onboarding Wizard',
'pageClass' => ManagedTenantOnboardingWizard::class,
'panelPlane' => 'admin',
'surfaceKind' => 'wizard',
'discoveryState' => 'primary_discovered_baseline_exempt',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'The onboarding wizard is a workflow-specific surface with draft continuity, capability-gated steps, confirmations, and dedicated audit coverage.',
'evidence' => [
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php',
'proves' => 'The wizard enforces capability checks on its interactive paths instead of inheriting the generic declaration contract.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/Onboarding/OnboardingDraftAccessTest.php',
'proves' => 'Workspace and tenant continuity for onboarding drafts remains guarded by dedicated 404 and 403 semantics.',
],
],
'followUpAction' => 'none',
'mustRemainBaselineExempt' => true,
'mustNotRemainBaselineExempt' => false,
],
ManagedTenantsLanding::class => [
'surfaceKey' => 'managed_tenants_landing',
'surfaceName' => 'Managed Tenants Landing',
'pageClass' => ManagedTenantsLanding::class,
'panelPlane' => 'admin',
'surfaceKind' => 'landing',
'discoveryState' => 'primary_discovered_baseline_exempt',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'landing_routing_surface',
'explicitReason' => 'The managed-tenants landing is a workspace routing shell that keeps discoverability and open-tenant navigation explicit without pretending to be a generic declaration-backed table page.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php',
'proves' => 'The landing stays membership-scoped, preserves selector routing, and rejects outsider tenant openings.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Filament/ManagedTenantsLandingLifecycleTest.php',
'proves' => 'The landing intentionally exposes broader administrative discoverability than the tenant chooser.',
],
],
'followUpAction' => 'add_focused_test',
'mustRemainBaselineExempt' => true,
'mustNotRemainBaselineExempt' => false,
],
TenantDashboard::class => [
'surfaceKey' => 'tenant_dashboard',
'surfaceName' => 'Tenant Dashboard',
'pageClass' => TenantDashboard::class,
'panelPlane' => 'tenant',
'surfaceKind' => 'dashboard_shell',
'discoveryState' => 'primary_discovered_baseline_exempt',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'dashboard_shell_widget_owned',
'explicitReason' => 'The tenant dashboard is a widget shell whose meaningful mutations and visibility rules live in its widgets and follow-up routes rather than in page-level generic actions.',
'evidence' => [
[
'kind' => 'db_only_surface_test',
'reference' => 'tests/Feature/Filament/TenantDashboardDbOnlyTest.php',
'proves' => 'The dashboard shell renders DB-only and keeps its main behavior in widget rendering rather than page-level actions.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php',
'proves' => 'Arrival context CTAs remain permission-aware and deny-as-not-found for non-members.',
],
],
'followUpAction' => 'none',
'mustRemainBaselineExempt' => true,
'mustNotRemainBaselineExempt' => false,
],
];
}
/**
* @return array{
* surfaceKey: string,
* surfaceName: string,
* pageClass: string,
* panelPlane: string,
* surfaceKind: string,
* discoveryState: string,
* closureDecision: string,
* reasonCategory: ?string,
* explicitReason: string,
* evidence: array<int, array{
* kind: string,
* reference: string,
* proves: string
* }>,
* followUpAction: string,
* mustRemainBaselineExempt: bool,
* mustNotRemainBaselineExempt: bool
* }|null
*/
public static function spec195ResidualSurface(string $className): ?array
{
return self::spec195ResidualSurfaceInventory()[$className] ?? null;
}
} }

View File

@ -6,6 +6,10 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Pages\Page;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class ActionSurfaceValidator final class ActionSurfaceValidator
{ {
@ -53,9 +57,20 @@ public function validate(): ActionSurfaceValidationResult
public function validateComponents(array $components): ActionSurfaceValidationResult public function validateComponents(array $components): ActionSurfaceValidationResult
{ {
$issues = []; $issues = [];
$discoveredClassNames = array_values(array_unique(array_merge(
array_map(
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
$this->discovery->discover(),
),
array_map(
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
$components,
),
)));
$this->validateSpec193MonitoringSurfaceInventory($issues); $this->validateSpec193MonitoringSurfaceInventory($issues);
$this->validateSpec192RecordPageInventory($issues); $this->validateSpec192RecordPageInventory($issues);
$this->validateSpec195ResidualSurfaceInventory($issues, $discoveredClassNames);
foreach ($components as $component) { foreach ($components as $component) {
if (! class_exists($component->className)) { if (! class_exists($component->className)) {
@ -371,6 +386,341 @@ className: $className,
} }
} }
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
* @param array<int, string> $discoveredClassNames
*/
private function validateSpec195ResidualSurfaceInventory(array &$issues, array $discoveredClassNames): void
{
$issues = array_merge(
$issues,
self::validateSpec195ResidualInventoryFixture(
inventory: ActionSurfaceExemptions::spec195ResidualSurfaceInventory(),
discoveredClasses: $discoveredClassNames,
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
residualCandidateClasses: $this->spec195ResidualCandidateClasses($discoveredClassNames),
),
);
}
/**
* @param array<string, array<string, mixed>> $inventory
* @param array<int, string> $discoveredClasses
* @param array<string, string> $baselineExemptions
* @param array<int, string> $residualCandidateClasses
* @return array<int, ActionSurfaceValidationIssue>
*/
public static function validateSpec195ResidualInventoryFixture(
array $inventory,
array $discoveredClasses,
array $baselineExemptions,
array $residualCandidateClasses = [],
): array {
$issues = [];
$allowedDiscoveryStates = [
'primary_discovered',
'primary_discovered_baseline_exempt',
'outside_primary_discovery',
];
$allowedClosureDecisions = [
'generic_contract_enrollment',
'intentional_exemption',
'separately_governed',
'retired_no_longer_relevant',
'harmless_special_case',
];
$allowedReasonCategories = [
'system_triage_surface',
'workflow_specific_governance',
'break_glass_repair_utility',
'read_mostly_context_detail',
'disabled_or_actionless_surface',
'selector_routing_only',
'registration_form_with_dedicated_rbac',
'landing_routing_surface',
'dashboard_shell_widget_owned',
'security_flow_exception',
];
$allowedPanelPlanes = ['admin', 'tenant', 'system'];
$allowedSurfaceKinds = [
'system_detail',
'system_utility',
'selector',
'wizard',
'landing',
'dashboard_shell',
'recovery_flow',
'read_mostly_context',
];
$allowedFollowUpActions = [
'none',
'tighten_reason',
'add_guard_only',
'add_focused_test',
'consider_enrollment',
];
$allowedEvidenceKinds = [
'guard_test',
'feature_livewire_test',
'authorization_test',
'workflow_spec',
'audit_test',
'db_only_surface_test',
];
$discoveredLookup = array_fill_keys($discoveredClasses, true);
$surfaceKeys = [];
foreach ($inventory as $className => $surface) {
if (! class_exists($className)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 residual inventory references a surface class that does not exist.',
hint: 'Keep ActionSurfaceExemptions::spec195ResidualSurfaceInventory() aligned with the in-scope residual surface classes.',
);
continue;
}
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
if ($surfaceKey === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 residual inventory entry is missing a non-empty surface key.',
hint: 'Provide the stable spec surface key for this residual surface.',
);
} elseif (isset($surfaceKeys[$surfaceKey])) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf('Spec 195 residual surface key "%s" is declared more than once.', $surfaceKey),
hint: 'Each residual surface must have a unique stable key.',
);
} else {
$surfaceKeys[$surfaceKey] = true;
}
if (($surface['pageClass'] ?? null) !== $className) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 residual inventory pageClass must exactly match the keyed class.',
hint: 'Keep the array key and pageClass field aligned for reviewer clarity.',
);
}
if (! is_string($surface['surfaceName'] ?? null) || trim((string) $surface['surfaceName']) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 residual inventory surfaceName must be non-empty.',
hint: 'Use a human-readable review label such as "System Ops View Run".',
);
}
if (! in_array($surface['panelPlane'] ?? null, $allowedPanelPlanes, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 panel plane is invalid or missing.',
hint: 'Use admin, tenant, or system.',
);
}
if (! in_array($surface['surfaceKind'] ?? null, $allowedSurfaceKinds, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 surface kind is invalid or missing.',
hint: 'Use one of the documented residual surface kinds from the logical contract.',
);
}
if (! in_array($surface['closureDecision'] ?? null, $allowedClosureDecisions, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 closure decision is invalid or missing.',
hint: 'Use generic_contract_enrollment, intentional_exemption, separately_governed, retired_no_longer_relevant, or harmless_special_case.',
);
}
$expectedDiscoveryState = isset($discoveredLookup[$className])
? (array_key_exists($className, $baselineExemptions) ? 'primary_discovered_baseline_exempt' : 'primary_discovered')
: 'outside_primary_discovery';
if (($surface['discoveryState'] ?? null) !== $expectedDiscoveryState) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf(
'Spec 195 discovery state is not truthful. Expected "%s".',
$expectedDiscoveryState,
),
hint: 'Keep discoveryState aligned with the primary validator discovery result and baseline exemption registry.',
);
}
if (! in_array($surface['discoveryState'] ?? null, $allowedDiscoveryStates, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 discovery state is invalid or missing.',
hint: 'Use primary_discovered, primary_discovered_baseline_exempt, or outside_primary_discovery.',
);
}
if (! is_string($surface['explicitReason'] ?? null) || trim((string) $surface['explicitReason']) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 explicit reason must be non-empty.',
hint: 'Document the concrete operator or review reason for the chosen closure decision.',
);
}
$closureDecision = (string) ($surface['closureDecision'] ?? '');
$reasonCategory = $surface['reasonCategory'] ?? null;
if ($closureDecision === 'generic_contract_enrollment') {
if (is_string($reasonCategory) && trim($reasonCategory) !== '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Generic-contract enrollment entries must not carry a reason category.',
hint: 'Clear reasonCategory when the residual surface is fully enrolled into the generic contract.',
);
}
if (! method_exists($className, 'actionSurfaceDeclaration')) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Generic-contract enrollment requires actionSurfaceDeclaration().',
hint: 'Enroll the surface into the declaration-backed contract before classifying it as generic_contract_enrollment.',
);
}
} elseif (! in_array($reasonCategory, $allowedReasonCategories, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 reason category is invalid or missing for a non-enrolled residual surface.',
hint: 'Use one of the allowed Spec 195 reason categories for intentional exemptions, separate governance, retired surfaces, and harmless special cases.',
);
}
if (! in_array($surface['followUpAction'] ?? null, $allowedFollowUpActions, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 follow-up action is invalid or missing.',
hint: 'Use none, tighten_reason, add_guard_only, add_focused_test, or consider_enrollment.',
);
}
if (! is_bool($surface['mustRemainBaselineExempt'] ?? null)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 mustRemainBaselineExempt must be boolean.',
hint: 'Use true only when the discovered page must remain in baseline().',
);
}
if (! is_bool($surface['mustNotRemainBaselineExempt'] ?? null)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 mustNotRemainBaselineExempt must be boolean.',
hint: 'Use true when the residual surface must stay out of baseline().',
);
}
if (($surface['mustRemainBaselineExempt'] ?? false) === true && ($surface['mustNotRemainBaselineExempt'] ?? false) === true) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 baseline flags cannot both be true.',
hint: 'A residual surface can either stay in baseline() or be required to stay out of it, but not both.',
);
}
if (($surface['mustRemainBaselineExempt'] ?? false) === true && ! array_key_exists($className, $baselineExemptions)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 says this residual surface must remain baseline-exempt, but baseline() does not include it.',
hint: 'Keep discovered special surfaces aligned between baseline() and spec195ResidualSurfaceInventory().',
);
}
if (($surface['mustNotRemainBaselineExempt'] ?? false) === true && array_key_exists($className, $baselineExemptions)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 says this residual surface must not remain baseline-exempt, but baseline() still includes it.',
hint: 'Remove the stale baseline exemption or change the residual closure classification.',
);
}
$evidence = $surface['evidence'] ?? null;
if (! is_array($evidence) || $evidence === []) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 195 residual surfaces require at least one structured evidence descriptor.',
hint: 'Add one or more evidence entries with kind, reference, and proves.',
);
} else {
foreach ($evidence as $index => $descriptor) {
if (! is_array($descriptor)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf('Spec 195 evidence entry #%d must be an array.', $index + 1),
hint: 'Use structured evidence descriptors with kind, reference, and proves.',
);
continue;
}
if (! in_array($descriptor['kind'] ?? null, $allowedEvidenceKinds, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf('Spec 195 evidence entry #%d kind is invalid or missing.', $index + 1),
hint: 'Use guard_test, feature_livewire_test, authorization_test, workflow_spec, audit_test, or db_only_surface_test.',
);
}
if (! is_string($descriptor['reference'] ?? null) || trim((string) $descriptor['reference']) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf('Spec 195 evidence entry #%d reference must be non-empty.', $index + 1),
hint: 'Point reviewers at the concrete test file or source artifact that proves the classification.',
);
}
if (! is_string($descriptor['proves'] ?? null) || trim((string) $descriptor['proves']) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf('Spec 195 evidence entry #%d proves text must be non-empty.', $index + 1),
hint: 'Explain what the referenced evidence actually proves about the residual surface.',
);
}
}
}
}
$candidateClasses = array_values(array_unique(array_merge(
$residualCandidateClasses,
self::spec195BaselineResidualCandidateClasses($baselineExemptions),
)));
sort($candidateClasses);
foreach ($candidateClasses as $className) {
if (array_key_exists($className, $inventory)) {
continue;
}
$classPath = self::spec195ClassPath($className);
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Residual action surface is missing a Spec 195 closure entry.',
hint: $classPath !== null
? sprintf(
'Add %s to ActionSurfaceExemptions::spec195ResidualSurfaceInventory() and classify it via the reviewer workflow. File: %s',
$className,
$classPath,
)
: 'Add the missing residual surface to ActionSurfaceExemptions::spec195ResidualSurfaceInventory() and classify it via the reviewer workflow.',
);
}
return $issues;
}
/** /**
* @param array<int, ActionSurfaceValidationIssue> $issues * @param array<int, ActionSurfaceValidationIssue> $issues
*/ */
@ -419,10 +769,26 @@ private function validateClassExemptionOrFail(string $className, array &$issues)
$reason = $this->exemptions->reasonForClass($className); $reason = $this->exemptions->reasonForClass($className);
if ($reason === null) { if ($reason === null) {
$residualSurface = ActionSurfaceExemptions::spec195ResidualSurface($className);
if ($residualSurface !== null) {
$closureDecision = (string) ($residualSurface['closureDecision'] ?? '');
if ($closureDecision === 'generic_contract_enrollment') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Residual surface is marked for generic-contract enrollment but still lacks actionSurfaceDeclaration().',
hint: 'Add actionSurfaceDeclaration() or change the Spec 195 closure decision if the surface is intentionally staying separate.',
);
}
return;
}
$issues[] = new ActionSurfaceValidationIssue( $issues[] = new ActionSurfaceValidationIssue(
className: $className, className: $className,
message: 'Missing action-surface declaration and no component exemption exists.', message: 'Missing action-surface declaration, baseline exemption, and Spec 195 residual closure entry.',
hint: 'Add actionSurfaceDeclaration() or register a baseline exemption with a non-empty reason.', hint: 'Add actionSurfaceDeclaration(), register a baseline exemption with a non-empty reason, or classify the surface in spec195ResidualSurfaceInventory().',
); );
return; return;
@ -653,4 +1019,142 @@ className: $className,
hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.', hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.',
); );
} }
/**
* @param array<int, string> $discoveredClassNames
* @return array<int, string>
*/
private function spec195ResidualCandidateClasses(array $discoveredClassNames): array
{
$candidates = $this->spec195SystemResidualCandidateClasses($discoveredClassNames);
foreach (self::spec195BaselineResidualCandidateClasses(ActionSurfaceExemptions::baseline()->all()) as $className) {
$candidates[] = $className;
}
$candidates = array_values(array_unique($candidates));
sort($candidates);
return $candidates;
}
/**
* @param array<int, string> $discoveredClassNames
* @return array<int, string>
*/
private function spec195SystemResidualCandidateClasses(array $discoveredClassNames): array
{
$discoveredLookup = array_fill_keys($discoveredClassNames, true);
$classes = [];
foreach ($this->collectPhpClasses($this->appFilamentSystemPagesPath()) as $className) {
if (isset($discoveredLookup[$className])) {
continue;
}
if ($className === 'App\\Filament\\System\\Pages\\Auth\\Login') {
continue;
}
if (! class_exists($className) || ! is_subclass_of($className, Page::class)) {
continue;
}
$classes[] = $className;
}
sort($classes);
return $classes;
}
private function appFilamentSystemPagesPath(): string
{
return base_path('app/Filament/System/Pages');
}
/**
* @param array<string, string> $baselineExemptions
* @return array<int, string>
*/
private static function spec195BaselineResidualCandidateClasses(array $baselineExemptions): array
{
$classes = [];
foreach (array_keys($baselineExemptions) as $className) {
if (! self::qualifiesAsSpec195BaselineResidualCandidate($className)) {
continue;
}
$classes[] = $className;
}
sort($classes);
return $classes;
}
private static function qualifiesAsSpec195BaselineResidualCandidate(string $className): bool
{
if (in_array($className, [
'App\\Filament\\Pages\\BreakGlassRecovery',
'App\\Filament\\Pages\\ChooseTenant',
'App\\Filament\\Pages\\ChooseWorkspace',
'App\\Filament\\Pages\\TenantDashboard',
], true)) {
return true;
}
return str_starts_with($className, 'App\\Filament\\Pages\\Tenancy\\')
|| str_starts_with($className, 'App\\Filament\\Pages\\Workspaces\\');
}
/**
* @return array<int, string>
*/
private function collectPhpClasses(string $directory): array
{
if (! is_dir($directory)) {
return [];
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
);
$classes = [];
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.php')) {
continue;
}
$classes[] = $this->classNameFromPath($file->getPathname());
}
sort($classes);
return $classes;
}
private function classNameFromPath(string $path): string
{
$normalizedPath = str_replace('\\', '/', $path);
$normalizedAppPath = str_replace('\\', '/', app_path());
$relativePath = ltrim(substr($normalizedPath, strlen($normalizedAppPath)), '/');
return 'App\\'.str_replace('/', '\\', substr($relativePath, 0, -4));
}
private static function spec195ClassPath(string $className): ?string
{
if (! str_starts_with($className, 'App\\')) {
return null;
}
$path = base_path('app/'.str_replace('\\', '/', substr($className, 4)).'.php');
return is_file($path) ? $path : null;
}
} }

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceActions\Enums;
enum GovernanceFrictionClass: string
{
case F0 = 'F0';
case F1 = 'F1';
case F2 = 'F2';
case F3 = 'F3';
public function requiresConfirmation(): bool
{
return $this !== self::F0;
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceActions\Enums;
enum GovernanceReasonPolicy: string
{
case None = 'none';
case Optional = 'optional';
case Required = 'required';
public function requiresReason(): bool
{
return $this === self::Required;
}
}

View File

@ -0,0 +1,637 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceActions;
use App\Support\Ui\GovernanceActions\Enums\GovernanceFrictionClass;
use App\Support\Ui\GovernanceActions\Enums\GovernanceReasonPolicy;
use InvalidArgumentException;
final class GovernanceActionCatalog
{
/**
* @return array<string, array{
* familyKey: string,
* canonicalObject: string,
* panels: array<int, string>,
* surfaceKeys: array<int, string>,
* defaultActionOrder: array<int, string>,
* supportsDocumentedDeviation: bool,
* defaultMutationScopeSource: string
* }>
*/
public static function families(): array
{
return [
'exception_decision' => [
'familyKey' => 'exception_decision',
'canonicalObject' => 'exception',
'panels' => ['admin', 'tenant'],
'surfaceKeys' => ['finding_exceptions_queue', 'view_finding_exception', 'view_finding'],
'defaultActionOrder' => [
'approve_exception',
'reject_exception',
'renew_exception',
'revoke_exception',
],
'supportsDocumentedDeviation' => true,
'defaultMutationScopeSource' => 'exception governance',
],
'review_lifecycle' => [
'familyKey' => 'review_lifecycle',
'canonicalObject' => 'review',
'panels' => ['tenant'],
'surfaceKeys' => ['view_tenant_review'],
'defaultActionOrder' => ['refresh_review', 'publish_review', 'archive_review'],
'supportsDocumentedDeviation' => true,
'defaultMutationScopeSource' => 'tenant review lifecycle',
],
'evidence_lifecycle' => [
'familyKey' => 'evidence_lifecycle',
'canonicalObject' => 'snapshot',
'panels' => ['tenant'],
'surfaceKeys' => ['list_evidence_snapshots', 'view_evidence_snapshot'],
'defaultActionOrder' => ['refresh_evidence', 'expire_snapshot'],
'supportsDocumentedDeviation' => true,
'defaultMutationScopeSource' => 'evidence lifecycle',
],
'run_triage' => [
'familyKey' => 'run_triage',
'canonicalObject' => 'run',
'panels' => ['system'],
'surfaceKeys' => ['system_view_run'],
'defaultActionOrder' => ['retry_run', 'mark_investigated', 'cancel_run'],
'supportsDocumentedDeviation' => true,
'defaultMutationScopeSource' => 'run triage',
],
'finding_lifecycle' => [
'familyKey' => 'finding_lifecycle',
'canonicalObject' => 'finding',
'panels' => ['tenant'],
'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'],
'defaultActionOrder' => ['close_finding', 'reopen_finding'],
'supportsDocumentedDeviation' => false,
'defaultMutationScopeSource' => 'finding lifecycle',
],
'tenant_lifecycle' => [
'familyKey' => 'tenant_lifecycle',
'canonicalObject' => 'tenant',
'panels' => ['admin'],
'surfaceKeys' => ['tenant_index_row', 'view_tenant', 'edit_tenant'],
'defaultActionOrder' => ['archive_tenant', 'restore_tenant'],
'supportsDocumentedDeviation' => true,
'defaultMutationScopeSource' => 'tenant lifecycle',
],
];
}
/**
* @return array<string, GovernanceActionRule>
*/
public static function rules(): array
{
return [
'approve_exception' => new GovernanceActionRule(
actionKey: 'approve_exception',
familyKey: 'exception_decision',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Approve exception',
modalHeading: 'Approve exception',
modalDescription: 'Approve this exception request for the selected tenant and linked finding. TenantPilot updates the governed exception decision and risk-acceptance continuity only.',
successTitle: 'Exception approved',
auditVerb: 'approve exception',
serviceOwner: 'FindingExceptionService',
surfaceKeys: ['finding_exceptions_queue'],
),
'reject_exception' => new GovernanceActionRule(
actionKey: 'reject_exception',
familyKey: 'exception_decision',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'contextual',
canonicalLabel: 'Reject exception',
modalHeading: 'Reject exception',
modalDescription: 'Reject this exception request for the selected tenant and linked finding. TenantPilot records the governance decision and leaves the finding out of governed risk acceptance.',
successTitle: 'Exception rejected',
auditVerb: 'reject exception',
serviceOwner: 'FindingExceptionService',
surfaceKeys: ['finding_exceptions_queue'],
),
'renew_exception' => new GovernanceActionRule(
actionKey: 'renew_exception',
familyKey: 'exception_decision',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Renew exception',
modalHeading: 'Renew exception',
modalDescription: 'Submit a renewal request for this governed exception. TenantPilot records the request and keeps the formal decision pending until it is reviewed.',
successTitle: 'Renewal request submitted',
auditVerb: 'renew exception',
serviceOwner: 'FindingExceptionService',
surfaceKeys: ['view_finding_exception', 'view_finding'],
),
'revoke_exception' => new GovernanceActionRule(
actionKey: 'revoke_exception',
familyKey: 'exception_decision',
frictionClass: GovernanceFrictionClass::F3,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'required',
canonicalLabel: 'Revoke exception',
modalHeading: 'Revoke exception',
modalDescription: 'Revoke this active exception for the tenant and linked finding. TenantPilot records the revocation and removes the governed exception support.',
successTitle: 'Exception revoked',
auditVerb: 'revoke exception',
serviceOwner: 'FindingExceptionService',
surfaceKeys: ['view_finding_exception', 'view_finding'],
),
'refresh_review' => new GovernanceActionRule(
actionKey: 'refresh_review',
familyKey: 'review_lifecycle',
frictionClass: GovernanceFrictionClass::F1,
reasonPolicy: GovernanceReasonPolicy::None,
dangerPolicy: 'none',
canonicalLabel: 'Refresh review',
modalHeading: 'Refresh review',
modalDescription: 'Refresh this tenant review from the latest eligible evidence basis. TenantPilot queues a recomputation for this review and keeps existing publication history untouched.',
successTitle: 'Refresh review queued',
auditVerb: 'refresh review',
serviceOwner: 'TenantReviewService',
surfaceKeys: ['view_tenant_review'],
),
'publish_review' => new GovernanceActionRule(
actionKey: 'publish_review',
familyKey: 'review_lifecycle',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Publish review',
modalHeading: 'Publish review',
modalDescription: 'Publish this tenant review as the current governed review outcome for this tenant. TenantPilot records the publication decision only.',
successTitle: 'Review published',
auditVerb: 'publish review',
serviceOwner: 'TenantReviewLifecycleService',
surfaceKeys: ['view_tenant_review'],
),
'archive_review' => new GovernanceActionRule(
actionKey: 'archive_review',
familyKey: 'review_lifecycle',
frictionClass: GovernanceFrictionClass::F3,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'required',
canonicalLabel: 'Archive review',
modalHeading: 'Archive review',
modalDescription: 'Archive this tenant review so it stays historical only. TenantPilot preserves the evidence history but removes the review from active lifecycle work.',
successTitle: 'Review archived',
auditVerb: 'archive review',
serviceOwner: 'TenantReviewLifecycleService',
surfaceKeys: ['view_tenant_review'],
),
'refresh_evidence' => new GovernanceActionRule(
actionKey: 'refresh_evidence',
familyKey: 'evidence_lifecycle',
frictionClass: GovernanceFrictionClass::F1,
reasonPolicy: GovernanceReasonPolicy::None,
dangerPolicy: 'none',
canonicalLabel: 'Refresh evidence',
modalHeading: 'Refresh evidence',
modalDescription: 'Refresh content evidence for this tenant. TenantPilot queues a new snapshot and leaves existing governed snapshots intact.',
successTitle: 'Refresh evidence queued',
auditVerb: 'refresh evidence',
serviceOwner: 'EvidenceSnapshotService',
surfaceKeys: ['view_evidence_snapshot'],
),
'expire_snapshot' => new GovernanceActionRule(
actionKey: 'expire_snapshot',
familyKey: 'evidence_lifecycle',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'required',
canonicalLabel: 'Expire snapshot',
modalHeading: 'Expire snapshot',
modalDescription: 'Expire this evidence snapshot for the current tenant. TenantPilot records that the snapshot is no longer valid for governance use.',
successTitle: 'Snapshot expired',
auditVerb: 'expire snapshot',
serviceOwner: 'EvidenceSnapshotService',
surfaceKeys: ['list_evidence_snapshots', 'view_evidence_snapshot'],
),
'retry_run' => new GovernanceActionRule(
actionKey: 'retry_run',
familyKey: 'run_triage',
frictionClass: GovernanceFrictionClass::F1,
reasonPolicy: GovernanceReasonPolicy::None,
dangerPolicy: 'none',
canonicalLabel: 'Retry',
modalHeading: 'Retry run',
modalDescription: 'Retry this failed run. TenantPilot queues a new run and preserves the original run history.',
successTitle: 'Retry queued',
auditVerb: 'retry run',
serviceOwner: 'OperationRunTriageService',
surfaceKeys: ['system_view_run'],
),
'mark_investigated' => new GovernanceActionRule(
actionKey: 'mark_investigated',
familyKey: 'run_triage',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Mark investigated',
modalHeading: 'Mark investigated',
modalDescription: 'Mark this run as investigated. TenantPilot records the triage rationale on this run only.',
successTitle: 'Run marked as investigated',
auditVerb: 'mark investigated',
serviceOwner: 'OperationRunTriageService',
surfaceKeys: ['system_view_run'],
),
'cancel_run' => new GovernanceActionRule(
actionKey: 'cancel_run',
familyKey: 'run_triage',
frictionClass: GovernanceFrictionClass::F3,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'required',
canonicalLabel: 'Cancel',
modalHeading: 'Cancel run',
modalDescription: 'Cancel this in-flight run. TenantPilot records the cancellation reason and marks the run as failed.',
successTitle: 'Run cancelled',
auditVerb: 'cancel run',
serviceOwner: 'OperationRunTriageService',
surfaceKeys: ['system_view_run'],
),
'close_finding' => new GovernanceActionRule(
actionKey: 'close_finding',
familyKey: 'finding_lifecycle',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Close',
modalHeading: 'Close finding',
modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.',
successTitle: 'Finding closed',
auditVerb: 'close finding',
serviceOwner: 'FindingWorkflowService',
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
),
'reopen_finding' => new GovernanceActionRule(
actionKey: 'reopen_finding',
familyKey: 'finding_lifecycle',
frictionClass: GovernanceFrictionClass::F2,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'none',
canonicalLabel: 'Reopen',
modalHeading: 'Reopen finding',
modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.',
successTitle: 'Finding reopened',
auditVerb: 'reopen finding',
serviceOwner: 'FindingWorkflowService',
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
),
'archive_tenant' => new GovernanceActionRule(
actionKey: 'archive_tenant',
familyKey: 'tenant_lifecycle',
frictionClass: GovernanceFrictionClass::F3,
reasonPolicy: GovernanceReasonPolicy::Required,
dangerPolicy: 'required',
canonicalLabel: 'Archive',
modalHeading: 'Archive tenant',
modalDescription: 'Archive this tenant. TenantPilot keeps it available for inspection and audit history but removes it from active management flows.',
successTitle: 'Tenant archived',
auditVerb: 'archive tenant',
serviceOwner: 'TenantResource',
surfaceKeys: ['tenant_index_row', 'view_tenant', 'edit_tenant'],
),
'restore_tenant' => new GovernanceActionRule(
actionKey: 'restore_tenant',
familyKey: 'tenant_lifecycle',
frictionClass: GovernanceFrictionClass::F1,
reasonPolicy: GovernanceReasonPolicy::None,
dangerPolicy: 'none',
canonicalLabel: 'Restore',
modalHeading: 'Restore tenant',
modalDescription: 'Restore this tenant so it becomes available again in normal management flows.',
successTitle: 'Tenant restored',
auditVerb: 'restore tenant',
serviceOwner: 'TenantResource',
surfaceKeys: ['tenant_index_row', 'view_tenant', 'edit_tenant'],
),
];
}
public static function rule(string $actionKey): GovernanceActionRule
{
$rule = static::rules()[$actionKey] ?? null;
if (! $rule instanceof GovernanceActionRule) {
throw new InvalidArgumentException(sprintf('Unknown governance action "%s".', $actionKey));
}
return $rule;
}
/**
* @return array<int, array{
* surfaceKey: string,
* pageClass: string,
* actionName: string,
* familyKey: string,
* statePredicate: string,
* primaryOrSecondary: string,
* capabilityKey: string|null,
* uiFieldKey: string|null,
* auditChannel: string
* }>
*/
public static function surfaceBindings(): array
{
return [
[
'surfaceKey' => 'finding_exceptions_queue',
'pageClass' => 'App\\Filament\\Pages\\Monitoring\\FindingExceptionsQueue',
'actionName' => 'approve_selected_exception',
'familyKey' => 'exception_decision',
'statePredicate' => 'selected exception is pending',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'finding_exception.approve',
'uiFieldKey' => 'approval_reason',
'auditChannel' => 'tenant_audit',
],
[
'surfaceKey' => 'finding_exceptions_queue',
'pageClass' => 'App\\Filament\\Pages\\Monitoring\\FindingExceptionsQueue',
'actionName' => 'reject_selected_exception',
'familyKey' => 'exception_decision',
'statePredicate' => 'selected exception is pending',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'finding_exception.approve',
'uiFieldKey' => 'rejection_reason',
'auditChannel' => 'tenant_audit',
],
[
'surfaceKey' => 'view_finding_exception',
'pageClass' => 'App\\Filament\\Resources\\FindingExceptionResource\\Pages\\ViewFindingException',
'actionName' => 'renew_exception',
'familyKey' => 'exception_decision',
'statePredicate' => 'exception can be renewed',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'finding_exception.manage',
'uiFieldKey' => 'request_reason',
'auditChannel' => 'tenant_audit',
],
[
'surfaceKey' => 'view_finding_exception',
'pageClass' => 'App\\Filament\\Resources\\FindingExceptionResource\\Pages\\ViewFindingException',
'actionName' => 'revoke_exception',
'familyKey' => 'exception_decision',
'statePredicate' => 'exception can be revoked',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'finding_exception.manage',
'uiFieldKey' => 'revocation_reason',
'auditChannel' => 'tenant_audit',
],
[
'surfaceKey' => 'view_evidence_snapshot',
'pageClass' => 'App\\Filament\\Resources\\EvidenceSnapshotResource\\Pages\\ViewEvidenceSnapshot',
'actionName' => 'refresh_evidence',
'familyKey' => 'evidence_lifecycle',
'statePredicate' => 'snapshot is visible to tenant operator',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'evidence.manage',
'uiFieldKey' => null,
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'view_evidence_snapshot',
'pageClass' => 'App\\Filament\\Resources\\EvidenceSnapshotResource\\Pages\\ViewEvidenceSnapshot',
'actionName' => 'expire_snapshot',
'familyKey' => 'evidence_lifecycle',
'statePredicate' => 'snapshot can expire',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'evidence.manage',
'uiFieldKey' => 'expiration_reason',
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'list_evidence_snapshots',
'pageClass' => 'App\\Filament\\Resources\\EvidenceSnapshotResource',
'actionName' => 'expire',
'familyKey' => 'evidence_lifecycle',
'statePredicate' => 'snapshot can expire',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'evidence.manage',
'uiFieldKey' => 'expiration_reason',
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'view_tenant_review',
'pageClass' => 'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
'actionName' => 'refresh_review',
'familyKey' => 'review_lifecycle',
'statePredicate' => 'review is mutable',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'tenant_review.manage',
'uiFieldKey' => null,
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'view_tenant_review',
'pageClass' => 'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
'actionName' => 'publish_review',
'familyKey' => 'review_lifecycle',
'statePredicate' => 'review is mutable and ready to publish',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'tenant_review.manage',
'uiFieldKey' => 'publish_reason',
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'view_tenant_review',
'pageClass' => 'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
'actionName' => 'archive_review',
'familyKey' => 'review_lifecycle',
'statePredicate' => 'review is not terminal',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'tenant_review.manage',
'uiFieldKey' => 'archive_reason',
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'system_view_run',
'pageClass' => 'App\\Filament\\System\\Pages\\Ops\\ViewRun',
'actionName' => 'retry',
'familyKey' => 'run_triage',
'statePredicate' => 'run is retryable',
'primaryOrSecondary' => 'primary',
'capabilityKey' => 'platform.operations.manage',
'uiFieldKey' => null,
'auditChannel' => 'system_audit',
],
[
'surfaceKey' => 'system_view_run',
'pageClass' => 'App\\Filament\\System\\Pages\\Ops\\ViewRun',
'actionName' => 'mark_investigated',
'familyKey' => 'run_triage',
'statePredicate' => 'run is triage-owned',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'platform.operations.manage',
'uiFieldKey' => 'reason',
'auditChannel' => 'system_audit',
],
[
'surfaceKey' => 'system_view_run',
'pageClass' => 'App\\Filament\\System\\Pages\\Ops\\ViewRun',
'actionName' => 'cancel',
'familyKey' => 'run_triage',
'statePredicate' => 'run is cancellable',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'platform.operations.manage',
'uiFieldKey' => 'reason',
'auditChannel' => 'system_audit',
],
[
'surfaceKey' => 'view_finding',
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
'actionName' => 'close',
'familyKey' => 'finding_lifecycle',
'statePredicate' => 'finding has open status',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'tenant_findings.close',
'uiFieldKey' => 'closed_reason',
'auditChannel' => 'tenant_audit',
],
[
'surfaceKey' => 'view_finding',
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
'actionName' => 'reopen',
'familyKey' => 'finding_lifecycle',
'statePredicate' => 'finding has terminal status',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'tenant_findings.triage',
'uiFieldKey' => 'reopen_reason',
'auditChannel' => 'tenant_audit',
],
[
'surfaceKey' => 'view_tenant',
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\ViewTenant',
'actionName' => 'archive',
'familyKey' => 'tenant_lifecycle',
'statePredicate' => 'tenant is active',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'tenant.delete',
'uiFieldKey' => 'archive_reason',
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'view_tenant',
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\ViewTenant',
'actionName' => 'restore',
'familyKey' => 'tenant_lifecycle',
'statePredicate' => 'tenant is archived',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'tenant.delete',
'uiFieldKey' => null,
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'edit_tenant',
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\EditTenant',
'actionName' => 'archive',
'familyKey' => 'tenant_lifecycle',
'statePredicate' => 'tenant is active',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'tenant.delete',
'uiFieldKey' => 'archive_reason',
'auditChannel' => 'workspace_audit',
],
[
'surfaceKey' => 'edit_tenant',
'pageClass' => 'App\\Filament\\Resources\\TenantResource\\Pages\\EditTenant',
'actionName' => 'restore',
'familyKey' => 'tenant_lifecycle',
'statePredicate' => 'tenant is archived',
'primaryOrSecondary' => 'secondary',
'capabilityKey' => 'tenant.delete',
'uiFieldKey' => null,
'auditChannel' => 'workspace_audit',
],
];
}
/**
* @return array<int, array{
* actionKey: string,
* surfaceKey: string,
* deviationType: string,
* rationale: string,
* reviewGate: string,
* allowedUntil: string|null
* }>
*/
public static function documentedDeviations(): array
{
return [
[
'actionKey' => 'reject_exception',
'surfaceKey' => 'finding_exceptions_queue',
'deviationType' => 'danger_override',
'rationale' => 'Reject stays visually distinct from approval without escalating into the F3 destructive family.',
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
'allowedUntil' => null,
],
[
'actionKey' => 'refresh_evidence',
'surfaceKey' => 'view_evidence_snapshot',
'deviationType' => 'reason_override',
'rationale' => 'Refresh evidence remains an F1 action with no operator-entered rationale in the current release.',
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
'allowedUntil' => null,
],
[
'actionKey' => 'retry_run',
'surfaceKey' => 'system_view_run',
'deviationType' => 'reason_override',
'rationale' => 'Retry stays queue-first and does not collect free-text rationale unless a documented future case requires it.',
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
'allowedUntil' => null,
],
[
'actionKey' => 'restore_tenant',
'surfaceKey' => 'view_tenant',
'deviationType' => 'reason_override',
'rationale' => 'Restore remains a confirmed F1 lifecycle action with no required rationale in the current release.',
'reviewGate' => 'Spec194GovernanceActionSemanticsGuardTest',
'allowedUntil' => null,
],
];
}
/**
* @return array<int, array{
* surfaceKey: string,
* pageClass: string,
* actionName: string,
* familyKey: string,
* statePredicate: string,
* primaryOrSecondary: string,
* capabilityKey: string|null,
* uiFieldKey: string|null,
* auditChannel: string
* }>
*/
public static function bindingsForSurface(string $surfaceKey): array
{
return array_values(array_filter(
static::surfaceBindings(),
static fn (array $binding): bool => $binding['surfaceKey'] === $surfaceKey,
));
}
/**
* @return array<int, string>
*/
public static function actionKeys(): array
{
return array_keys(static::rules());
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceActions;
use App\Support\Ui\GovernanceActions\Enums\GovernanceFrictionClass;
use App\Support\Ui\GovernanceActions\Enums\GovernanceReasonPolicy;
final readonly class GovernanceActionRule
{
/**
* @param array<int, string> $surfaceKeys
*/
public function __construct(
public string $actionKey,
public string $familyKey,
public GovernanceFrictionClass $frictionClass,
public GovernanceReasonPolicy $reasonPolicy,
public string $dangerPolicy,
public string $canonicalLabel,
public string $modalHeading,
public string $modalDescription,
public string $successTitle,
public string $auditVerb,
public string $serviceOwner,
public array $surfaceKeys = [],
) {}
public function requiresConfirmation(): bool
{
return $this->frictionClass->requiresConfirmation();
}
public function requiresReason(): bool
{
return $this->reasonPolicy->requiresReason();
}
public function requiresDangerSeparation(): bool
{
return $this->dangerPolicy === 'required';
}
}

View File

@ -4,6 +4,9 @@
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
@ -11,8 +14,14 @@
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->prepend(SuppressDebugbarForSmokeRequests::class);
$middleware->encryptCookies(except: [
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
]);
$middleware->web(prepend: [ $middleware->web(prepend: [
\App\Http\Middleware\UseSystemSessionCookieForLivewireRequests::class, UseSystemSessionCookieForLivewireRequests::class,
]); ]);
$middleware->alias([ $middleware->alias([

View File

@ -10,12 +10,14 @@
use App\Http\Controllers\SelectTenantController; use App\Http\Controllers\SelectTenantController;
use App\Http\Controllers\SwitchWorkspaceController; use App\Http\Controllers\SwitchWorkspaceController;
use App\Http\Controllers\TenantOnboardingController; use App\Http\Controllers\TenantOnboardingController;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Onboarding\OnboardingDraftResolver; use App\Services\Onboarding\OnboardingDraftResolver;
use App\Support\Auth\WorkspaceRole;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory; use App\Support\Tenants\TenantPageCategory;
@ -65,7 +67,213 @@
->middleware('throttle:entra-callback') ->middleware('throttle:entra-callback')
->name('auth.entra.callback'); ->name('auth.entra.callback');
Route::get('/admin/local/backup-health-browser-fixture-login', function (Request $request) { $makeSmokeCookie = static fn () => cookie()->make(
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
120,
);
$resolveSmokeTenant = static function (?string $identifier): ?Tenant {
$identifier = trim((string) $identifier);
if ($identifier === '') {
return null;
}
return Tenant::query()
->withTrashed()
->where(function ($query) use ($identifier): void {
$query->where('external_id', $identifier)
->orWhere('tenant_id', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
}
})
->first();
};
$resolveSmokeWorkspace = static function (?string $identifier, ?Tenant $tenant = null): ?Workspace {
if ($tenant instanceof Tenant) {
return Workspace::query()->whereKey($tenant->workspace_id)->first();
}
$identifier = trim((string) $identifier);
if ($identifier === '') {
return null;
}
return Workspace::query()
->where(function ($query) use ($identifier): void {
$query->where('slug', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
}
})
->first();
};
$resolveSmokeRedirect = static function (?string $redirect, ?Tenant $tenant = null): string {
$fallback = $tenant instanceof Tenant && ! $tenant->trashed()
? '/admin/t/'.$tenant->external_id
: '/admin';
$redirect = trim((string) $redirect);
if ($redirect === '') {
return $fallback;
}
$parsedRedirect = parse_url($redirect);
if ($parsedRedirect === false || isset($parsedRedirect['scheme']) || isset($parsedRedirect['host'])) {
return $fallback;
}
$path = '/'.ltrim((string) ($parsedRedirect['path'] ?? ''), '/');
if ($path !== '/admin' && ! str_starts_with($path, '/admin/')) {
return $fallback;
}
$query = isset($parsedRedirect['query']) ? '?'.$parsedRedirect['query'] : '';
$fragment = isset($parsedRedirect['fragment']) ? '#'.$parsedRedirect['fragment'] : '';
return $path.$query.$fragment;
};
$resolveSmokeUser = static function (?string $email, ?Workspace $workspace = null, ?Tenant $tenant = null): ?User {
$email = trim((string) $email);
if ($email !== '') {
$user = User::query()->where('email', $email)->first();
return $user instanceof User ? $user : null;
}
$scopedWorkspace = $workspace;
if (! $scopedWorkspace instanceof Workspace && $tenant instanceof Tenant) {
$scopedWorkspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
}
if (! $scopedWorkspace instanceof Workspace) {
return null;
}
$rolePriority = [
WorkspaceRole::Owner->value => 0,
WorkspaceRole::Manager->value => 1,
WorkspaceRole::Operator->value => 2,
WorkspaceRole::Readonly->value => 3,
];
$users = User::query()
->whereHas('workspaceMemberships', function ($query) use ($scopedWorkspace): void {
$query->where('workspace_id', (int) $scopedWorkspace->getKey());
})
->when($tenant instanceof Tenant, function ($query) use ($tenant): void {
$query->whereHas('tenantMemberships', function ($membershipQuery) use ($tenant): void {
$membershipQuery->where('tenant_id', (int) $tenant->getKey());
});
})
->with(['workspaceMemberships' => function ($query) use ($scopedWorkspace): void {
$query->where('workspace_id', (int) $scopedWorkspace->getKey());
}])
->get()
->filter(function (User $user) use ($tenant): bool {
return ! $tenant instanceof Tenant || $user->canAccessTenant($tenant);
})
->sortBy(function (User $user) use ($rolePriority): array {
$role = $user->workspaceMemberships->first()?->role;
return [
$rolePriority[(string) $role] ?? 99,
(int) $user->getKey(),
];
})
->values();
$user = $users->first();
return $user instanceof User ? $user : null;
};
$completeSmokeLogin = static function (
Request $request,
?string $email = null,
?string $tenantIdentifier = null,
?string $workspaceIdentifier = null,
?string $redirect = null,
) use (
$makeSmokeCookie,
$resolveSmokeRedirect,
$resolveSmokeTenant,
$resolveSmokeUser,
$resolveSmokeWorkspace,
): \Illuminate\Http\RedirectResponse {
$tenant = $resolveSmokeTenant($tenantIdentifier);
$workspace = $resolveSmokeWorkspace($workspaceIdentifier, $tenant);
$user = $resolveSmokeUser($email, $workspace, $tenant);
abort_unless($user instanceof User, 404);
$workspaceContext = app(WorkspaceContext::class);
if (! $workspace instanceof Workspace) {
$workspace = $workspaceContext->resolveInitialWorkspaceFor($user, $request);
}
abort_unless($workspace instanceof Workspace, 404);
abort_unless($workspaceContext->isMember($user, $workspace), 404);
if ($tenant instanceof Tenant) {
abort_unless((int) $tenant->workspace_id === (int) $workspace->getKey(), 404);
abort_unless($user->canAccessTenant($tenant), 404);
}
Auth::guard('web')->login($user);
$request->session()->regenerate();
$request->session()->put(
SuppressDebugbarForSmokeRequests::SESSION_KEY,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
);
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
if ($tenant instanceof Tenant) {
$workspaceContext->rememberTenantContext($tenant, $request);
} else {
$workspaceContext->clearRememberedTenantContext($request);
}
return redirect()
->to($resolveSmokeRedirect($redirect, $tenant))
->withCookie($makeSmokeCookie());
};
Route::get('/admin/local/smoke-login', function (Request $request) use ($completeSmokeLogin) {
abort_unless(app()->environment(['local', 'testing']), 404);
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
$defaultEmail = is_array($fixture) ? data_get($fixture, 'user.email') : null;
$defaultTenant = is_array($fixture)
? (data_get($fixture, 'blocked_drillthrough.tenant_external_id') ?? data_get($fixture, 'blocked_drillthrough.tenant_id'))
: null;
$defaultWorkspace = is_array($fixture) ? data_get($fixture, 'workspace.slug') : null;
return $completeSmokeLogin(
$request,
email: (string) ($request->query('email', $defaultEmail ?? '')),
tenantIdentifier: (string) ($request->query('tenant', $defaultTenant ?? '')),
workspaceIdentifier: (string) ($request->query('workspace', $defaultWorkspace ?? '')),
redirect: (string) ($request->query('redirect', '')),
);
})->name('admin.local.smoke-login');
Route::get('/admin/local/backup-health-browser-fixture-login', function (Request $request) use ($completeSmokeLogin) {
abort_unless(app()->environment(['local', 'testing']), 404); abort_unless(app()->environment(['local', 'testing']), 404);
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture'); $fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
@ -77,18 +285,12 @@
abort_unless(is_string($userEmail) && $userEmail !== '', 404); abort_unless(is_string($userEmail) && $userEmail !== '', 404);
abort_unless(is_string($tenantRouteKey) && $tenantRouteKey !== '', 404); abort_unless(is_string($tenantRouteKey) && $tenantRouteKey !== '', 404);
$user = User::query()->where('email', $userEmail)->firstOrFail(); return $completeSmokeLogin(
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->firstOrFail(); $request,
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail(); email: $userEmail,
tenantIdentifier: $tenantRouteKey,
Auth::login($user); workspaceIdentifier: is_array($fixture) ? data_get($fixture, 'workspace.slug') : null,
$request->session()->regenerate(); );
$workspaceContext = app(WorkspaceContext::class);
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
$workspaceContext->rememberTenantContext($tenant, $request);
return redirect()->to('/admin/t/'.$tenant->external_id);
})->name('admin.local.backup-health-browser-fixture-login'); })->name('admin.local.backup-health-browser-fixture-login');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])

View File

@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
use App\Support\Auth\PlatformCapabilities;
use App\Support\System\SystemOperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->browser()->timeout(20_000);
uses(RefreshDatabase::class);
function spec194ApprovedFindingException(Tenant $tenant, User $requester): FindingException
{
$approver = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $approver,
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Spec194 browser smoke request.',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
return $service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
'approval_reason' => 'Spec194 browser smoke approval.',
]);
}
function spec194SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = ''): string
{
return route('admin.local.smoke-login', array_filter([
'email' => $user->email,
'tenant' => $tenant->external_id,
'workspace' => $tenant->workspace->slug,
'redirect' => $redirect,
], static fn (?string $value): bool => filled($value)));
}
it('smokes tenant and admin governance semantics through modal entry points', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
$finding = Finding::factory()->for($tenant)->create();
$pendingException = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Spec194 focused review queue smoke.',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$approvedException = spec194ApprovedFindingException($tenant, $user);
$snapshotRun = OperationRun::factory()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'operation_run_id' => (int) $snapshotRun->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 2],
'generated_at' => now(),
]);
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
]);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Ready->value,
'completeness_state' => TenantReviewCompletenessState::Complete->value,
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
'publish_blockers' => [],
'section_state_counts' => [
'complete' => 6,
'partial' => 0,
'missing' => 0,
'stale' => 0,
],
]),
])->save();
$review = $review->refresh();
$archivedTenant = Tenant::factory()->archived()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec194 Archived Tenant',
]);
createUserWithTenant(
tenant: $archivedTenant,
user: $user,
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
visit(spec194SmokeLoginUrl($user, $tenant))
->waitForText('Dashboard')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(FindingExceptionsQueue::getUrl(panel: 'admin').'?exception='.(int) $pendingException->getKey())
->waitForText('Focused review lane')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Approve exception')
->assertSee('Reject exception');
visit(FindingExceptionResource::getUrl('view', ['record' => $approvedException], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Renew exception')
->assertSee('Revoke exception');
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Publish review')
->waitForText('Publication reason')
->click('Cancel')
->click('[aria-label="More"]')
->assertSee('Refresh review')
->assertSee('Export executive pack')
->click('[aria-label="Danger"]')
->click('Archive review')
->waitForText('Archive reason')
->click('Cancel')
->assertSee('Publish review')
->assertSee('Evidence snapshot');
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Refresh evidence')
->waitForText('Confirm')
->click('Cancel')
->click('Expire snapshot')
->waitForText('Expiry reason')
->click('Cancel')
->assertSee('Refresh evidence')
->assertSee('Expire snapshot');
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('[aria-label="Lifecycle"]')
->click('Archive')
->waitForText('Archive reason')
->click('Cancel')
->assertSee('Lifecycle');
visit(TenantResource::getUrl('edit', ['record' => $tenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Lifecycle');
visit(TenantResource::getUrl('view', ['record' => $archivedTenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Lifecycle');
visit(TenantResource::getUrl('edit', ['record' => $archivedTenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Lifecycle');
});
it('smokes system run triage semantics without javascript errors', function (): void {
$failedRun = OperationRun::factory()->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'type' => 'inventory_sync',
]);
$runningRun = OperationRun::factory()->create([
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'type' => 'inventory_sync',
'created_at' => now()->subMinutes(15),
'started_at' => now()->subMinutes(10),
]);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPERATIONS_VIEW,
PlatformCapabilities::OPERATIONS_MANAGE,
],
'is_active' => true,
]);
auth('web')->logout();
$this->flushSession();
$this->actingAs($platformUser, 'platform');
visit(SystemOperationRunLinks::view($failedRun))
->waitForText('Operation #'.(int) $failedRun->getKey())
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Retry')
->assertSee('Mark investigated')
->assertDontSee('Cancel');
visit(SystemOperationRunLinks::view($runningRun))
->waitForText('Operation #'.(int) $runningRun->getKey())
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('Mark investigated')
->assertSee('Cancel');
});

View File

@ -23,6 +23,9 @@
return $action->getLabel() === 'Archive' && $action->isConfirmationRequired(); return $action->getLabel() === 'Archive' && $action->isConfirmationRequired();
}) })
->mountAction('archive') ->mountAction('archive')
->setActionData([
'archive_reason' => 'Retiring this tenant from active management.',
])
->callMountedAction() ->callMountedAction()
->assertHasNoActionErrors(); ->assertHasNoActionErrors();
@ -35,6 +38,15 @@
->where('action', AuditActionId::TenantArchived->value) ->where('action', AuditActionId::TenantArchived->value)
->exists())->toBeTrue(); ->exists())->toBeTrue();
$archiveAudit = AuditLog::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('action', AuditActionId::TenantArchived->value)
->latest('id')
->first();
expect(data_get($archiveAudit?->metadata, 'reason'))->toBe('Retiring this tenant from active management.');
Filament::setTenant(null, true); Filament::setTenant(null, true);
Livewire::actingAs($user) Livewire::actingAs($user)

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
use Barryvdh\Debugbar\LaravelDebugbar;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
uses(RefreshDatabase::class);
it('logs into the admin smoke helper with explicit tenant and workspace context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$response = $this->get(route('admin.local.smoke-login', [
'email' => $user->email,
'tenant' => $tenant->external_id,
'workspace' => $tenant->workspace->slug,
]));
$response
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant))
->assertPlainCookie(
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
);
$this->assertAuthenticatedAs($user);
expect(session(App\Support\Workspaces\WorkspaceContext::SESSION_KEY))->toBe((int) $tenant->workspace_id)
->and(session(SuppressDebugbarForSmokeRequests::SESSION_KEY))
->toBe(SuppressDebugbarForSmokeRequests::COOKIE_VALUE)
->and(data_get(session(App\Support\Workspaces\WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY), (string) $tenant->workspace_id))
->toBe((int) $tenant->getKey());
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertSuccessful();
});
it('suppresses debugbar only for smoke-cookie requests and restores normal state afterward', function (): void {
config(['debugbar.enabled' => true]);
Route::middleware('web')->get('/__tests/smoke-debugbar-state', function () {
$debugbarState = null;
if (app()->bound('debugbar')) {
$debugbar = app('debugbar');
if ($debugbar instanceof LaravelDebugbar) {
$debugbarState = $debugbar->isEnabled();
}
}
return response()->json([
'config_enabled' => (bool) config('debugbar.enabled'),
'service_enabled' => $debugbarState,
]);
});
$smokeResponse = $this->withUnencryptedCookies([
SuppressDebugbarForSmokeRequests::COOKIE_NAME => SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
])->get('/__tests/smoke-debugbar-state');
$smokeResponse
->assertSuccessful()
->assertJsonPath('config_enabled', false);
if ($smokeResponse->json('service_enabled') !== null) {
expect($smokeResponse->json('service_enabled'))->toBeFalse();
}
config(['debugbar.enabled' => true]);
if (app()->bound('debugbar')) {
$debugbar = app('debugbar');
if ($debugbar instanceof LaravelDebugbar) {
$debugbar->enable();
}
}
$normalMiddlewareState = null;
$middleware = app(SuppressDebugbarForSmokeRequests::class);
$middleware->handle(Request::create('/admin/operations', 'GET'), function () use (&$normalMiddlewareState) {
$normalMiddlewareState = config('debugbar.enabled');
return response('ok');
});
expect($normalMiddlewareState)->toBeTrue();
});

View File

@ -4,6 +4,7 @@
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
@ -27,7 +28,11 @@
expect($tenant)->not->toBeNull(); expect($tenant)->not->toBeNull();
$this->get(route('admin.local.backup-health-browser-fixture-login')) $this->get(route('admin.local.backup-health-browser-fixture-login'))
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant))
->assertPlainCookie(
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
);
$this->assertAuthenticatedAs($user); $this->assertAuthenticatedAs($user);
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey()); expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());

View File

@ -20,8 +20,14 @@
'completeness_state' => EvidenceCompletenessState::Complete->value, 'completeness_state' => EvidenceCompletenessState::Complete->value,
]); ]);
app(App\Services\Evidence\EvidenceSnapshotService::class)->expire($snapshot, $user); app(App\Services\Evidence\EvidenceSnapshotService::class)->expire($snapshot, $user, 'Evidence basis is obsolete.');
$expiredAudit = AuditLog::query()
->where('action', AuditActionId::EvidenceSnapshotExpired->value)
->latest('id')
->first();
expect(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotCreated->value)->exists())->toBeTrue() expect(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotCreated->value)->exists())->toBeTrue()
->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue(); ->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue()
->and(data_get($expiredAudit?->metadata, 'reason'))->toBe('Evidence basis is obsolete.');
}); });

View File

@ -16,6 +16,8 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -166,17 +168,41 @@ function evidenceSnapshotHeaderActions(Testable $component): array
$tenant->makeCurrent(); $tenant->makeCurrent();
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user) $refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
$refreshComponent = Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()]) ->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_snapshot') ->assertActionVisible('refresh_evidence')
->assertActionVisible('expire_snapshot'); ->assertActionExists('refresh_evidence', fn (Action $action): bool => $action->getLabel() === $refreshRule->canonicalLabel
&& $action->isConfirmationRequired()
&& $action->getModalHeading() === $refreshRule->modalHeading
&& $action->getModalDescription() === $refreshRule->modalDescription)
->assertActionVisible('expire_snapshot')
->assertActionExists('expire_snapshot', fn (Action $action): bool => $action->getLabel() === $expireRule->canonicalLabel
&& $action->isConfirmationRequired()
&& $action->getModalHeading() === $expireRule->modalHeading
&& $action->getModalDescription() === $expireRule->modalDescription)
->mountAction('refresh_evidence')
->assertActionMounted('refresh_evidence');
$component = Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()]);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('expire_snapshot')
->mountAction('expire_snapshot')
->assertActionMounted('expire_snapshot')
->callMountedAction()
->assertHasActionErrors(['expiration_reason']);
expect(collect(evidenceSnapshotHeaderActions($component)) expect(collect(evidenceSnapshotHeaderActions($component))
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null) ->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
->filter() ->filter()
->values() ->values()
->all()) ->all())
->toEqualCanonicalizing(['refresh_snapshot', 'expire_snapshot']) ->toEqualCanonicalizing(['refresh_evidence', 'expire_snapshot'])
->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all()) ->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all())
->toContain('operation_run', 'review_pack'); ->toContain('operation_run', 'review_pack');
}); });
@ -386,8 +412,8 @@ function evidenceSnapshotHeaderActions(Testable $component): array
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()]) ->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_snapshot') ->assertActionVisible('refresh_evidence')
->assertActionDisabled('refresh_snapshot') ->assertActionDisabled('refresh_evidence')
->assertActionVisible('expire_snapshot') ->assertActionVisible('expire_snapshot')
->assertActionDisabled('expire_snapshot'); ->assertActionDisabled('expire_snapshot');
}); });

View File

@ -48,7 +48,10 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ListEvidenceSnapshots::class) ->test(ListEvidenceSnapshots::class)
->callTableAction('expire', $snapshot); ->callTableAction('expire', $snapshot, [
'expiration_reason' => 'This snapshot is no longer valid for governance use.',
])
->assertHasNoTableActionErrors();
$snapshot->refresh(); $snapshot->refresh();
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot); $truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);

View File

@ -409,7 +409,9 @@
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListTenants::class) Livewire::test(ListTenants::class)
->callTableAction('archive', $tenant); ->callTableAction('archive', $tenant, [
'archive_reason' => 'Removing this tenant from the active housekeeping list.',
]);
expect(Tenant::count())->toBe(0); expect(Tenant::count())->toBe(0);

View File

@ -42,6 +42,8 @@
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'consent_status' => 'required', 'consent_status' => 'required',
'is_enabled' => true,
'provider' => 'microsoft',
]); ]);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -55,6 +57,13 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ViewProviderConnection::class, ['record' => $connection->getKey()]) ->test(ViewProviderConnection::class, ['record' => $connection->getKey()])
->assertActionVisible('check_connection')
->assertActionDisabled('check_connection')
->assertActionExists('check_connection', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission())
->assertActionVisible('inventory_sync')
->assertActionDisabled('inventory_sync')
->assertActionVisible('compliance_snapshot')
->assertActionDisabled('compliance_snapshot')
->assertActionVisible('edit') ->assertActionVisible('edit')
->assertActionDisabled('edit') ->assertActionDisabled('edit')
->assertActionExists('edit', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission()); ->assertActionExists('edit', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
@ -67,6 +76,8 @@
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'consent_status' => 'required', 'consent_status' => 'required',
'is_enabled' => true,
'provider' => 'microsoft',
]); ]);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -79,6 +90,12 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ViewProviderConnection::class, ['record' => $connection->getKey()]) ->test(ViewProviderConnection::class, ['record' => $connection->getKey()])
->assertActionVisible('check_connection')
->assertActionEnabled('check_connection')
->assertActionVisible('inventory_sync')
->assertActionEnabled('inventory_sync')
->assertActionVisible('compliance_snapshot')
->assertActionEnabled('compliance_snapshot')
->assertActionVisible('edit') ->assertActionVisible('edit')
->assertActionEnabled('edit'); ->assertActionEnabled('edit');
}); });

View File

@ -51,8 +51,6 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
$table = spec125CriticalTable($component); $table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource()); expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue(); expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue(); expect($table->persistsSortInSession())->toBeTrue();

View File

@ -64,7 +64,9 @@
->test(ListTenants::class) ->test(ListTenants::class)
->assertTableActionDisabled('archive', $tenant) ->assertTableActionDisabled('archive', $tenant)
->assertTableActionExists('archive', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant) ->assertTableActionExists('archive', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $tenant)
->callTableAction('archive', $tenant); ->callTableAction('archive', $tenant, [
'archive_reason' => 'Readonly users should not be able to archive tenants.',
]);
expect($tenant->fresh()->trashed())->toBeFalse(); expect($tenant->fresh()->trashed())->toBeFalse();
}); });

View File

@ -10,6 +10,7 @@
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\User;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Livewire\Livewire; use Livewire\Livewire;
@ -76,3 +77,12 @@
Bus::assertNothingDispatched(); Bus::assertNothingDispatched();
}); });
it('keeps tenant dashboard access deny-as-not-found for non-members', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$outsider = User::factory()->create();
$this->actingAs($outsider)
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertNotFound();
});

View File

@ -7,6 +7,9 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -29,11 +32,17 @@ function tenantWithApp(): Tenant
'status' => 'active', 'status' => 'active',
]); ]);
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->dedicated()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'ok', 'is_enabled' => true,
'connection_type' => ProviderConnectionType::Dedicated->value,
'consent_status' => ProviderConsentStatus::Granted->value,
'consent_granted_at' => now(),
'consent_last_checked_at' => now(),
'verification_status' => ProviderVerificationStatus::Healthy->value,
'last_health_check_at' => now(),
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -3,15 +3,21 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\AuditLog;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
use App\Support\Audit\AuditActionId;
use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Tenants\TenantRecoveryTriagePresentation; use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
use App\Support\Workspaces\WorkspaceContext;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures; use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class); uses(BuildsPortfolioTriageFixtures::class);
@ -166,11 +172,58 @@
$component $component
->callMountedAction(); ->callMountedAction();
expect(TenantTriageReview::query()
->where('tenant_id', (int) $actionTenant->getKey())
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
->where('current_state', TenantTriageReview::STATE_REVIEWED)
->whereNull('resolved_at')
->exists())->toBeTrue()
->and(AuditLog::query()
->where('workspace_id', (int) $actionTenant->workspace_id)
->where('tenant_id', (int) $actionTenant->getKey())
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
->exists())->toBeTrue()
->and($component->instance())->toBeInstanceOf(ListTenants::class);
});
it('keeps review-state mutations available on the tenant detail header for the current concern', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Detail Action Tenant');
$actionTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Detail Action Backup Tenant');
$this->seedPortfolioBackupConcern($actionTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session([WorkspaceContext::SESSION_KEY => (int) $actionTenant->workspace_id]);
$component = Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $actionTenant->getRouteKey()])
->assertActionVisible('markReviewed')
->assertActionEnabled('markReviewed')
->assertActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
->assertActionVisible('markFollowUpNeeded')
->assertActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Target state: Follow-up needed')
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
->mountAction('markReviewed')
->assertActionMounted('markReviewed')
->callMountedAction()
->assertHasNoActionErrors();
expect(TenantTriageReview::query() expect(TenantTriageReview::query()
->where('tenant_id', (int) $actionTenant->getKey()) ->where('tenant_id', (int) $actionTenant->getKey())
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH) ->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
->where('current_state', TenantTriageReview::STATE_REVIEWED) ->where('current_state', TenantTriageReview::STATE_REVIEWED)
->whereNull('resolved_at') ->whereNull('resolved_at')
->exists())->toBeTrue() ->exists())->toBeTrue()
->and($component->instance())->toBeInstanceOf(ListTenants::class); ->and(AuditLog::query()
->where('workspace_id', (int) $actionTenant->workspace_id)
->where('tenant_id', (int) $actionTenant->getKey())
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
->exists())->toBeTrue()
->and($component->instance())->toBeInstanceOf(ViewTenant::class);
}); });

View File

@ -194,6 +194,9 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->mountAction('archive') ->mountAction('archive')
->setActionData([
'archive_reason' => 'Archiving this tenant from the detail workflow.',
])
->callMountedAction() ->callMountedAction()
->assertHasNoActionErrors(); ->assertHasNoActionErrors();

View File

@ -65,6 +65,9 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionDisabled('archive') ->assertActionDisabled('archive')
->mountAction('archive') ->mountAction('archive')
->setActionData([
'archive_reason' => 'Readonly users should not be able to archive tenants.',
])
->callMountedAction() ->callMountedAction()
->assertSuccessful(); ->assertSuccessful();

View File

@ -21,7 +21,7 @@
$service->triage($finding, $tenant, $user); $service->triage($finding, $tenant, $user);
$service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey()); $service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey());
$service->resolve($finding->refresh(), $tenant, $user, 'patched'); $service->resolve($finding->refresh(), $tenant, $user, 'patched');
$service->reopen($finding->refresh(), $tenant, $user); $service->reopen($finding->refresh(), $tenant, $user, 'The issue recurred after validation.');
$service->close($finding->refresh(), $tenant, $user, 'duplicate'); $service->close($finding->refresh(), $tenant, $user, 'duplicate');
expect(AuditLog::query() expect(AuditLog::query()
@ -40,6 +40,11 @@
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate') ->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate')
->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull() ->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull()
->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull(); ->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull();
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
expect($reopenedAudit)->not->toBeNull()
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.');
}); });
it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void { it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void {

View File

@ -52,7 +52,9 @@
Livewire::test(ListFindings::class) Livewire::test(ListFindings::class)
->filterTable('open', false) ->filterTable('open', false)
->callTableAction('reopen', $finding) ->callTableAction('reopen', $finding, [
'reopen_reason' => 'The issue recurred in a later scan.',
])
->assertHasNoTableActionErrors(); ->assertHasNoTableActionErrors();
$finding->refresh(); $finding->refresh();

View File

@ -30,7 +30,7 @@
->and($resolvedFinding->resolved_reason)->toBe('patched') ->and($resolvedFinding->resolved_reason)->toBe('patched')
->and($this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved))->not->toBeNull(); ->and($this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved))->not->toBeNull();
$reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user); $reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, 'The issue recurred after remediation.');
expect($reopenedFinding->status)->toBe(Finding::STATUS_REOPENED) expect($reopenedFinding->status)->toBe(Finding::STATUS_REOPENED)
->and($reopenedFinding->reopened_at)->not->toBeNull() ->and($reopenedFinding->reopened_at)->not->toBeNull()
@ -81,6 +81,9 @@
expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, ' ')) expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, ' '))
->toThrow(\InvalidArgumentException::class, 'closed_reason is required.'); ->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');
expect(fn () => $service->reopen($this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED), $tenant, $user, ' '))
->toThrow(\InvalidArgumentException::class, 'reopen_reason is required.');
expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, ' ')) expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, ' '))
->toThrow(\InvalidArgumentException::class, 'closed_reason is required.'); ->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');
}); });

View File

@ -35,7 +35,11 @@
->assertActionVisible('start_progress'); ->assertActionVisible('start_progress');
Livewire::test(ViewFinding::class, ['record' => $resolvedFinding->getKey()]) Livewire::test(ViewFinding::class, ['record' => $resolvedFinding->getKey()])
->assertActionVisible('reopen'); ->assertActionVisible('reopen')
->mountAction('reopen')
->assertActionMounted('reopen')
->callMountedAction()
->assertHasActionErrors(['reopen_reason']);
}); });
it('executes workflow actions from view header and supports assignment to tenant members only', function (): void { it('executes workflow actions from view header and supports assignment to tenant members only', function (): void {
@ -69,7 +73,15 @@
->and((int) $finding->owner_user_id)->toBe((int) $user->getKey()); ->and((int) $finding->owner_user_id)->toBe((int) $user->getKey());
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()]) Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->callAction('reopen') ->mountAction('reopen')
->assertActionMounted('reopen')
->callMountedAction()
->assertHasActionErrors(['reopen_reason']);
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
->callAction('reopen', [
'reopen_reason' => 'The finding recurred after remediation.',
])
->assertHasNoActionErrors() ->assertHasNoActionErrors()
->callAction('assign', [ ->callAction('assign', [
'assignee_user_id' => (int) $outsider->getKey(), 'assignee_user_id' => (int) $outsider->getKey(),

View File

@ -49,6 +49,7 @@
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns; use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
@ -637,6 +638,92 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->toBe(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin')); ->toBe(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'));
}); });
it('keeps tenant detail header actions aligned with the shared administrative family while preserving workflow-heavy exceptions', function (): void {
$tenant = Tenant::factory()->active()->create();
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
ProviderConnection::factory()->platform()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'is_default' => true,
'is_enabled' => true,
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$listComponent = Livewire::actingAs($user)
->test(ListTenants::class)
->assertTableActionVisible('admin_consent', $tenant)
->assertTableActionVisible('open_in_entra', $tenant)
->assertTableActionVisible('syncTenant', $tenant)
->assertTableActionVisible('verify', $tenant)
->assertTableActionVisible('setup_rbac', $tenant)
->assertTableActionVisible('archive', $tenant);
$markReviewedAction = $listComponent->instance()->getTable()->getAction('markReviewed');
$markFollowUpNeededAction = $listComponent->instance()->getTable()->getAction('markFollowUpNeeded');
$component = Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('admin_consent')
->assertActionVisible('open_in_entra')
->assertActionVisible('syncTenant')
->assertActionVisible('verify')
->assertActionVisible('setup_rbac')
->assertActionVisible('refresh_rbac')
->assertActionVisible('archive');
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
$headerGroups = collect($instance->getCachedHeaderActions())
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
->mapWithKeys(static function (ActionGroup $group): array {
$actionNames = collect($group->getActions())
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
return [(string) $group->getLabel() => $actionNames];
});
$visibleHeaderActionNames = $headerGroups
->flatMap(static fn (array $actionNames): array => $actionNames)
->values()
->all();
expect($markReviewedAction)->not->toBeNull()
->and($markReviewedAction?->getName())->toBe('markReviewed')
->and($markReviewedAction?->isConfirmationRequired())->toBeTrue()
->and($markFollowUpNeededAction)->not->toBeNull()
->and($markFollowUpNeededAction?->getName())->toBe('markFollowUpNeeded')
->and($markFollowUpNeededAction?->isConfirmationRequired())->toBeTrue()
->and(array_keys($headerGroups->all()))->toBe(['External links', 'Setup', 'Triage', 'Lifecycle'])
->and($headerGroups->get('External links'))->toEqualCanonicalizing(['admin_consent', 'open_in_entra'])
->and($headerGroups->get('Setup'))->toEqualCanonicalizing(['syncTenant', 'verify', 'setup_rbac', 'refresh_rbac'])
->and($headerGroups->get('Triage'))->toEqualCanonicalizing(['markReviewed', 'markFollowUpNeeded'])
->and($headerGroups->get('Lifecycle'))->toEqualCanonicalizing(['archive'])
->and($visibleHeaderActionNames)->not->toContain('edit')
->and($visibleHeaderActionNames)->toContain('markReviewed')
->and($visibleHeaderActionNames)->toContain('markFollowUpNeeded')
->and($visibleHeaderActionNames)->not->toContain('forceDelete')
->and(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())->toContain('tenant_edit');
});
it('renders the backup items relation manager on the backup set detail page', function (): void { it('renders the backup items relation manager on the backup set detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -845,6 +932,64 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->toContain('OnboardingVerificationV1_5UxTest'); ->toContain('OnboardingVerificationV1_5UxTest');
}); });
it('documents the spec 195 residual inventory, human-readable names, and baseline alignment', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
expect(array_keys($inventory))->toEqualCanonicalizing([
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
\App\Filament\Pages\BreakGlassRecovery::class,
\App\Filament\Pages\ChooseWorkspace::class,
\App\Filament\Pages\ChooseTenant::class,
\App\Filament\Pages\Tenancy\RegisterTenant::class,
\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class,
\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class,
\App\Filament\Pages\TenantDashboard::class,
]);
foreach ($inventory as $className => $surface) {
expect(trim((string) ($surface['surfaceName'] ?? '')))
->not->toBe('', "{$className} must keep a human-readable surfaceName in Spec 195.")
->and($surface['pageClass'] ?? null)->toBe($className)
->and($surface['evidence'] ?? [])->not->toBeEmpty("{$className} must keep structured Spec 195 evidence.");
}
$mustRemainBaselineExempt = collect($inventory)
->filter(fn (array $surface): bool => ($surface['mustRemainBaselineExempt'] ?? false) === true)
->keys()
->values()
->all();
$mustNotRemainBaselineExempt = collect($inventory)
->filter(fn (array $surface): bool => ($surface['mustNotRemainBaselineExempt'] ?? false) === true)
->keys()
->values()
->all();
expect($mustRemainBaselineExempt)->toEqualCanonicalizing([
\App\Filament\Pages\ChooseWorkspace::class,
\App\Filament\Pages\ChooseTenant::class,
\App\Filament\Pages\Tenancy\RegisterTenant::class,
\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class,
\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class,
\App\Filament\Pages\TenantDashboard::class,
]);
foreach ($mustRemainBaselineExempt as $className) {
expect(array_key_exists($className, $baselineExemptions))
->toBeTrue("{$className} should stay aligned between baseline() and Spec 195.");
}
foreach ($mustNotRemainBaselineExempt as $className) {
expect(array_key_exists($className, $baselineExemptions))
->toBeFalse("{$className} must not keep a stale baseline exemption under Spec 195.");
}
});
it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void { it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline(); $baselineExemptions = ActionSurfaceExemptions::baseline();
@ -889,6 +1034,50 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
} }
}); });
it('keeps residual system pages outside primary discovery but inside the spec 195 closure inventory', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
foreach ([
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
] as $className) {
expect($components->has($className))
->toBeFalse("{$className} should stay outside the primary declaration-backed discovery scope.")
->and(ActionSurfaceExemptions::spec195ResidualSurface($className))
->not->toBeNull("{$className} must still carry an explicit Spec 195 closure entry.");
}
});
it('reports actionable file context when a residual surface is missing from the spec 195 inventory', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
unset($inventory[\App\Filament\System\Pages\Dashboard::class]);
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: array_map(
static fn ($component): string => $component->className,
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
),
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class],
);
$formattedIssues = implode("\n", array_map(
static fn ($issue): string => $issue->format(),
$issues,
));
expect($formattedIssues)
->toContain(\App\Filament\System\Pages\Dashboard::class)
->toContain('Residual action surface is missing a Spec 195 closure entry')
->toContain('Dashboard.php');
});
it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void { it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline(); $baselineExemptions = ActionSurfaceExemptions::baseline();
@ -1939,6 +2128,63 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->and($table->getBulkActions())->toBeEmpty(); ->and($table->getBulkActions())->toBeEmpty();
}); });
it('keeps provider connection detail secondary actions aligned under More', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'is_enabled' => true,
'is_default' => false,
]);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ViewProviderConnection::class, ['record' => $connection->getKey()])
->assertActionVisible('grant_admin_consent');
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
$headerActions = $instance->getCachedHeaderActions();
$primaryHeaderActions = collect($headerActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
$moreGroup = collect($headerActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
$moreActionNames = collect($moreGroup?->getActions())
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($primaryHeaderActions)->toEqual(['grant_admin_consent'])
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
->and($moreGroup?->getLabel())->toBe('More')
->and($moreActionNames)->toEqualCanonicalizing([
'edit',
'check_connection',
'inventory_sync',
'compliance_snapshot',
'set_default',
'enable_dedicated_override',
'rotate_dedicated_credential',
'delete_dedicated_credential',
'revert_to_platform',
'enable_connection',
'disable_connection',
]);
});
it('uses clickable rows without extra row actions on the alert deliveries list', function (): void { it('uses clickable rows without extra row actions on the alert deliveries list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -122,6 +122,28 @@ className: $className,
); );
} }
/**
* @return array<int, string>
*/
function repositoryDiscoveredActionSurfaceClasses(): array
{
return array_map(
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
);
}
/**
* @param array<int, \App\Support\Ui\ActionSurface\ActionSurfaceValidationIssue> $issues
*/
function formatActionSurfaceIssues(array $issues): string
{
return implode("\n", array_map(
static fn ($issue): string => $issue->format(),
$issues,
));
}
it('passes when all required slots are declared', function (): void { it('passes when all required slots are declared', function (): void {
$validator = new ActionSurfaceValidator( $validator = new ActionSurfaceValidator(
profileDefinition: new ActionSurfaceProfileDefinition, profileDefinition: new ActionSurfaceProfileDefinition,
@ -245,3 +267,73 @@ className: $className,
expect($result->hasIssues())->toBeFalse($result->formatForAssertion()); expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
}); });
it('accepts the repository spec 195 residual inventory even when only inventory validation runs', function (): void {
$validator = ActionSurfaceValidator::withBaselineExemptions();
$result = $validator->validateComponents([]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('fails when a residual system candidate is missing a spec 195 closure entry', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
unset($inventory[\App\Filament\System\Pages\Dashboard::class]);
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class],
);
expect(formatActionSurfaceIssues($issues))
->toContain(\App\Filament\System\Pages\Dashboard::class)
->toContain('Residual action surface is missing a Spec 195 closure entry');
});
it('fails when a non-enrolled spec 195 residual surface has no reason category', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
$inventory[\App\Filament\Pages\ChooseWorkspace::class]['reasonCategory'] = null;
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
);
expect(formatActionSurfaceIssues($issues))
->toContain(\App\Filament\Pages\ChooseWorkspace::class)
->toContain('reason category is invalid or missing');
});
it('fails when a spec 195 residual surface is missing structured evidence', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
$inventory[\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class]['evidence'] = [];
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
);
expect(formatActionSurfaceIssues($issues))
->toContain(\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class)
->toContain('require at least one structured evidence descriptor');
});
it('fails when a retired spec 195 residual surface still remains baseline-exempt', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
$baselineExemptions[\App\Filament\Pages\BreakGlassRecovery::class] = 'Stale retired exemption.';
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: repositoryDiscoveredActionSurfaceClasses(),
baselineExemptions: $baselineExemptions,
);
expect(formatActionSurfaceIssues($issues))
->toContain(\App\Filament\Pages\BreakGlassRecovery::class)
->toContain('must not remain baseline-exempt');
});

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
it('keeps the spec 194 family inventory, surface bindings, and documented deviations explicit', function (): void {
$families = GovernanceActionCatalog::families();
$rules = GovernanceActionCatalog::rules();
$bindings = GovernanceActionCatalog::surfaceBindings();
expect(array_keys($families))->toEqualCanonicalizing([
'exception_decision',
'review_lifecycle',
'evidence_lifecycle',
'run_triage',
'finding_lifecycle',
'tenant_lifecycle',
])
->and(array_keys($rules))->toHaveCount(16)
->and($bindings)->not->toBeEmpty();
foreach ($bindings as $binding) {
$matchingRule = collect($rules)->first(
fn ($rule): bool => $rule->familyKey === $binding['familyKey']
&& in_array($binding['surfaceKey'], $rule->surfaceKeys, true),
);
expect($matchingRule)->not->toBeNull();
}
expect(GovernanceActionCatalog::documentedDeviations())->not->toBeEmpty();
});
it('keeps evidence and review surface bindings aligned to their canonical action names', function (): void {
$bindingsBySurface = collect(GovernanceActionCatalog::surfaceBindings())->groupBy('surfaceKey');
expect($bindingsBySurface->get('view_evidence_snapshot', collect())->pluck('actionName')->all())
->toEqualCanonicalizing(['refresh_evidence', 'expire_snapshot'])
->and($bindingsBySurface->get('view_tenant_review', collect())->pluck('actionName')->all())
->toContain('refresh_review', 'publish_review', 'archive_review');
});
it('keeps triage mutations out of the tenantless run viewer while the system run page owns them', function (): void {
$tenantlessViewer = file_get_contents(base_path('app/Filament/Pages/Operations/TenantlessOperationRunViewer.php'));
$systemViewRun = file_get_contents(base_path('app/Filament/System/Pages/Ops/ViewRun.php'));
expect($tenantlessViewer)->toBeString()
->and($systemViewRun)->toBeString()
->and($tenantlessViewer)->not->toContain("Action::make('retry')")
->and($tenantlessViewer)->not->toContain("Action::make('cancel')")
->and($tenantlessViewer)->not->toContain("Action::make('mark_investigated')")
->and($systemViewRun)->toContain("Action::make('retry')")
->and($systemViewRun)->toContain("Action::make('cancel')")
->and($systemViewRun)->toContain("Action::make('mark_investigated')");
});
it('keeps the governed surface files inside the catalog binding inventory', function (): void {
$boundFiles = collect(GovernanceActionCatalog::surfaceBindings())
->pluck('pageClass')
->unique()
->values()
->all();
expect($boundFiles)->toContain(
'App\\Filament\\Pages\\Monitoring\\FindingExceptionsQueue',
'App\\Filament\\Resources\\FindingExceptionResource\\Pages\\ViewFindingException',
'App\\Filament\\Resources\\EvidenceSnapshotResource\\Pages\\ViewEvidenceSnapshot',
'App\\Filament\\Resources\\TenantReviewResource\\Pages\\ViewTenantReview',
'App\\Filament\\System\\Pages\\Ops\\ViewRun',
'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
'App\\Filament\\Resources\\TenantResource\\Pages\\ViewTenant',
'App\\Filament\\Resources\\TenantResource\\Pages\\EditTenant',
);
});

View File

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
/**
* @return array<int, string>
*/
function spec195DiscoveredClasses(): array
{
return array_map(
static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className,
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
);
}
/**
* @param array<int, \App\Support\Ui\ActionSurface\ActionSurfaceValidationIssue> $issues
*/
function spec195FormattedIssues(array $issues): string
{
return implode("\n", array_map(
static fn ($issue): string => $issue->format(),
$issues,
));
}
it('keeps every spec 195 residual surface classified exactly once with structured evidence', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
expect(array_keys($inventory))->toEqualCanonicalizing([
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
\App\Filament\Pages\BreakGlassRecovery::class,
\App\Filament\Pages\ChooseWorkspace::class,
\App\Filament\Pages\ChooseTenant::class,
\App\Filament\Pages\Tenancy\RegisterTenant::class,
\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class,
\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class,
\App\Filament\Pages\TenantDashboard::class,
]);
$surfaceKeys = collect($inventory)->pluck('surfaceKey')->all();
expect($surfaceKeys)->toHaveCount(count(array_unique($surfaceKeys)));
foreach ($inventory as $className => $surface) {
expect($surface['closureDecision'] ?? null)
->not->toBeNull("{$className} must keep a closure decision.")
->and(trim((string) ($surface['surfaceName'] ?? '')))
->not->toBe('', "{$className} must keep a human-readable surfaceName.")
->and($surface['evidence'] ?? [])
->not->toBeEmpty("{$className} must keep structured evidence.");
}
});
it('keeps the residual system tail explicitly classified instead of silently baseline-exempted', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
->and($inventory[\App\Filament\System\Pages\Dashboard::class]['closureDecision'] ?? null)->toBe('separately_governed');
foreach ([
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
] as $className) {
expect(array_key_exists($className, $baselineExemptions))
->toBeFalse("{$className} must not rely on baseline() for Spec 195 closure.");
}
});
it('retires break glass recovery from live baseline handling', function (): void {
$surface = ActionSurfaceExemptions::spec195ResidualSurface(\App\Filament\Pages\BreakGlassRecovery::class);
expect($surface)->not->toBeNull()
->and($surface['closureDecision'] ?? null)->toBe('retired_no_longer_relevant')
->and($surface['reasonCategory'] ?? null)->toBe('disabled_or_actionless_surface')
->and(ActionSurfaceExemptions::baseline()->hasClass(\App\Filament\Pages\BreakGlassRecovery::class))->toBeFalse();
});
it('fails when a residual candidate is missing a closure decision entry', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
unset($inventory[\App\Filament\System\Pages\Dashboard::class]);
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: spec195DiscoveredClasses(),
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class],
);
expect(spec195FormattedIssues($issues))
->toContain('Residual action surface is missing a Spec 195 closure entry')
->toContain(\App\Filament\System\Pages\Dashboard::class);
});
it('fails when a discovered residual exemption loses its reason category', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
$inventory[\App\Filament\Pages\ChooseTenant::class]['reasonCategory'] = null;
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: spec195DiscoveredClasses(),
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
);
expect(spec195FormattedIssues($issues))
->toContain(\App\Filament\Pages\ChooseTenant::class)
->toContain('reason category is invalid or missing');
});
it('fails when a residual surface loses its structured evidence', function (): void {
$inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory();
$inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['evidence'] = [];
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: $inventory,
discoveredClasses: spec195DiscoveredClasses(),
baselineExemptions: ActionSurfaceExemptions::baseline()->all(),
);
expect(spec195FormattedIssues($issues))
->toContain(\App\Filament\System\Pages\Directory\ViewTenant::class)
->toContain('require at least one structured evidence descriptor');
});
it('fails when a retired surface is reintroduced as a stale baseline exemption', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline()->all();
$baselineExemptions[\App\Filament\Pages\BreakGlassRecovery::class] = 'Stale retired page exemption.';
$issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture(
inventory: ActionSurfaceExemptions::spec195ResidualSurfaceInventory(),
discoveredClasses: spec195DiscoveredClasses(),
baselineExemptions: $baselineExemptions,
);
expect(spec195FormattedIssues($issues))
->toContain(\App\Filament\Pages\BreakGlassRecovery::class)
->toContain('must not remain baseline-exempt');
});

View File

@ -27,7 +27,7 @@
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->permissionPosture()->resolved()->create(); $finding = Finding::factory()->for($tenant)->permissionPosture()->resolved()->create();
$finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user); $finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The finding recurred after a later scan.');
expect($finding->status)->toBe(Finding::STATUS_REOPENED) expect($finding->status)->toBe(Finding::STATUS_REOPENED)
->and($finding->reopened_at)->not->toBeNull() ->and($finding->reopened_at)->not->toBeNull()

View File

@ -74,5 +74,18 @@
->assertSee('Related drilldown') ->assertSee('Related drilldown')
->assertDontSee('Quiet monitoring mode') ->assertDontSee('Quiet monitoring mode')
->assertActionVisible('approve_selected_exception') ->assertActionVisible('approve_selected_exception')
->assertActionVisible('reject_selected_exception'); ->assertActionVisible('reject_selected_exception')
->mountAction('approve_selected_exception')
->assertActionMounted('approve_selected_exception')
->callMountedAction()
->assertHasActionErrors(['approval_reason']);
Livewire::withQueryParams([
'exception' => (int) $exception->getKey(),
])
->test(FindingExceptionsQueue::class)
->mountAction('reject_selected_exception')
->assertActionMounted('reject_selected_exception')
->callMountedAction()
->assertHasActionErrors(['rejection_reason']);
}); });

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderInventorySyncJob; use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun; use App\Models\OperationRun;
@ -64,6 +65,54 @@
Queue::assertPushed(ProviderInventorySyncJob::class, 1); Queue::assertPushed(ProviderInventorySyncJob::class, 1);
}); });
it('starts inventory sync from the provider connection detail page', function (): void {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'consent_status' => 'granted',
]);
Livewire::test(ViewProviderConnection::class, ['record' => $connection->getKey()])
->assertActionVisible('inventory_sync')
->callAction('inventory_sync');
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
expect($opRun?->context)->toMatchArray([
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
});
it('dedupes compliance snapshot runs and does not call Graph during start', function (): void { it('dedupes compliance snapshot runs and does not call Graph during start', function (): void {
Queue::fake(); Queue::fake();

View File

@ -40,6 +40,9 @@ function editTenantUiHeaderActions(Testable $component): array
&& $action->getTooltip() === 'You do not have permission to archive tenants.'; && $action->getTooltip() === 'You do not have permission to archive tenants.';
}) })
->mountAction('archive') ->mountAction('archive')
->setActionData([
'archive_reason' => 'Managers should not be able to archive tenants.',
])
->callMountedAction() ->callMountedAction()
->assertSuccessful(); ->assertSuccessful();
@ -67,6 +70,12 @@ function editTenantUiHeaderActions(Testable $component): array
->assertActionEnabled('archive') ->assertActionEnabled('archive')
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired()) ->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
->mountAction('archive') ->mountAction('archive')
->assertActionMounted('archive')
->callMountedAction()
->assertHasActionErrors(['archive_reason'])
->setActionData([
'archive_reason' => 'This tenant is being archived from the edit page.',
])
->callMountedAction() ->callMountedAction()
->assertHasNoActionErrors(); ->assertHasNoActionErrors();

View File

@ -2,6 +2,7 @@
use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\Tenancy\RegisterTenant;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire;
describe('Register tenant page authorization', function () { describe('Register tenant page authorization', function () {
it('is not visible for readonly members', function () { it('is not visible for readonly members', function () {
@ -29,4 +30,19 @@
Filament::setCurrentPanel(null); Filament::setCurrentPanel(null);
}); });
it('rejects readonly members when they try to mount the register-tenant page', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Livewire::actingAs($user)
->test(RegisterTenant::class)
->assertNotFound();
Filament::setCurrentPanel(null);
});
}); });

View File

@ -262,7 +262,7 @@
$auditLogger = app(WorkspaceAuditLogger::class); $auditLogger = app(WorkspaceAuditLogger::class);
TenantResource::restoreTenant($activeTenant, $auditLogger); TenantResource::restoreTenant($activeTenant, $auditLogger);
TenantResource::archiveTenant($onboardingTenant, $auditLogger); TenantResource::archiveTenant($onboardingTenant, $auditLogger, 'Trying to archive an onboarding tenant should be rejected.');
$activeTenant->refresh(); $activeTenant->refresh();
$onboardingTenant->refresh(); $onboardingTenant->refresh();

View File

@ -174,6 +174,8 @@
->test(ListTenants::class) ->test(ListTenants::class)
->assertTableActionVisible('archive', $tenant) ->assertTableActionVisible('archive', $tenant)
->assertTableActionEnabled('archive', $tenant) ->assertTableActionEnabled('archive', $tenant)
->assertTableActionVisible('syncTenant', $tenant)
->assertTableActionEnabled('syncTenant', $tenant)
->assertTableActionVisible('verify', $tenant) ->assertTableActionVisible('verify', $tenant)
->assertTableActionEnabled('verify', $tenant); ->assertTableActionEnabled('verify', $tenant);
@ -183,6 +185,8 @@
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive') ->assertActionVisible('archive')
->assertActionEnabled('archive') ->assertActionEnabled('archive')
->assertActionVisible('syncTenant')
->assertActionEnabled('syncTenant')
->assertActionVisible('verify') ->assertActionVisible('verify')
->assertActionEnabled('verify'); ->assertActionEnabled('verify');
}); });

View File

@ -9,10 +9,12 @@
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
afterEach(function () { afterEach(function () {
Carbon::setTestNow();
CarbonImmutable::setTestNow(); CarbonImmutable::setTestNow();
}); });
@ -33,9 +35,13 @@
config()->set('tenantpilot.system_console.stuck_thresholds.queued_minutes', 10); config()->set('tenantpilot.system_console.stuck_thresholds.queued_minutes', 10);
config()->set('tenantpilot.system_console.stuck_thresholds.running_minutes', 20); config()->set('tenantpilot.system_console.stuck_thresholds.running_minutes', 20);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-27 10:00:00')); $referenceTime = CarbonImmutable::parse('2026-02-27 10:00:00');
Carbon::setTestNow($referenceTime);
CarbonImmutable::setTestNow($referenceTime);
$stuckQueued = OperationRun::factory()->create([ $stuckQueued = OperationRun::factory()->create([
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(30), 'created_at' => now()->subMinutes(30),
@ -43,6 +49,7 @@
]); ]);
$stuckRunning = OperationRun::factory()->create([ $stuckRunning = OperationRun::factory()->create([
'type' => 'inventory_sync',
'status' => OperationRunStatus::Running->value, 'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(25), 'created_at' => now()->subMinutes(25),
@ -50,6 +57,7 @@
]); ]);
$freshQueued = OperationRun::factory()->create([ $freshQueued = OperationRun::factory()->create([
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(5), 'created_at' => now()->subMinutes(5),

View File

@ -218,9 +218,13 @@
->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system')) ->assertActionExists('go_to_runbooks', fn (Action $action): bool => $action->getLabel() === 'Go to runbooks' && $action->getUrl() === Runbooks::getUrl(panel: 'system'))
->assertActionVisible('retry') ->assertActionVisible('retry')
->assertActionExists('retry', fn (Action $action): bool => $action->getLabel() === 'Retry' && $action->isConfirmationRequired()) ->assertActionExists('retry', fn (Action $action): bool => $action->getLabel() === 'Retry' && $action->isConfirmationRequired())
->assertActionHidden('cancel')
->assertActionVisible('mark_investigated') ->assertActionVisible('mark_investigated')
->assertActionExists('mark_investigated', fn (Action $action): bool => $action->getLabel() === 'Mark investigated' && $action->isConfirmationRequired()) ->assertActionExists('mark_investigated', fn (Action $action): bool => $action->getLabel() === 'Mark investigated' && $action->isConfirmationRequired())
->assertActionHidden('cancel'); ->mountAction('mark_investigated')
->assertActionMounted('mark_investigated')
->callMountedAction()
->assertHasActionErrors(['reason']);
expect($failedRunView->instance()->getTitle())->toBe('Operation #'.(int) $failedRun->getKey()); expect($failedRunView->instance()->getTitle())->toBe('Operation #'.(int) $failedRun->getKey());
@ -266,8 +270,19 @@
->assertActionHidden('retry') ->assertActionHidden('retry')
->assertActionVisible('cancel') ->assertActionVisible('cancel')
->assertActionExists('cancel', fn (Action $action): bool => $action->getLabel() === 'Cancel' && $action->isConfirmationRequired()) ->assertActionExists('cancel', fn (Action $action): bool => $action->getLabel() === 'Cancel' && $action->isConfirmationRequired())
->mountAction('cancel')
->assertActionMounted('cancel')
->callMountedAction()
->assertHasActionErrors(['reason']);
Livewire::test(ViewRun::class, [
'run' => $runningRun,
])
->assertActionVisible('cancel')
->assertActionVisible('mark_investigated') ->assertActionVisible('mark_investigated')
->callAction('cancel') ->callAction('cancel', data: [
'reason' => 'Stopping the in-flight run after operator triage.',
])
->assertHasNoActionErrors() ->assertHasNoActionErrors()
->assertNotified('Run cancelled'); ->assertNotified('Run cancelled');
@ -278,7 +293,8 @@
$runningRun->refresh(); $runningRun->refresh();
expect((string) $runningRun->status)->toBe(OperationRunStatus::Completed->value) expect((string) $runningRun->status)->toBe(OperationRunStatus::Completed->value)
->and((string) $runningRun->outcome)->toBe(OperationRunOutcome::Failed->value); ->and((string) $runningRun->outcome)->toBe(OperationRunOutcome::Failed->value)
->and(data_get($runningRun->context, 'triage.cancel_reason'))->toBe('Stopping the in-flight run after operator triage.');
}); });
it('keeps detail inspection and navigation available while hiding triage for view-only operators', function () { it('keeps detail inspection and navigation available while hiding triage for view-only operators', function () {

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('requires directory-view capability on residual system directory detail pages', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get(SystemDirectoryLinks::tenantDetail($tenant))
->assertForbidden();
$this->actingAs($platformUser, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertForbidden();
});
it('keeps the residual system tenant detail page read-mostly and contextual', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Residual Directory Workspace']);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Residual Directory Tenant',
]);
ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Residual Default Connection',
'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Healthy->value,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
]);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get(SystemDirectoryLinks::tenantDetail($tenant))
->assertSuccessful()
->assertSee('Residual Directory Tenant')
->assertSee('Residual Directory Workspace')
->assertSee('Connectivity signals')
->assertSee('Residual Default Connection')
->assertSee('Open in /admin')
->assertSee(SystemDirectoryLinks::adminTenant($tenant), false)
->assertSee('Open operations runs')
->assertSee(SystemOperationRunLinks::index(), false)
->assertSee(SystemOperationRunLinks::view($run), false)
->assertDontSee('Enter break-glass mode')
->assertDontSee('Emergency: Assign Owner');
});
it('keeps the residual system workspace detail page read-mostly and link-driven', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Residual Workspace Detail']);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Workspace Detail Tenant',
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
]);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$response = $this->actingAs($platformUser, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertSuccessful()
->assertSee('Residual Workspace Detail')
->assertSee('Tenants summary')
->assertSee('Workspace Detail Tenant')
->assertSee(SystemDirectoryLinks::tenantDetail($tenant), false)
->assertSee('Open in /admin')
->assertSee(SystemDirectoryLinks::adminWorkspace($workspace), false)
->assertSee('Open operations runs')
->assertSee(SystemOperationRunLinks::index(), false)
->assertSee(SystemOperationRunLinks::view($run), false)
->assertDontSee('Enter break-glass mode')
->assertDontSee('Emergency: Assign Owner');
$html = $response->getContent();
expect($html)->toContain('wire:name="Filament\\Livewire\\DatabaseNotifications"');
expect($html)->not->toContain('__lazyLoad');
});

View File

@ -57,7 +57,7 @@
->assertActionEnabled('restore') ->assertActionEnabled('restore')
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore') ->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore')
->assertActionHidden('archive') ->assertActionHidden('archive')
->assertActionHidden('related_onboarding'); ->assertActionDoesNotExist('related_onboarding');
}); });
it('keeps archived tenant detail inspectable for readonly members while blocking lifecycle mutation', function (): void { it('keeps archived tenant detail inspectable for readonly members while blocking lifecycle mutation', function (): void {
@ -72,7 +72,7 @@
->assertActionVisible('restore') ->assertActionVisible('restore')
->assertActionDisabled('restore') ->assertActionDisabled('restore')
->assertActionHidden('archive') ->assertActionHidden('archive')
->assertActionHidden('related_onboarding'); ->assertActionDoesNotExist('related_onboarding');
}); });
it('keeps archived tenant routes authoritative when another tenant is currently selected', function (): void { it('keeps archived tenant routes authoritative when another tenant is currently selected', function (): void {

View File

@ -42,7 +42,7 @@
$review = $reviewService->refresh($review, $user, $refreshSnapshot); $review = $reviewService->refresh($review, $user, $refreshSnapshot);
$review = $reviewService->compose($review->fresh()); $review = $reviewService->compose($review->fresh());
$published = $lifecycle->publish($review, $user); $published = $lifecycle->publish($review, $user, 'Publishing the current review pack.');
EvidenceSnapshot::query() EvidenceSnapshot::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -70,7 +70,7 @@
operationRunCount: 3, operationRunCount: 3,
)); ));
$lifecycle->archive($nextReview, $user); $lifecycle->archive($nextReview, $user, 'Replacing with a newer governance review.');
expect(AuditLog::query()->where('action', AuditActionId::TenantReviewCreated->value)->exists())->toBeTrue() expect(AuditLog::query()->where('action', AuditActionId::TenantReviewCreated->value)->exists())->toBeTrue()
->and(AuditLog::query()->where('action', AuditActionId::TenantReviewRefreshed->value)->exists())->toBeTrue() ->and(AuditLog::query()->where('action', AuditActionId::TenantReviewRefreshed->value)->exists())->toBeTrue()
@ -84,7 +84,19 @@
->latest('id') ->latest('id')
->first(); ->first();
$publishAudit = AuditLog::query()
->where('action', AuditActionId::TenantReviewPublished->value)
->latest('id')
->first();
$archiveAudit = AuditLog::query()
->where('action', AuditActionId::TenantReviewArchived->value)
->latest('id')
->first();
expect($exportAudit)->not->toBeNull() expect($exportAudit)->not->toBeNull()
->and($exportAudit?->resource_type)->toBe('tenant_review') ->and($exportAudit?->resource_type)->toBe('tenant_review')
->and(data_get($exportAudit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey()); ->and(data_get($exportAudit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
->and(data_get($publishAudit?->metadata, 'reason'))->toBe('Publishing the current review pack.')
->and(data_get($archiveAudit?->metadata, 'reason'))->toBe('Replacing with a newer governance review.');
}); });

View File

@ -11,6 +11,7 @@
$publishedReview = app(TenantReviewLifecycleService::class)->publish( $publishedReview = app(TenantReviewLifecycleService::class)->publish(
composeTenantReviewForTest($tenant, $user), composeTenantReviewForTest($tenant, $user),
$user, $user,
'Ready for the next review cycle.',
); );
EvidenceSnapshot::query() EvidenceSnapshot::query()

View File

@ -33,7 +33,7 @@
->and($truth->primaryLabel)->toBe('Publication blocked') ->and($truth->primaryLabel)->toBe('Publication blocked')
->and($truth->nextStepText())->toBe('Resolve the review blockers before publication'); ->and($truth->nextStepText())->toBe('Resolve the review blockers before publication');
expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user)) expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user, 'Ready for formal publication.'))
->toThrow(\InvalidArgumentException::class); ->toThrow(\InvalidArgumentException::class);
}); });
@ -58,7 +58,7 @@
], ],
); );
$published = app(TenantReviewLifecycleService::class)->publish($review, $user); $published = app(TenantReviewLifecycleService::class)->publish($review, $user, 'Ready for formal publication.');
$publishedAt = $published->published_at?->toIso8601String(); $publishedAt = $published->published_at?->toIso8601String();
expect($published->status)->toBe(TenantReviewStatus::Published->value) expect($published->status)->toBe(TenantReviewStatus::Published->value)
@ -67,7 +67,7 @@
$publishedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($published); $publishedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($published);
$archived = app(TenantReviewLifecycleService::class)->archive($published, $user); $archived = app(TenantReviewLifecycleService::class)->archive($published, $user, 'Superseded by newer review cycle.');
$archivedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($archived); $archivedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($archived);
expect($archived->status)->toBe(TenantReviewStatus::Archived->value) expect($archived->status)->toBe(TenantReviewStatus::Archived->value)

View File

@ -10,6 +10,7 @@
use App\Models\TenantReview; use App\Models\TenantReview;
use App\Models\User; use App\Models\User;
use App\Services\TenantReviews\TenantReviewLifecycleService; use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -85,6 +86,7 @@ function tenantReviewContractHeaderActions(Testable $component): array
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
[$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly'); [$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly');
$review = composeTenantReviewForTest($tenant, $owner); $review = composeTenantReviewForTest($tenant, $owner);
$refreshRule = GovernanceActionCatalog::rule('refresh_review');
setTenantPanelContext($tenant); setTenantPanelContext($tenant);
@ -101,20 +103,28 @@ function tenantReviewContractHeaderActions(Testable $component): array
Livewire::actingAs($owner) Livewire::actingAs($owner)
->test(ViewTenantReview::class, ['record' => $review->getKey()]) ->test(ViewTenantReview::class, ['record' => $review->getKey()])
->assertActionExists('refresh_review', fn (Action $action): bool => $action->getLabel() === $refreshRule->canonicalLabel
&& $action->isConfirmationRequired()
&& $action->getModalHeading() === $refreshRule->modalHeading
&& $action->getModalDescription() === $refreshRule->modalDescription)
->mountAction('refresh_review') ->mountAction('refresh_review')
->assertActionMounted('refresh_review'); ->assertActionMounted('refresh_review');
Livewire::actingAs($owner) Livewire::actingAs($owner)
->test(ViewTenantReview::class, ['record' => $review->getKey()]) ->test(ViewTenantReview::class, ['record' => $review->getKey()])
->mountAction('publish_review') ->mountAction('publish_review')
->assertActionMounted('publish_review'); ->assertActionMounted('publish_review')
->callMountedAction()
->assertHasActionErrors(['publish_reason']);
$published = app(TenantReviewLifecycleService::class)->publish($review, $owner); $published = app(TenantReviewLifecycleService::class)->publish($review, $owner, 'Ready for publication.');
Livewire::actingAs($owner) Livewire::actingAs($owner)
->test(ViewTenantReview::class, ['record' => $published->getKey()]) ->test(ViewTenantReview::class, ['record' => $published->getKey()])
->mountAction('archive_review') ->mountAction('archive_review')
->assertActionMounted('archive_review'); ->assertActionMounted('archive_review')
->callMountedAction()
->assertHasActionErrors(['archive_reason']);
}); });
it('keeps tenant review header hierarchy to one primary action and moves related links into summary context', function (): void { it('keeps tenant review header hierarchy to one primary action and moves related links into summary context', function (): void {

View File

@ -120,3 +120,26 @@
->assertSuccessful() ->assertSuccessful()
->assertSee('All tenants'); ->assertSee('All tenants');
}); });
it('redirects clear selected tenant from the evidence index to the workspace-safe evidence overview', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant($tenant, true);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
])
->from('/admin/evidence')
->post(route('admin.clear-tenant-context'))
->assertRedirect(route('admin.evidence.overview'));
$this->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.evidence.overview'))
->assertSuccessful()
->assertSee('No evidence snapshots in this scope');
});

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Workspaces\ManagedTenantsLanding;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('keeps the spec 195 managed-tenants landing available without an active tenant context', function (): void {
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-tenants']);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec195 Landing Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Spec195 Landing Tenant')
->assertSee('Managed tenants')
->assertDontSee('No tenant selected');
});
it('routes the managed-tenants landing back into the chooser flow and open-tenant flow', function (): void {
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-routing']);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec195 Routed Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
$component = Livewire::actingAs($user)
->test(ManagedTenantsLanding::class, ['workspace' => $workspace]);
$component
->call('goToChooseTenant')
->assertRedirect(ChooseTenant::getUrl());
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
Livewire::actingAs($user)
->test(ManagedTenantsLanding::class, ['workspace' => $workspace])
->call('openTenant', $tenant->getKey())
->assertRedirect(TenantResource::getUrl('view', ['record' => $tenant]));
});
it('rejects opening a tenant from the landing when the actor lacks tenant entitlement', function (): void {
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-guard']);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec195 Guarded Tenant',
]);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
session([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
Livewire::actingAs($user)
->test(ManagedTenantsLanding::class, ['workspace' => $workspace])
->call('openTenant', $tenant->getKey())
->assertNotFound();
});

View File

@ -43,7 +43,7 @@
'due_at' => now()->subDays(10), 'due_at' => now()->subDays(10),
]); ]);
$reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user); $reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The issue recurred after verification.');
expect($reopened->status)->toBe(Finding::STATUS_REOPENED) expect($reopened->status)->toBe(Finding::STATUS_REOPENED)
->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') ->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Support\Ui\GovernanceActions\Enums\GovernanceFrictionClass;
use App\Support\Ui\GovernanceActions\Enums\GovernanceReasonPolicy;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
it('keeps the spec 194 governance catalog aligned to the approved action matrix', function (
string $actionKey,
GovernanceFrictionClass $expectedFriction,
GovernanceReasonPolicy $expectedReasonPolicy,
string $expectedLabel,
): void {
$rule = GovernanceActionCatalog::rule($actionKey);
expect($rule->frictionClass)->toBe($expectedFriction)
->and($rule->reasonPolicy)->toBe($expectedReasonPolicy)
->and($rule->canonicalLabel)->toBe($expectedLabel)
->and($rule->requiresConfirmation())->toBeTrue()
->and($rule->surfaceKeys)->not->toBeEmpty();
})->with([
'approve exception' => ['approve_exception', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Approve exception'],
'reject exception' => ['reject_exception', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Reject exception'],
'renew exception' => ['renew_exception', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Renew exception'],
'revoke exception' => ['revoke_exception', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Revoke exception'],
'refresh review' => ['refresh_review', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Refresh review'],
'publish review' => ['publish_review', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Publish review'],
'archive review' => ['archive_review', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Archive review'],
'refresh evidence' => ['refresh_evidence', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Refresh evidence'],
'expire snapshot' => ['expire_snapshot', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Expire snapshot'],
'retry run' => ['retry_run', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Retry'],
'mark investigated' => ['mark_investigated', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Mark investigated'],
'cancel run' => ['cancel_run', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Cancel'],
'close finding' => ['close_finding', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Close'],
'reopen finding' => ['reopen_finding', GovernanceFrictionClass::F2, GovernanceReasonPolicy::Required, 'Reopen'],
'archive tenant' => ['archive_tenant', GovernanceFrictionClass::F3, GovernanceReasonPolicy::Required, 'Archive'],
'restore tenant' => ['restore_tenant', GovernanceFrictionClass::F1, GovernanceReasonPolicy::None, 'Restore'],
]);
it('keeps the f1 actions on the current-release no-rationale path', function (): void {
expect(GovernanceActionCatalog::rule('refresh_review')->reasonPolicy)->toBe(GovernanceReasonPolicy::None)
->and(GovernanceActionCatalog::rule('refresh_evidence')->reasonPolicy)->toBe(GovernanceReasonPolicy::None)
->and(GovernanceActionCatalog::rule('retry_run')->reasonPolicy)->toBe(GovernanceReasonPolicy::None)
->and(GovernanceActionCatalog::rule('restore_tenant')->reasonPolicy)->toBe(GovernanceReasonPolicy::None);
});
it('keeps governed surface bindings on canonical action names', function (): void {
$bindingsBySurface = collect(GovernanceActionCatalog::surfaceBindings())->groupBy('surfaceKey');
expect($bindingsBySurface->get('view_evidence_snapshot', collect())->pluck('actionName')->all())
->toEqualCanonicalizing(['refresh_evidence', 'expire_snapshot'])
->and($bindingsBySurface->get('view_tenant_review', collect())->pluck('actionName')->all())
->toContain('refresh_review', 'publish_review', 'archive_review');
});
it('documents the current-release deviations instead of relying on silent local overrides', function (): void {
$deviations = collect(GovernanceActionCatalog::documentedDeviations())
->map(fn (array $deviation): string => $deviation['actionKey'].'@'.$deviation['surfaceKey'])
->all();
expect($deviations)->toContain(
'reject_exception@finding_exceptions_queue',
'refresh_evidence@view_evidence_snapshot',
'retry_run@system_view_run',
'restore_tenant@view_tenant',
);
});
it('keeps every required-reason binding attached to a concrete ui field', function (): void {
$rules = collect(GovernanceActionCatalog::rules());
$bindingsByAction = collect(GovernanceActionCatalog::surfaceBindings())
->groupBy('actionName');
foreach ([
'approve_selected_exception' => 'approve_exception',
'reject_selected_exception' => 'reject_exception',
'renew_exception' => 'renew_exception',
'revoke_exception' => 'revoke_exception',
'publish_review' => 'publish_review',
'archive_review' => 'archive_review',
'expire_snapshot' => 'expire_snapshot',
'expire' => 'expire_snapshot',
'mark_investigated' => 'mark_investigated',
'cancel' => 'cancel_run',
'close' => 'close_finding',
'reopen' => 'reopen_finding',
'archive' => 'archive_tenant',
] as $bindingAction => $ruleKey) {
$rule = $rules->get($ruleKey);
$binding = $bindingsByAction->get($bindingAction, collect())->first();
expect($rule?->requiresReason())->toBeTrue()
->and($binding)->not->toBeNull()
->and($binding['uiFieldKey'] ?? null)->not->toBeNull();
}
});

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Governance Friction Hardening and Operator Vocabulary
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-12
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Repository-required surface naming, route references, and Filament action-matrix metadata are intentional product-operational constraints for this codebase, not implementation design instructions.
- No clarification markers remain. Spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,318 @@
openapi: 3.1.0
info:
title: Governance Action Semantics Internal Contract
version: 0.1.0
summary: Internal logical contract for Spec 194 governance action friction, reason, and vocabulary alignment
description: |
This contract is an internal planning artifact for Spec 194. The affected
surfaces continue to render through Filament and Livewire. The schemas
below define the bounded semantic contract for governance action families,
friction classes, reason policies, danger expectations, approved surface
bindings, and documented deviations.
servers:
- url: /internal
x-governance-action-consumers:
- family: exception_decision
sourceFiles:
- apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
- apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php
- apps/platform/app/Services/Findings/FindingExceptionService.php
mustRender:
- shared_family_binding
- required_reason_for_f2_or_f3_actions
- canonical_exception_vocabulary
mustNotRender:
- local_synonym_drift
- undocumented_reason_override
- family: review_lifecycle
sourceFiles:
- apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
- apps/platform/app/Services/TenantReviews/TenantReviewLifecycleService.php
mustRender:
- publish_vs_archive_semantic_separation
- export_remains_f0
mustNotRender:
- export_as_governance_peer
- family: evidence_lifecycle
sourceFiles:
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
- apps/platform/app/Services/Evidence/EvidenceSnapshotService.php
mustRender:
- refresh_vs_expire_separation
- required_reason_for_expire_when_declared
mustNotRender:
- refresh_and_expire_equivalent_semantics
- family: run_triage
sourceFiles:
- apps/platform/app/Filament/System/Pages/Ops/ViewRun.php
- apps/platform/app/Services/SystemConsole/OperationRunTriageService.php
mustRender:
- retry_cancel_investigated_severity_split
- required_reason_for_high_impact_actions
mustNotRender:
- cancel_as_lightweight_follow_up
- family: lifecycle_support
sourceFiles:
- apps/platform/app/Filament/Resources/FindingResource.php
- apps/platform/app/Filament/Resources/TenantResource.php
- apps/platform/app/Services/Findings/FindingWorkflowService.php
mustRender:
- consistent_close_reopen_family
- consistent_archive_restore_family
mustNotRender:
- undocumented_surface_specific_override
- family: regression_guards
sourceFiles:
- apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php
- apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php
- apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php
paths:
/internal/governance-actions/families/{family}:
get:
summary: Return the logical semantics contract for one governance action family
operationId: getGovernanceActionFamilyContract
parameters:
- name: family
in: path
required: true
schema:
$ref: '#/components/schemas/FamilyKey'
responses:
'200':
description: Logical semantics contract for the requested family
content:
application/vnd.tenantpilot.governance-action-semantics+json:
schema:
$ref: '#/components/schemas/GovernanceActionFamilyContract'
'404':
description: Requested family is not declared in the Spec 194 catalog
/internal/governance-actions/surfaces/{surface}:
get:
summary: Return all governance action bindings for one surface
operationId: getGovernanceSurfaceBindings
parameters:
- name: surface
in: path
required: true
schema:
$ref: '#/components/schemas/SurfaceKey'
responses:
'200':
description: Declared governance action bindings for the requested surface
content:
application/vnd.tenantpilot.governance-action-bindings+json:
schema:
type: array
items:
$ref: '#/components/schemas/GovernanceActionSurfaceBinding'
'404':
description: Requested surface has no Spec 194 governance bindings
components:
schemas:
FamilyKey:
type: string
enum:
- exception_decision
- review_lifecycle
- evidence_lifecycle
- run_triage
- finding_lifecycle
- tenant_lifecycle
- non_governance_navigation
SurfaceKey:
type: string
enum:
- finding_exceptions_queue
- view_finding_exception
- list_evidence_snapshots
- view_evidence_snapshot
- view_tenant_review
- view_finding
- tenantless_operation_run_viewer
- system_view_run
- view_tenant
- edit_tenant
FrictionClass:
type: string
enum:
- F0
- F1
- F2
- F3
ReasonPolicy:
type: string
enum:
- none
- optional
- required
DangerPolicy:
type: string
enum:
- none
- contextual
- required
AuditChannel:
type: string
enum:
- tenant_audit
- workspace_audit
- system_audit
- operation_context
GovernanceActionRule:
type: object
additionalProperties: false
required:
- actionKey
- canonicalLabel
- frictionClass
- reasonPolicy
- dangerPolicy
- auditVerb
- serviceOwner
properties:
actionKey:
type: string
canonicalLabel:
type: string
frictionClass:
$ref: '#/components/schemas/FrictionClass'
reasonPolicy:
$ref: '#/components/schemas/ReasonPolicy'
dangerPolicy:
$ref: '#/components/schemas/DangerPolicy'
modalHeadingPattern:
type: string
successNotificationPattern:
type: string
auditVerb:
type: string
serviceOwner:
type: string
GovernanceActionSurfaceBinding:
type: object
additionalProperties: false
required:
- surfaceKey
- pageClass
- actionName
- familyKey
- statePredicate
- auditChannel
properties:
surfaceKey:
$ref: '#/components/schemas/SurfaceKey'
pageClass:
type: string
actionName:
type: string
familyKey:
$ref: '#/components/schemas/FamilyKey'
statePredicate:
type: string
primaryOrSecondary:
type: string
enum:
- primary
- secondary
capabilityKey:
type:
- string
- 'null'
uiFieldKey:
type:
- string
- 'null'
auditChannel:
$ref: '#/components/schemas/AuditChannel'
DocumentedDeviation:
type: object
additionalProperties: false
required:
- actionKey
- surfaceKey
- deviationType
- rationale
- reviewGate
properties:
actionKey:
type: string
surfaceKey:
$ref: '#/components/schemas/SurfaceKey'
deviationType:
type: string
enum:
- friction_override
- reason_override
- danger_override
- vocabulary_override
rationale:
type: string
reviewGate:
type: string
allowedUntil:
type:
- string
- 'null'
GovernanceActionFamilyContract:
type: object
additionalProperties: false
required:
- familyKey
- canonicalObject
- rules
- bindings
- regressionRequirements
properties:
familyKey:
$ref: '#/components/schemas/FamilyKey'
canonicalObject:
type: string
panels:
type: array
items:
type: string
enum:
- tenant
- admin
- system
defaultActionOrder:
type: array
items:
type: string
defaultMutationScopeSource:
type: string
rules:
type: array
items:
$ref: '#/components/schemas/GovernanceActionRule'
bindings:
type: array
items:
$ref: '#/components/schemas/GovernanceActionSurfaceBinding'
deviations:
type: array
items:
$ref: '#/components/schemas/DocumentedDeviation'
regressionRequirements:
type: object
additionalProperties: false
required:
- requiresGuardCoverage
- requiresFeatureCoverage
- requiresBrowserSmoke
- requiresPositiveAuthCase
- requiresNegativeAuthCase
properties:
requiresGuardCoverage:
type: boolean
requiresFeatureCoverage:
type: boolean
requiresBrowserSmoke:
type: boolean
requiresPositiveAuthCase:
type: boolean
requiresNegativeAuthCase:
type: boolean
mustVerifyAuditPropagation:
type: boolean

View File

@ -0,0 +1,149 @@
# Data Model: Governance Friction Hardening and Operator Vocabulary
## Overview
This feature introduces no new persisted entity, table, enum-backed database field, or long-lived artifact. It reuses existing Filament pages, current mutation services, current audit loggers, and existing authorization helpers while adding a derived planning model for governance-action semantics.
## Existing Source Truths Reused Without Change
The following truths remain authoritative and are not redefined by this feature:
- existing page and resource routes
- existing model ownership and scope semantics
- existing capability checks and `UiEnforcement` behavior
- existing confirmation, audit, and `OperationRun` behavior for underlying actions
- existing domain services that own lifecycle mutation
- existing tenant, workspace, and system-plane separation
This feature changes action semantics, reason consistency, vocabulary, and regression protection only.
## New Derived Planning Models
### GovernanceActionFamilyEntry
**Type**: derived semantic inventory entry
**Source**: explicit Spec 194 family matrix + code-level guard catalog
| Field | Type | Notes |
|------|------|-------|
| `familyKey` | string | Stable identifier such as `exception_decision`, `review_lifecycle`, or `run_triage` |
| `canonicalObject` | string | Operator-facing object noun such as `exception`, `review`, `snapshot`, `run`, `finding`, or `tenant` |
| `panels` | array<string> | `tenant`, `admin`, `system` |
| `surfaceKeys` | array<string> | Concrete surfaces that host this family |
| `defaultActionOrder` | array<string> | Canonical order of verbs inside the family |
| `supportsDocumentedDeviation` | boolean | Whether family-level deviations are allowed only when explicitly catalogued |
| `defaultMutationScopeSource` | string | Where the family gets its mutation scope wording |
### GovernanceActionRule
**Type**: derived action-semantics rule
**Source**: governance catalog + concrete action bindings
| Field | Type | Notes |
|------|------|-------|
| `actionKey` | string | Stable identifier such as `approve_exception` or `archive_review` |
| `familyKey` | string | Links action to its family |
| `frictionClass` | string | `F0`, `F1`, `F2`, or `F3` |
| `reasonPolicy` | string | `none`, `optional`, or `required` |
| `dangerPolicy` | string | `none`, `contextual`, or `required` |
| `canonicalLabel` | string | Operator-facing default button label |
| `modalHeadingPattern` | string | Canonical confirmation or reason-capture heading |
| `successNotificationPattern` | string | Canonical post-success phrase |
| `auditVerb` | string | Stable action wording for audit trail alignment |
| `serviceOwner` | string | Existing service or class that owns the mutation |
### GovernanceActionSurfaceBinding
**Type**: derived surface binding entry
**Source**: existing Filament actions on target pages
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Stable surface identifier |
| `pageClass` | string | Concrete Filament page or resource page class |
| `actionName` | string | Local Filament action name |
| `familyKey` | string | Family that the local action belongs to |
| `statePredicate` | string | Human-readable visibility or state rule |
| `primaryOrSecondary` | string | Whether the action is the main governance action or a supporting action on that surface |
| `capabilityKey` | string or null | Canonical capability registry entry |
| `uiFieldKey` | string or null | Input field name used for reason capture when applicable |
| `auditChannel` | string | `tenant_audit`, `workspace_audit`, `system_audit`, or `operation_context` |
### DocumentedGovernanceDeviation
**Type**: derived exception entry
**Source**: spec-approved deviation list
| Field | Type | Notes |
|------|------|-------|
| `actionKey` | string | The action deviating from its family default |
| `surfaceKey` | string | Surface where the deviation occurs |
| `deviationType` | string | `friction_override`, `reason_override`, `vocabulary_override`, or `danger_override` |
| `rationale` | string | Why the default family rule is insufficient |
| `reviewGate` | string | What test or review guard must assert the exception |
| `allowedUntil` | string or null | Optional bounded-lifetime note for temporary deviations |
### GovernanceRegressionExpectation
**Type**: derived regression entry
**Source**: spec guard and test strategy
| Field | Type | Notes |
|------|------|-------|
| `familyKey` | string | The family under protection |
| `requiresGuardCoverage` | boolean | Catalog and exception guard required |
| `requiresFeatureCoverage` | boolean | At least one concrete page or service test required |
| `requiresBrowserSmoke` | boolean | Browser smoke required for high-risk families |
| `requiresPositiveAuthCase` | boolean | Positive authorization coverage required |
| `requiresNegativeAuthCase` | boolean | Negative authorization coverage required |
| `mustVerifyAuditPropagation` | boolean | Required when the family uses reason capture or strong lifecycle mutation |
## Resolution Rules
### Family-first rules
1. Every in-scope governance action resolves first to one `GovernanceActionFamilyEntry`, then to one concrete `GovernanceActionRule`.
2. Surface location alone cannot redefine friction class.
3. A local action name may differ from the canonical rule only if a documented deviation exists.
### Friction rules
1. `F0` actions cannot require confirmation, mandatory reason capture, or danger styling.
2. `F1` actions require confirmation but default to no mandatory reason.
3. `F2` actions require confirmation plus explicit explanation.
4. `F3` actions require confirmation, mandatory reason capture, and strong danger separation.
### Reason rules
1. Required reason capture must bind to a concrete UI field and a concrete audit or lifecycle propagation path.
2. Optional reason capture cannot silently become required on one surface without a documented deviation.
3. Existing structured lifecycle metadata may satisfy propagation only if the operator-entered rationale is preserved.
4. In the current release, `refresh_evidence`, `retry_run`, and `restore_tenant` resolve to `reasonPolicy=none`; optional F1 rationale capture is not introduced unless a documented deviation is added.
### Vocabulary rules
1. Canonical labels, modal headings, notifications, and audit verbs derive from the same family rule.
2. Navigation and export actions cannot borrow governance verbs from formal decision families.
3. Euphemistic or implementation-first synonyms are rejected unless explicitly documented.
4. Until a direct risk-acceptance surface exists, any indirect risk-acceptance wording must resolve through the exception family canon rather than creating a local synonym family.
### Deviation rules
1. Deviations are allowed only when the family default is insufficient for a specific surface or workflow state.
2. Every deviation must cite a rationale and a regression check.
3. No silent deviations are allowed.
## Relationships
- One `GovernanceActionFamilyEntry` contains one or more `GovernanceActionRule` entries.
- One `GovernanceActionRule` may bind to multiple `GovernanceActionSurfaceBinding` entries.
- One `GovernanceActionRule` may have zero or more `DocumentedGovernanceDeviation` entries.
- Every family must map to at least one `GovernanceRegressionExpectation` entry.
## Safety Rules
- No derived model may widen tenant, workspace, or system access beyond existing route and helper semantics.
- No action may lose current authorization, confirmation, audit, or `OperationRun` behavior when aligned to the catalog.
- No `F3` action may remain visually or semantically indistinguishable from nearby `F0` or `F1` actions.
- No family may introduce a new synonym set without explicit catalog ownership.
- No reason requirement may be added in UI without a corresponding propagation path in the owning service or audit log.

View File

@ -0,0 +1,316 @@
# Implementation Plan: Governance Friction Hardening and Operator Vocabulary
**Branch**: `194-governance-friction-hardening` | **Date**: 2026-04-12 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/spec.md`
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 surface layer, existing mutation services, existing audit loggers, and the current RBAC helpers. It explicitly avoids introducing a new workflow engine, new persistence, or a broad UI meta-framework.
## Summary
Codify one narrow governance-action semantics contract across tenant, workspace, and system surfaces. Introduce a derived governance action catalog that classifies in-scope actions into explicit friction classes, reason rules, danger semantics, and canonical vocabulary; then align the affected Filament pages and existing mutation services so exception, review, evidence, run-triage, finding-lifecycle, and tenant-lifecycle actions behave consistently. Protect the result with a spec-scoped guard, focused feature tests, RBAC regression coverage, and one browser smoke suite.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, existing audit loggers (`AuditLogger`, `WorkspaceAuditLogger`, `SystemConsoleAuditLogger`), existing mutation services (`FindingExceptionService`, `FindingWorkflowService`, `TenantReviewLifecycleService`, `EvidenceSnapshotService`, `OperationRunTriageService`)
**Storage**: PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned
**Testing**: Pest unit, feature, and browser tests run through Laravel Sail
**Target Platform**: Laravel monolith web application under `apps/platform`, with tenant routes under `/admin/t/{tenant}/...`, workspace routes under `/admin/...`, and platform routes under `/system/...`
**Project Type**: web application
**Performance Goals**: Preserve current operator interaction speed, keep render-time governance semantics DB-only with no outbound HTTP, avoid adding polling or additional round trips for confirmation flows, and keep any catalog lookups constant-time and local
**Constraints**: No new persistence, no new workflow states, no panel/provider changes, no raw capability strings, no cross-plane authorization drift, destructive-like actions keep `->requiresConfirmation()`, and no new generic execution framework
**Scale/Scope**: 8 primary operator surfaces across 3 authorization planes, 6 high-priority governance families, 4 medium or low-priority supporting families, focused changes to existing page classes, services, and tests only
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature does not alter inventory or snapshot truth. It governs action semantics only. |
| Read/write separation | PASS | PASS | Existing mutations keep confirmation, audit, and tests. No new write domain is introduced. |
| Graph contract path | N/A | N/A | No new Microsoft Graph endpoints or contract-registry changes are planned. |
| Deterministic capabilities | PASS | PASS | Existing capability registries and `UiEnforcement` remain authoritative. |
| Workspace + tenant isolation | PASS | PASS | Existing scope boundaries remain authoritative on `/admin`, `/admin/t/{tenant}`, and `/system`. |
| RBAC-UX authorization semantics | PASS | PASS | Non-member remains `404`, member-without-capability remains `403`, and server-side checks remain unchanged. |
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` flows keep their current lifecycle and feedback contract; this feature changes only semantics. |
| Data minimization | PASS | PASS | No new persistence, caches, or semantic mirrors are introduced. |
| Proportionality / anti-bloat | PASS | PASS | The plan adds one narrow derived catalog and guard instead of a new framework or persistence layer. |
| UI semantics / few layers | PASS | PASS | The feature uses direct action semantics and targeted builders instead of a new presenter stack. |
| Filament-native UI | PASS | PASS | Native Filament actions, action groups, and current shared helpers remain the implementation path. |
| Surface taxonomy / decision-first roles | PASS | PASS | Surface roles and action semantics remain aligned with Spec 192 and Spec 193 without reclassifying the affected surfaces. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain inside the existing Filament v5 + Livewire v4 stack. |
| Provider registration location | PASS | PASS | No provider change is needed; Laravel 11+ registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced and search settings are not altered. |
| Destructive action safety | PASS | PASS | Strong actions continue to execute via confirmed Filament actions plus current authorization. |
| Asset strategy | PASS | PASS | No new assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The plan remains on Filament v5 + Livewire v4 and introduces no legacy APIs.
- **Provider registration location**: No panel or provider changes are required; registration remains in `bootstrap/providers.php`.
- **Global search**: This feature does not add new globally searchable resources and does not change current resource search behavior.
- **Destructive actions**: `Revoke exception`, `Archive review`, `Cancel`, and `Archive` remain execution actions with confirmation and server-side authorization.
- **Asset strategy**: No new global or lazy-loaded assets are planned. Existing `filament:assets` deployment behavior remains unchanged.
- **Testing plan**: Add one spec-scoped guard layer, focused action and authorization tests, and one browser smoke suite across the highest-risk surfaces.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/research.md`.
Key decisions:
- Reuse existing mutation services and audit loggers; do not add a new governance workflow engine.
- Introduce one narrow derived governance-action catalog instead of page-local constants or a broad action meta-framework.
- Keep action semantics family-first: exception, review, evidence, run-triage, finding-lifecycle, and tenant-lifecycle.
- Treat reason capture as a family contract and extend current services or audit metadata only where the spec requires stronger propagation.
- Use the existing three testing layers already proven in this repo: spec guard, focused feature/RBAC tests, and one browser smoke suite.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/194-governance-friction-hardening/`:
- `research.md`: implementation-shape, reason-propagation, and vocabulary decisions
- `data-model.md`: derived governance family, rule, binding, deviation, and regression models
- `contracts/governance-action-semantics.logical.openapi.yaml`: internal logical contract for governance-action family rules and surface bindings
- `quickstart.md`: implementation and verification sequence for the feature
Design highlights:
- Keep all new semantics derived, not persisted.
- Model governance rules by action family first, then bind them to concrete page actions.
- Reuse existing services as owners of state change, audit logging, and operation behavior.
- Centralize only the shared semantics that already have multiple real concrete cases.
- Keep surface placement aligned with Spec 192 and Spec 193; Spec 194 governs semantic hardness, not where actions live.
## Phase 1 - Agent Context Update
Planned command:
- `.specify/scripts/bash/update-agent-context.sh copilot`
This feature does not introduce a new technology stack, but the required context refresh still runs after the technical context and design artifacts are complete.
## Project Structure
### Documentation (this feature)
```text
specs/194-governance-friction-hardening/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── contracts/
│ └── governance-action-semantics.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── Monitoring/
│ │ │ │ └── FindingExceptionsQueue.php # MODIFY
│ │ │ └── Operations/
│ │ │ └── TenantlessOperationRunViewer.php # REVIEW / possible minor alignment only
│ │ ├── Resources/
│ │ │ ├── FindingResource.php # MODIFY
│ │ │ ├── FindingResource/
│ │ │ │ └── Pages/
│ │ │ │ └── ViewFinding.php # MODIFY
│ │ │ ├── FindingExceptionResource/
│ │ │ │ └── Pages/
│ │ │ │ └── ViewFindingException.php # MODIFY
│ │ │ ├── EvidenceSnapshotResource.php # MODIFY
│ │ │ ├── EvidenceSnapshotResource/
│ │ │ │ └── Pages/
│ │ │ │ └── ViewEvidenceSnapshot.php # MODIFY
│ │ │ ├── TenantReviewResource/
│ │ │ │ └── Pages/
│ │ │ │ └── ViewTenantReview.php # MODIFY
│ │ │ ├── TenantResource.php # MODIFY
│ │ │ └── TenantResource/
│ │ │ └── Pages/
│ │ │ ├── ViewTenant.php # MODIFY
│ │ │ └── EditTenant.php # MODIFY
│ │ └── System/
│ │ └── Pages/
│ │ └── Ops/
│ │ └── ViewRun.php # MODIFY
│ ├── Services/
│ │ ├── Findings/
│ │ │ ├── FindingExceptionService.php # MODIFY
│ │ │ └── FindingWorkflowService.php # MODIFY
│ │ ├── Evidence/
│ │ │ └── EvidenceSnapshotService.php # MODIFY
│ │ ├── TenantReviews/
│ │ │ └── TenantReviewLifecycleService.php # MODIFY
│ │ └── SystemConsole/
│ │ └── OperationRunTriageService.php # MODIFY
│ ├── Support/
│ │ └── Ui/
│ │ └── GovernanceActions/
│ │ ├── GovernanceActionCatalog.php # NEW
│ │ ├── GovernanceActionRule.php # NEW
│ │ └── Enums/
│ │ ├── GovernanceFrictionClass.php # NEW
│ │ └── GovernanceReasonPolicy.php # NEW
└── tests/
├── Feature/
│ ├── Guards/
│ │ └── Spec194GovernanceActionSemanticsGuardTest.php # NEW
│ ├── Monitoring/
│ │ ├── FindingExceptionsQueueHierarchyTest.php # MODIFY
│ │ └── FindingExceptionsQueueTest.php # MODIFY
│ ├── Findings/
│ │ ├── FindingExceptionWorkflowTest.php # MODIFY
│ │ ├── FindingExceptionRenewalTest.php # MODIFY
│ │ ├── FindingExceptionRevocationTest.php # MODIFY
│ │ ├── FindingWorkflowViewActionsTest.php # MODIFY
│ │ └── FindingAuditLogTest.php # MODIFY
│ ├── Evidence/
│ │ └── EvidenceSnapshotResourceTest.php # MODIFY
│ ├── TenantReview/
│ │ ├── TenantReviewUiContractTest.php # MODIFY
│ │ └── TenantReviewLifecycleTest.php # MODIFY
│ ├── Operations/
│ │ ├── TenantlessOperationRunViewerTest.php # REVIEW / possible extend
│ │ └── SystemRunBlockedExecutionNotificationTest.php # REVIEW / possible extend
│ ├── Rbac/
│ │ ├── TenantLifecycleActionVisibilityTest.php # MODIFY
│ │ ├── EditTenantArchiveUiEnforcementTest.php # MODIFY
│ │ └── TenantResourceAuthorizationTest.php # MODIFY
│ └── Audit/
│ └── TenantLifecycleAuditLogTest.php # MODIFY
├── Unit/
│ └── Ui/
│ └── GovernanceActions/
│ └── GovernanceActionCatalogTest.php # NEW
└── Browser/
│ └── Spec194GovernanceFrictionSmokeTest.php # NEW
```
**Structure Decision**: Keep the work entirely inside the existing Laravel or Filament monolith under `apps/platform`. Add one narrow support namespace for shared governance semantics, then modify the affected page classes, mutation services, and focused tests. Do not introduce new persistence or a second runtime orchestration layer.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New derived friction and reason taxonomy | The feature needs one shared project-wide rule for actions that already exist across multiple surfaces and panels. | Local constants and per-page copy edits would not prevent drift or make regression guardable. |
| New shared governance-action catalog | Multiple concrete families already exist and need one canonical source for friction, reason, vocabulary, and approved deviations. | Keeping all semantics inside individual page classes would duplicate logic, produce inconsistent naming, and make CI enforcement weak. |
## Proportionality Review
- **Current operator problem**: Similar governance actions currently carry different semantic weight, reason burden, and vocabulary depending on the surface.
- **Existing structure is insufficient because**: Current page-local action definitions and service calls do not provide one guardable source for friction class, reason requirement, or canonical wording across families.
- **Narrowest correct implementation**: Add a small derived catalog and enums for friction and reason policy, bind existing actions to those rules, and keep all state change in the current services.
- **Ownership cost created**: One small shared support namespace, one spec-scoped guard test, targeted page and service test updates, and one browser smoke suite.
- **Alternative intentionally rejected**: A generic governance workflow framework or persisted action matrix was rejected because the repo only needs explicit cross-surface semantics, not a new runtime engine.
- **Release truth**: current-release operator safety, auditability, and semantic consistency
## Implementation Strategy
### Phase A - Codify the shared governance semantics contract
Goal: create one derived, testable source for action families without introducing a new workflow engine.
Changes:
- Add `GovernanceFrictionClass` and `GovernanceReasonPolicy` enums.
- Add `GovernanceActionRule` plus `GovernanceActionCatalog` as the canonical mapping of action family, friction, reason policy, danger expectation, and canonical copy.
- Declare the current-release indirect risk-acceptance continuity rule so finding exception semantics remain the canonical carrier until a direct risk-acceptance surface exists.
- Add `Spec194GovernanceActionSemanticsGuardTest.php` to ensure every in-scope action family and documented deviation is declared.
- Keep the catalog derived only. Do not create DB tables or stored mirrors.
Tests:
- Add `GovernanceActionCatalogTest.php` for catalog completeness and invariants.
- Add `Spec194GovernanceActionSemanticsGuardTest.php` for project-level inventory and exception coverage.
### Phase B - Align the highest-risk governance families
Goal: normalize the surfaces where semantic inconsistency carries the highest operator risk.
Changes:
- Align exception decision and lifecycle actions on `FindingExceptionsQueue` and `ViewFindingException`.
- Align review publication and archival semantics on `ViewTenantReview`.
- Align evidence refresh versus expiry on `EvidenceSnapshotResource` and `ViewEvidenceSnapshot`, keeping `Refresh evidence` as confirmed F1 with no operator-entered reason.
- Align run triage semantics on `System ViewRun`, keeping `Retry` as confirmed F1 with no operator-entered reason while `Mark investigated` and `Cancel` keep stronger rationale rules.
- Extend or standardize reason propagation in the owning services and audit loggers where F2 or F3 requires it.
Tests:
- Extend exception workflow and queue tests.
- Extend tenant review lifecycle and UI-contract tests.
- Extend evidence snapshot resource tests.
- Add or extend run-triage tests around `ViewRun`-owned actions and audit behavior.
### Phase C - Align supporting lifecycle families and preserve calm surfaces
Goal: finish cross-surface consistency without overcorrecting lower-risk actions.
Changes:
- Align finding close and reopen semantics across header, row, and bulk actions in `FindingResource`, `ViewFinding`, and `FindingWorkflowService`.
- Align tenant archive and restore semantics across `ViewTenant`, `EditTenant`, `TenantResource`, and current audit logging, keeping `Restore` as confirmed F1 with no operator-entered reason.
- Keep indirect risk-acceptance wording aligned with the exception family and document any allowed alias only in the shared catalog.
- Review `TenantlessOperationRunViewer` to ensure it stays context-first and does not drift into a triage surface unless justified.
- Keep navigation, export, and related-context actions explicitly outside governance friction.
Tests:
- Extend finding workflow header-, row-, and bulk-action tests, finding audit tests, and finding view-action tests.
- Extend tenant lifecycle RBAC, naming, and audit tests.
- Extend any affected operation viewer tests only if the viewer surface changes semantics.
### Phase D - Browser verification and final regression protection
Goal: prove the new semantics in a real browser and prevent new local exceptions from returning.
Changes:
- Add `Spec194GovernanceFrictionSmokeTest.php` covering exception queue/detail, review detail, evidence detail, system run detail, and tenant lifecycle surfaces.
- Ensure the guard layer fails when a new governance action lacks a declared family, friction class, reason rule, or documented deviation.
- Re-run formatting and the focused Sail verification pack.
Tests:
- Browser smoke coverage for visible friction, copy, and danger separation.
- Focused guard, feature, and authorization tests for all changed families.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| The catalog grows into a general workflow framework | Medium | Low | Keep only friction, reason, vocabulary, and deviation metadata; leave execution in existing services. |
| Reason capture is added inconsistently across services | High | Medium | Make reason policy family-owned and test propagation at service and audit levels. |
| Low-risk actions accidentally inherit F3 semantics | Medium | Medium | Keep explicit F0 and F1 boundaries in the catalog and browser smoke coverage. |
| Surface-specific copy diverges from the family canon | Medium | Medium | Use one catalog source for labels and heading copy where practical and guard with tests. |
| Authorization semantics drift while actions are reworked | High | Low | Reuse existing Policies and `UiEnforcement`, and extend positive plus negative RBAC tests. |
## Test Strategy
- Add `GovernanceActionCatalogTest.php` so friction, reason, and deviation rules remain internally consistent.
- Add `Spec194GovernanceActionSemanticsGuardTest.php` to validate that every in-scope family, indirect risk-acceptance alias, and documented exception is declared.
- Extend exception queue/detail tests to assert family-consistent reason prompts and semantic separation.
- Extend review, evidence, finding, run, tenant lifecycle, and audit tests where the plan changes semantics or reason propagation, including explicit header-, row-, and bulk-finding lifecycle coverage.
- Reuse existing RBAC feature tests to prove non-member `404`, member-without-capability `403`, and correct disabled-state behavior where UI enforcement remains visible.
- Add `Spec194GovernanceFrictionSmokeTest.php` using the existing spec-based browser smoke pattern already present in the repo.
- Run the focused Sail verification commands from `quickstart.md`, then run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
## Constitution Check (Post-Design)
Re-check result: PASS.
- Livewire v4.0+ compliance remains intact because all touched surfaces stay inside the existing Filament v5 + Livewire v4 stack.
- Provider registration remains unchanged in `bootstrap/providers.php`.
- The plan changes no global-search semantics; affected surfaces are existing resource pages or standalone pages whose search behavior is unchanged.
- Destructive and governance-changing actions keep `->requiresConfirmation()` plus existing authorization.
- No new assets are introduced; existing `filament:assets` deployment behavior remains sufficient.

View File

@ -0,0 +1,110 @@
# Quickstart: Governance Friction Hardening and Operator Vocabulary
## Goal
Bring in-scope governance actions under one bounded semantics contract so that similar operator decisions use the same friction class, reason burden, danger semantics, and vocabulary across tenant, workspace, and system surfaces.
## Implementation Sequence
1. Introduce the shared semantics catalog.
- Add the Spec 194 governance catalog and its enums.
- Define the canonical families, friction classes, reason rules, and approved deviations.
- Add the spec guard so new governance actions cannot appear without a declared family rule.
2. Align the highest-risk families first.
- Refactor `FindingExceptionsQueue` and `ViewFindingException` around one exception-decision family.
- Refactor `ViewTenantReview` so publish and archive semantics are clearly distinct from export.
- Refactor evidence snapshot actions so refresh and expiry no longer behave like equivalent mutations.
- Refactor `System ViewRun` so retry, cancel, and mark investigated clearly communicate different severity.
3. Extend service-level reason and audit propagation where the new family rules require it.
- Keep existing services as mutation owners.
- Add or standardize reason inputs and audit metadata only where F2 or F3 requires it.
- Preserve existing `OperationRun` and notification behavior.
4. Align supporting lifecycle families.
- Harmonize finding close and reopen semantics.
- Harmonize tenant archive and restore semantics across view and edit pages.
- Verify `TenantlessOperationRunViewer` remains context-first and does not invent local triage semantics.
5. Add regression protection and browser verification.
- Add the spec guard and unit coverage for the catalog.
- Extend focused feature and RBAC tests on the affected surfaces.
- Add one browser smoke suite that exercises the highest-risk family flows.
## Suggested Source Files
- `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceFrictionClass.php`
- `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceReasonPolicy.php`
- `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionRule.php`
- `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
- `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
- `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`
- `apps/platform/app/Filament/Resources/TenantResource.php`
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
- `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
- `apps/platform/app/Services/Findings/FindingExceptionService.php`
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- `apps/platform/app/Services/TenantReviews/TenantReviewLifecycleService.php`
- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`
## Suggested Test Files
- `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`
- `apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php`
- `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php`
- `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php`
- `apps/platform/tests/Feature/Findings/FindingExceptionWorkflowTest.php`
- `apps/platform/tests/Feature/Findings/FindingExceptionRenewalTest.php`
- `apps/platform/tests/Feature/Findings/FindingExceptionRevocationTest.php`
- `apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php`
- `apps/platform/tests/Feature/Findings/FindingAuditLogTest.php`
- `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
- `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`
- `apps/platform/tests/Feature/TenantReview/TenantReviewLifecycleTest.php`
- `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
- `apps/platform/tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php`
- `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`
- `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`
- `apps/platform/tests/Feature/Rbac/TenantResourceAuthorizationTest.php`
- `apps/platform/tests/Feature/Audit/TenantLifecycleAuditLogTest.php`
- `apps/platform/tests/Browser/Spec194GovernanceFrictionSmokeTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingExceptionWorkflowTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewLifecycleTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec194GovernanceFrictionSmokeTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open `FindingExceptionsQueue` and verify that approve and reject use the expected friction and reason semantics.
2. Open `ViewFindingException` and verify that renew and revoke are clearly differentiated in severity and rationale burden.
3. Open `ViewTenantReview` and verify that publish, export, and archive no longer read like equivalent lifecycle peers.
4. Open an evidence snapshot detail page and verify that refresh remains lighter than expire.
5. Open `System ViewRun` and verify that retry, cancel, and mark investigated communicate different seriousness.
6. Open `ViewTenant` and `EditTenant` and verify that archive and restore remain semantically aligned across both surfaces.
7. Confirm browser smoke checks show no JavaScript errors on the remediated governance surfaces.
## Deployment Notes
- No migration is expected.
- No provider registration change is expected; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
- No new asset registration is expected. Existing deploy handling of `cd apps/platform && php artisan filament:assets` remains sufficient.

View File

@ -0,0 +1,139 @@
# Research: Governance Friction Hardening and Operator Vocabulary
## Decision: Introduce one narrow governance-action catalog instead of a new governance workflow framework
### Rationale
Spec 194 needs one project-wide, testable source for friction class, reason policy, danger expectation, and canonical vocabulary across actions that already exist on multiple surfaces. The repo already has several concrete governance families: exception decisions, review lifecycle, evidence lifecycle, run triage, finding lifecycle, and tenant lifecycle. That is enough real variance to justify one small derived catalog, but not a new runtime workflow engine.
### Alternatives considered
- Keep all semantics page-local and document them only in the spec: rejected because local copy and modal logic would drift again and CI could not enforce the rules.
- Build a full governance action framework with custom builders, registries, and resolvers: rejected because the repo only needs shared semantics, not a second execution engine.
## Decision: Keep existing mutation services and audit loggers as owners of state change
### Rationale
The current services already own the actual lifecycle mutation and most audit logging:
- `FindingExceptionService` for approve, reject, renew, revoke
- `TenantReviewLifecycleService` for publish and archive
- `EvidenceSnapshotService` for refresh and expire
- `OperationRunTriageService` for retry, cancel, and mark investigated
- `FindingWorkflowService` for close and reopen
- `TenantResource` lifecycle helpers plus `WorkspaceAuditLogger` for archive and restore
The narrowest correct implementation is to align UI semantics and extend service inputs or audit metadata only where Spec 194 requires stronger reason propagation.
### Alternatives considered
- Move lifecycle mutations into a new shared governance service layer: rejected because it would duplicate working domain services and add coordination overhead without solving a new business problem.
- Keep reason capture only in UI and not in service-level inputs: rejected because Spec 194 requires reasons to remain audit-visible and not be purely presentational.
## Decision: Treat reason capture as a family contract, not a local modal choice
### Rationale
Current repo behavior is inconsistent:
- Exception family already captures reasons across all four major actions.
- Review publish or archive capture no reason.
- Evidence refresh or expire capture no reason.
- System run triage captures reason only for `Mark investigated`, not for `Cancel`.
- Finding `Close` captures reason, but `Reopen` does not.
- Tenant archive or restore capture no reason.
Spec 194 therefore must define reason policy by family and then drive the UI forms and service inputs from that rule.
### Alternatives considered
- Leave reason capture to each page owner: rejected because it produced the current inconsistency.
- Force a reason on every action: rejected because it would over-harden F0 and F1 actions and reduce operator velocity without safety benefit.
## Decision: Distinguish technical refresh from formal governance lifecycle
### Rationale
The repo already shows that similarly placed actions do not have equivalent business meaning:
- `Refresh evidence` is operational regeneration of data.
- `Expire snapshot` formally invalidates a governance artifact.
- `Refresh review` is operational recomputation.
- `Publish review` is a formal release step.
- `Retry` is follow-up work.
- `Cancel` is a stronger intervention.
Spec 194 should therefore classify by business impact, not by whether the action appears in a header or uses the same Filament primitive.
### Alternatives considered
- Classify by surface location: rejected because the same family appears on queue, detail, workspace, and system pages.
- Classify by current button color: rejected because current color usage is part of the inconsistency.
## Decision: Use canonical operator vocabulary per family and prohibit casual synonyms
### Rationale
The same domain effect should not oscillate between verbs. The current repo already has stable families that can be hardened:
- `Approve / Reject`
- `Renew exception / Revoke exception`
- `Publish review / Archive review / Create next review`
- `Refresh evidence / Expire snapshot`
- `Close / Reopen`
- `Retry / Cancel / Mark investigated`
- `Archive / Restore`
Spec 194 should preserve those families and use them consistently in action labels, modal headings, notifications, and audit wording.
### Alternatives considered
- Allow page-specific synonyms where copy “reads better”: rejected because operator ambiguity is precisely the problem this spec is solving.
- Rename everything to one generic lifecycle lexicon: rejected because different domains still need domain-specific objects and verbs.
## Decision: Keep the new semantics derived and guardable, not persisted
### Rationale
The new friction classes and reason policies are product rules, not new domain records. They do not need their own table or long-lived artifact. A derived catalog plus tests is enough to make the rules explicit, reviewable, and regression-safe.
### Alternatives considered
- Persist the matrix in the database or a user-editable admin screen: rejected because the semantics are part of product behavior, not tenant-owned configuration.
- Leave the matrix only in documentation: rejected because the repo needs an enforceable regression gate.
## Decision: Reuse the existing test layering already proven in this repo
### Rationale
The repo already has the right three layers for Spec 194:
- Guard tests for contract-level invariants
- Focused feature or RBAC tests around concrete surfaces and services
- Browser smoke tests for cross-surface operator flows
This gives durable coverage without overbuilding.
### Alternatives considered
- Browser-test every friction permutation: rejected because service and page tests already cover most of the logic more cheaply.
- Add only a unit test for the catalog: rejected because surface wiring and authorization semantics would remain unverified.
## Decision: Align the highest-risk families first
### Rationale
The strongest current inconsistencies and operator risks are concentrated in:
- Exception decision and lifecycle actions
- Review publication and archival
- Evidence expiry semantics
- System run triage
These should be aligned before lower-risk supporting families such as tenant restore or navigation-adjacent actions.
### Alternatives considered
- Start with the broadest surface rollout: rejected because it would spread effort without first hardening the most consequential actions.
- Start with tenant lifecycle only: rejected because exception, review, evidence, and run triage already carry higher governance importance.

View File

@ -0,0 +1,365 @@
# Feature Specification: Governance Friction Hardening and Operator Vocabulary
**Feature Branch**: `194-governance-friction-hardening`
**Created**: 2026-04-12
**Status**: Proposed
**Input**: User description: "Spec 194 - Governance Friction Hardening & Operator Vocabulary"
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Governance-relevant actions across the admin panel still use inconsistent confirm depth, reason capture, danger styling, and operator wording for equivalent decisions.
- **Today's failure**: Operators can perform similar exception, review, evidence, run-triage, and tenant lifecycle actions with different semantic weight depending on the surface, which weakens audit clarity and increases misjudgment risk.
- **User-visible improvement**: Similar governance decisions will feel the same across queue, detail, workspace, and system surfaces, while harmless navigation and export actions remain visibly lighter.
- **Smallest enterprise-capable version**: Inventory all in-scope governance actions, group them into action families, assign one friction class per action, align the highest-risk families first, document exceptions, and add a lightweight regression gate for future actions.
- **Explicit non-goals**: No new governance states, no new workflow engine, no header-layout rewrite, no dispatch/preflight refactor, no new audit domain, and no generic action DSL.
- **Permanent complexity imported**: A small semantic layer with friction classes F0-F3, reason rules, a vocabulary canon, and a documented exception path.
- **Why now**: Spec 192 and Spec 193 solve surface placement and hierarchy, but not the semantics of the governance actions themselves.
- **Why not local**: The same action families already appear across queue, detail, workspace, and system surfaces, so page-by-page fixes would not stop drift.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Cross-surface taxonomy risk and multi-surface remediation breadth risk. Defense: no new persistence, no new domain workflow, and no generic framework beyond the smallest shared rule model.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- Existing tenant admin detail pages under `/admin/t/{tenant}/...` for findings, finding exceptions, evidence snapshots, and tenant reviews
- Existing workspace admin surfaces under `/admin/...` for finding exception queueing, operations, audit, and workspace tenant lifecycle
- Existing system operation run detail pages under `/system/ops/...`
- **Data Ownership**:
- No new tables, persisted entities, or ownership boundaries are introduced.
- Tenant-owned records remain tenant-owned: findings, finding exceptions, evidence snapshots, and tenant reviews.
- Workspace queue and monitoring pages continue to present existing workspace-visible or entitled cross-tenant views only.
- System run pages continue to expose existing platform-visible run records only.
- **RBAC**:
- Tenant admin plane `/admin/t/{tenant}` keeps tenant membership plus capability checks for finding, exception, evidence, and review actions.
- Workspace admin plane `/admin` keeps workspace membership and capability checks for queue review and tenant lifecycle actions.
- System plane `/system` keeps platform capabilities for operational triage.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Workspace monitoring pages may keep an entitled tenant prefilter or remembered context, but the same action family must retain the same friction class whether the operator arrived through workspace scope or a narrower tenant-prefiltered route.
- **Explicit entitlement checks preventing cross-tenant leakage**: Re-grouping, relabeling, or friction alignment must not bypass existing Gates, Policies, UiEnforcement helpers, or capability registries. Non-members remain deny-as-not-found, scoped records remain tenant-safe, and workspace/system pages must not hint at inaccessible tenant detail.
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | Primary Decision Surface | Approve or reject a pending exception request | Tenant, finding summary, validity, request age, and whether a decision is pending now | Tenant detail, finding detail, request history, evidence references | Primary because this is the workspace approval inbox | Follows pending-governance review, not storage objects | Removes switching between queue, finding detail, and tenant detail before deciding |
| ViewFindingException | Primary Decision Surface | Renew or revoke an existing exception | Current validity, expiry, owner, status, and whether renewal or revocation is allowed | Related finding, evidence references, prior decisions | Primary because active exception lifecycle should remain decidable on one page | Follows exception maintenance workflow | Avoids reconstructing state from audit or related records |
| ViewEvidenceSnapshot | Secondary Context Surface | Decide whether evidence remains valid and inspect its current truth | Artifact truth, completeness, status, expiry, and whether expiration is available | Operation run, review pack, raw evidence dimensions | Not primary because visits are usually inspection-first, but lifecycle mutation still needs governed semantics | Supports evidence inspection with occasional lifecycle intervention | Prevents refresh and expiry from reading as equivalent generic mutations |
| ViewTenantReview | Primary Decision Surface | Publish, archive, or continue a review lifecycle | Review status, readiness, publication state, and next lifecycle step | Pack export and deeper evidence context | Primary because review publication is a formal governance moment | Follows review release workflow | Prevents publish, export, and archive from blurring together |
| ViewFinding | Secondary Context Surface | Close, reopen, or route a finding into exception governance | Current status, severity, governance posture, and queue availability | Related records and deeper operational evidence | Secondary because it often feeds a later governance step | Aligns with finding triage and escalation | Keeps queue/navigation actions distinct from lifecycle mutation |
| TenantlessOperationRunViewer | Secondary Context Surface | Inspect one run and understand whether any follow-up exists | Run identity, scope, outcome, freshness, and follow-up availability | Related links, restore continuation, detailed diagnostics | Secondary because the surface is context-first unless it genuinely owns intervention | Supports monitoring review before intervention | Prevents context and navigation from feeling like governance mutation |
| System ViewRun | Primary Decision Surface | Retry, cancel, or mark a run investigated | Run identity, current outcome, retryability, cancellability, and investigation need | Related runbooks and downstream operational context | Primary because it is the platform triage point for run intervention | Follows operations workflow | Makes the difference between retry, cancel, and investigated immediately legible |
| ViewTenant / EditTenant | Primary Decision Surface | Archive or restore a tenant lifecycle state | Current lifecycle state and currently allowed lifecycle action | Supporting setup and external context | Primary because tenant lifecycle is a formal operating-state decision | Follows tenant lifecycle governance | Prevents archive/restore from behaving like routine utilities |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | Queue / Workbench | Governance decision workbench | Approve or reject the selected request | Explicit inspect action selects the request and opens in-page detail | forbidden | Scope, filters, and related navigation stay outside the decision lane | Review-selected decision group only | Existing workspace exception queue route | Same page with selected exception context plus tenant detail drilldown | Workspace scope and optional tenant prefilter | Finding exceptions / exception request | Whether a decision-ready request is selected now | none |
| ViewFindingException | Detail / Decision | Governance lifecycle detail | Renew or revoke the exception | Canonical tenant detail page | not applicable | Safe related navigation stays secondary | Revocation remains separated from renewal | Existing tenant exception list route | Existing tenant exception detail route | Active tenant context only | Finding exception / exception | Whether the exception is active, expiring, or revocable | none |
| ListEvidenceSnapshots | List / Table | Read-only registry report with lifecycle row action | Inspect a snapshot or expire an obsolete one | Clickable row opens snapshot detail | required | Row-level More menu only for non-primary mutation | `Expire snapshot` stays grouped under More | Existing tenant evidence index route | Existing tenant evidence detail route | Active tenant context only | Evidence snapshots / snapshot | Current evidence truth and next step | none |
| ViewEvidenceSnapshot | Detail / Context | Evidence lifecycle detail | Refresh evidence or expire snapshot | Canonical tenant snapshot detail page | not applicable | Related operation/review navigation stays contextual | `Expire snapshot` remains danger-separated from `Refresh evidence` | Existing tenant evidence index route | Existing tenant evidence detail route | Active tenant context only | Evidence snapshot / snapshot | Whether the snapshot is current, complete, and still valid | none |
| ViewTenantReview | Detail / Decision | Governance release detail | Publish review, export pack, or archive review | Canonical tenant review detail page | not applicable | Export and next-review actions stay secondary | Archive stays in a separate danger group | Existing tenant review register route | Existing tenant review detail route | Active tenant context only | Tenant reviews / review | Publication readiness and current lifecycle state | none |
| ViewFinding | Detail / Context | Finding lifecycle and governance context detail | Close, reopen, or route into exception governance | Canonical tenant finding detail page | not applicable | Related record and queue navigation stay separate from workflow mutation | Any destructive-like lifecycle action stays in the workflow family, not as navigation | Existing tenant findings route | Existing tenant finding detail route | Active tenant context only | Findings / finding | Governance posture and next action | none |
| TenantlessOperationRunViewer | Detail / Monitoring | Workspace run context viewer | Refresh or follow related run context | Canonical workspace operation detail page | forbidden | Scope, return, refresh, and related links remain structured and context-first | No destructive triage action is promoted unless the surface genuinely owns it | Existing workspace operations route | Existing tenantless run detail route | Workspace scope and optional tenant-origin context | Operations / operation run | Whether the run needs attention and what follow-up exists | context-first viewer |
| System ViewRun | Detail / Decision | Platform run triage detail | Retry, cancel, or mark the run investigated | Canonical system run detail page | forbidden | Navigation back to runs and runbooks stays secondary | Cancel is clearly separated as the strongest action | Existing system runs route | Existing system run detail route | Platform/system scope only | Operations / operation run | Whether intervention is possible and how severe it is | none |
| ViewTenant / EditTenant | Detail / Lifecycle | Workspace tenant lifecycle surface | Archive or restore the tenant | Canonical workspace tenant view/edit pages | not applicable | Setup and external links remain outside lifecycle semantics | Archive and restore stay inside one lifecycle family with explicit severity | Existing workspace tenant register route | Existing workspace tenant view/edit routes | Workspace scope only | Tenants / tenant | Whether the tenant is active or archived and which lifecycle action is allowed | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | Workspace approver | Approve or reject pending exception requests | Queue workbench | What request needs a formal decision right now? | Request state, tenant, finding summary, validity, review urgency, selection state | Related finding detail and full tenant exception detail | governance validity, request status, urgency | TenantPilot only | Approve exception, Reject exception | Reject exception may be negative governance, but does not automatically become F3 |
| ViewFindingException | Tenant manager | Renew or revoke an existing exception | Detail decision surface | Is this exception still justified, and what lifecycle step is warranted now? | Current validity, status, owner, expiry, renewal eligibility, revocation eligibility | Evidence references and decision history | lifecycle, governance validity, expiry | TenantPilot only | Renew exception | Revoke exception |
| ViewEvidenceSnapshot | Tenant operator | Refresh or formally expire the active evidence basis | Detail context surface | Is this evidence still valid to govern from? | Artifact truth, completeness, freshness, expiry, latest related operation | Review-pack and operation drilldowns, raw evidence dimensions | evidence validity, completeness, freshness | TenantPilot only | Refresh evidence | Expire snapshot |
| ViewTenantReview | Tenant reviewer | Publish or archive review lifecycle state | Detail decision surface | Is this review ready for formal release, continuation, or closure? | Review status, readiness, summary truth, next lifecycle step | Exported pack and supporting evidence detail | lifecycle, completeness, readiness | TenantPilot only | Publish review, Create next review | Archive review |
| ViewFinding | Tenant operator | Close, reopen, or route a finding into exception governance | Detail context surface | Does this finding need lifecycle closure, reopening, or governed exception handling? | Finding status, severity, governance posture, queue availability | Related navigation and deeper evidence | lifecycle, governance posture, severity | TenantPilot only | Close, Reopen, Request exception | Close is lifecycle-significant but not equivalent to tenant- or run-level destructive action |
| TenantlessOperationRunViewer | Workspace operator | Inspect run state and understand whether follow-up exists | Monitoring detail viewer | What happened, what scope does it affect, and is any further action warranted? | Run identity, scope, outcome, freshness, follow-up availability | Related links, restore continuation, detailed failure reasons | execution outcome, freshness, lifecycle attention | Must remain explicit when a follow-up mutation is exposed | Refresh, Open related context | None by default on this surface |
| System ViewRun | Platform operator | Retry, cancel, or mark a run investigated | Platform decision surface | Which intervention is justified for this run, and how serious is it? | Run identity, current outcome, action availability, investigation need | Runbooks and related operational context | execution outcome, retryability, cancellation state | Must be explicit per action before execution | Retry, Mark investigated | Cancel |
| ViewTenant / EditTenant | Workspace operator | Archive or restore a tenant lifecycle state | Lifecycle detail/edit surface | Should this tenant remain active, or should its lifecycle state change formally? | Current lifecycle state and currently allowed lifecycle action | Supporting setup or external reference context | lifecycle readiness | TenantPilot only | Restore | Archive |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: yes
- **Current operator problem**: Operators currently infer governance severity from local page conventions instead of one stable product rule, which weakens safety and audit clarity.
- **Existing structure is insufficient because**: Surface-layout rules alone do not determine confirm depth, reason obligation, danger semantics, or canonical verb choice across panels.
- **Narrowest correct implementation**: Introduce one narrow derived governance-action catalog, friction classes, reason rules, a vocabulary canon, and documented exceptions. Do not add new workflow states, new persistence, or a generic execution framework.
- **Ownership cost**: Ongoing review of new governance actions against the matrix, lightweight documentation upkeep for exceptions, and focused regression tests.
- **Alternative intentionally rejected**: Purely local page cleanup was rejected because it would not stop the same governance family from drifting again on another surface.
- **Release truth**: current-release operator safety and semantic consistency
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Make a Formal Exception Decision Safely (Priority: P1)
As a workspace approver, I want approving, rejecting, renewing, and revoking exception requests to use predictable friction and vocabulary so I can understand the seriousness of the action before I commit it.
**Why this priority**: Exception approval and exception lifecycle maintenance are the clearest governance decisions already spread across queue and detail surfaces.
**Independent Test**: This can be tested by reviewing the queue and exception detail surfaces alone and confirming that the same action family keeps the same confirm depth, reason expectation, and wording on both surfaces.
**Acceptance Scenarios**:
1. **Given** a pending exception request on Finding Exceptions Queue, **When** the operator chooses approve or reject, **Then** the modal language, reason handling, and severity cues match the shared rule for formal exception decisions.
2. **Given** an active exception on ViewFindingException, **When** the operator chooses renew or revoke, **Then** the page uses the same exception-family vocabulary and the stronger lifecycle action is clearly separated from the lighter one.
---
### User Story 2 - Distinguish Governance Lifecycle from Technical Refresh (Priority: P1)
As a tenant reviewer, I want publication, archival, refresh, and evidence expiry actions to feel semantically distinct so I do not confuse a technical refresh with a formal governance decision.
**Why this priority**: Review and evidence surfaces already mix technical and governance-adjacent lifecycle actions, making them the highest-value place to harden semantics after exception handling.
**Independent Test**: This can be tested by reviewing ViewTenantReview and ViewEvidenceSnapshot without changing any other page and verifying that publish, archive, and expire semantics are clearly distinct from export or refresh.
**Acceptance Scenarios**:
1. **Given** a mutable tenant review, **When** the operator compares Publish review, Export executive pack, and Archive review, **Then** publication and archival read as governance lifecycle steps while export remains clearly non-governance.
2. **Given** an evidence snapshot that can still be expired, **When** the operator compares Refresh evidence and Expire snapshot, **Then** refresh reads as a lower-friction operational action and expiry reads as a governed lifecycle invalidation.
---
### User Story 3 - Intervene on Runs with Calibrated Severity (Priority: P2)
As a platform operator, I want retry, cancel, and mark investigated to communicate different levels of seriousness so I can choose the right intervention without over- or under-reacting.
**Why this priority**: Run triage is a high-impact area where cancel and retry should never look like sibling actions with equivalent weight.
**Independent Test**: This can be tested entirely on run viewers by verifying that retry remains lighter, mark investigated captures rationale, and cancel is clearly the strongest action.
**Acceptance Scenarios**:
1. **Given** a system run that can be retried or cancelled, **When** the header renders, **Then** Retry and Cancel are visibly differentiated by friction class and danger semantics.
2. **Given** a run that needs explanation but not cancellation, **When** the operator marks it investigated, **Then** a reason is required and the action does not impersonate either retry or cancel semantics.
---
### User Story 4 - Keep Tenant Lifecycle Clear Without Inflating All Actions (Priority: P3)
As a workspace operator, I want tenant archive and restore actions to be consistent across View and Edit surfaces without turning every lifecycle action into a maximal danger event.
**Why this priority**: Tenant lifecycle is an important medium-priority family that should be aligned once the highest-risk governance families are stabilized.
**Independent Test**: This can be tested by comparing ViewTenant and EditTenant and confirming that archive and restore keep the same vocabulary, confirm depth, and danger rules on both surfaces.
**Acceptance Scenarios**:
1. **Given** an active tenant, **When** Archive is available on ViewTenant and EditTenant, **Then** the action uses the same wording, strong separation, and reason expectations on both surfaces.
2. **Given** an archived tenant, **When** Restore is available, **Then** it remains clearly distinct from Archive and does not inherit unnecessary high-risk semantics.
### Edge Cases
- If the same action family appears on a queue, a record page, and a system page, the surface type must not silently change the friction class.
- If a lower-risk operational action sits next to a formal governance action, the lower-risk action must not inherit danger styling or mandatory-reason burden just because of proximity.
- If a legacy surface exposes only one side of a canonical pair, the visible action still has to use the project-wide verb and friction rule for that family.
- If a structured form already captures required governance fields, the operator must still see a clear rationale prompt where the family rule requires explanation.
- If a future direct risk-acceptance surface is introduced, it must inherit the same vocabulary and reason rules currently carried by finding exception governance.
- If a surface does not genuinely own a governance mutation, it must not promote a navigation shortcut or export as if it were a formal decision action.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes operator-facing governance semantics on existing write actions only. It introduces no new Microsoft Graph contract, no new queued workflow, and no new persistence. Existing mutations keep their current preview, confirmation, audit, and tenant-isolation rules; any DB-only governance mutation that does not create an `OperationRun` must continue to emit the existing audit trail.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature deliberately adds only the smallest semantic layer needed now: friction classes, reason rules, a vocabulary canon, and documented exceptions. It does not add a generic action framework, new domain states, or a persisted matrix table.
**Constitution alignment (OPS-UX):** Existing actions that already create or reuse `OperationRun` objects, such as review refresh, evidence refresh, verification, and run retry/cancel flows, keep the current Ops-UX contract: toast intent-only, progress in monitoring surfaces, terminal DB notification where applicable, and service-owned `OperationRun.status` / `OperationRun.outcome` transitions. Spec 194 harmonizes only semantics, not operation lifecycle mechanics.
**Constitution alignment (RBAC-UX):** The affected planes are tenant admin `/admin/t/{tenant}`, workspace admin `/admin`, and system `/system`. No cross-plane access is widened. Non-members or users lacking entitled scope remain `404`, members missing capability remain `403`, and every destructive-like action still requires confirmation plus server-side authorization through existing Policies, Gates, or capability-backed helpers. Regression coverage must include at least one positive and one negative authorization path across tenant, workspace, and system families touched by this spec.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This spec does not touch login handshakes or `/auth/*` routes.
**Constitution alignment (BADGE-001):** This spec does not introduce new badge domains. Danger, severity, and lifecycle emphasis must continue to derive from centralized badge or action semantics rather than page-local color language.
**Constitution alignment (UI-FIL-001):** UI changes remain on native Filament `Action`, `ActionGroup`, `ViewRecord`, `ListRecords`, and existing shared helpers such as `UiEnforcement`. The feature must avoid page-local button frameworks or ad-hoc border/color systems. Semantic emphasis is carried through action grouping, action color, confirmation depth, and copy, not custom markup.
**Constitution alignment (UI-NAMING-001):** Each governance family must preserve one domain-first verb across buttons, modal headings, required-reason prompts, notifications, run titles, and audit prose. The operator verb must describe the business effect, not an implementation step or storage concern.
**Constitution alignment (DECIDE-001):** The Decision-First Surface Role table above defines which surfaces are primary decision surfaces and which remain secondary context or evidence surfaces. The feature keeps one governance case decidable in one place and prevents secondary exports, navigation, or related links from competing with the actual decision moment.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The UI/UX Surface Classification and Operator Surface Contract tables above define one inspect model per surface, the likely next operator action, placement for secondary and destructive actions, canonical nouns, scope signals, and any exception rationale. No affected surface may quietly mix navigation, review context, and governance mutation as undifferentiated peers.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Navigation, mutation, selected-context actions, and dangerous actions must remain structurally separated. `ActionGroup` usage must be meaningful rather than a mixed catch-all, and any exception must be justified as a real workflow need, not a convenience shortcut.
**Constitution alignment (OPSURF-001):** Default-visible content stays operator-first. Diagnostics and raw evidence remain secondary. Mutating actions must disclose mutation scope before execution where the underlying action affects more than local UI state, and the safe-execution pattern for stronger actions remains visible through wording and confirmation structure.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** This feature introduces semantic rules without adding a new presenter stack or persisted mirror. Domain truth remains in existing models and services; the new cross-cutting layer only governs action classification, operator copy, and friction consistency. Tests must verify business-visible consequences, not an indirection layer.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is expected to remain satisfied on all affected surfaces. Each surface must keep exactly one primary inspect/open model, no redundant View actions should be added, no empty `ActionGroup` placeholders may be introduced, and destructive actions must continue to use `Action::make(...)->action(...)->requiresConfirmation()`. No exemption is planned beyond existing context-only related-link groupings on already classified surfaces.
**Constitution alignment (UX-001 - Layout & Information Architecture):** Existing screen layouts, infolists, empty states, and table affordances remain intact. Spec 194 changes action semantics and grouping only; it does not justify converting infolists into disabled forms, removing list filters, or weakening empty-state clarity.
### Design Principles
1. **Gleiches Risiko, gleiche Friction-Klasse**: Actions mit vergleichbarer Governance-Bedeutung verwenden dieselbe Grundklasse, unabhaengig von Surface oder Panel.
2. **Danger ist semantisch, nicht dekorativ**: Danger signalisiert Risiko, Irreversibilitaet oder starke Wirkung und ist kein allgemeiner Mutations-Akzent.
3. **Reason-Capture ist bewusst abgestuft**: Nicht jede Mutation braucht eine Begruendung, aber formale Governance-Entscheidungen und starke Lifecycle-Eingriffe schon.
4. **Operator-Vokabular ist systemweit lesbar**: Aehnliche Handlungen verwenden dieselben oder bewusst kompatiblen Verben.
5. **Friction wird nicht lokal improvisiert**: Confirm, optionaler Grund, Pflichtgrund und Danger-Trennung werden nicht pro Surface neu erfunden.
6. **Cross-cutting schlaegt lokale Eleganz**: Lokale Convenience rechtfertigt keine stille Governance-Ausnahme.
### Friction Taxonomy
| Klasse | Bedeutung | Bestaetigung | Reason-Capture | Danger-Semantik | Typische Beispiele |
|---|---|---|---|---|---|
| F0 | Informational / Low Impact | keine zusaetzliche Friction | kein Grund | kein Danger | Open related record, Open approval queue, Export executive pack, Show all operations |
| F1 | Confirmed Operational Action | Bestaetigung erforderlich | kein Grund oder optional | nur wenn die Wirkung real riskant oder schwer reversibel ist | Refresh review, Refresh evidence, Retry run, Restore tenant |
| F2 | Explained Governance Action | Bestaetigung erforderlich | Grund erforderlich oder strukturiert eindeutig gefuehrt | abhaengig von Wirkung, aber immer semantisch klar | Approve exception, Reject exception, Renew exception, Publish review, Expire snapshot, Close finding, Reopen finding, Mark investigated |
| F3 | High-Risk / High-Impact Governance Action | strikte Bestaetigung | Grund zwingend | Danger zwingend und getrennte Platzierung | Revoke exception, Cancel run, Archive tenant, Archive review, kuenftige override- oder force-nahe Aktionen |
Eine action darf nur mit dokumentierter Ausnahme von ihrer Standardklasse abweichen.
### Reason Capture Rules
- **RL0 - none**: F0 actions verlangen keinen Grund und duerfen keine kuenstliche Governance-Frage erzeugen.
- **RL1 - optional**: F1 actions duerfen einen optionalen Grund erlauben, wenn zusaetzlicher Kontext hilfreich ist, duerfen ihn aber nicht ohne dokumentierten Sonderfall verpflichtend machen.
- **Current-release F1 decision**: In dieser Release-Stufe verwenden `Refresh evidence`, `Retry`, und `Restore` trotz bestaetigter Ausfuehrung keinen zusaetzlichen Freitext-Grund. Optionale F1-Begruendung bleibt ausserhalb von Spec 194, bis ein dokumentierter Sonderfall sie wirklich braucht.
- **RL2 - required**: F2 und F3 actions muessen eine explizite Begruendung oder eine gleichwertig strukturierte formale Erklaerung erfassen.
- **Audit propagation**: Jede verpflichtende oder eingegebene Begruendung muss in Audit-Prosa, Lifecycle-Historie oder Operation-Kontext wiederauffindbar sein, sofern die zugrunde liegende action heute auditierbar ist.
### Vocabulary Canon
- Formale Entscheidungs-Paare verwenden **Approve / Reject**.
- Exception-Lifecycle verwendet **Renew exception / Revoke exception**.
- Risk-acceptance-Semantik verwendet, sobald sie direkt sichtbar ist, **Accept risk / Renew acceptance / Revoke acceptance**. Bis dahin tragen Finding Exception Surfaces diese Semantik stellvertretend.
- Review-Lifecycle verwendet **Publish review / Archive review / Create next review**.
- Evidence verwendet **Refresh evidence** fuer technische Regeneration und **Expire snapshot** fuer formale Invalidierung. **Publish / Expire** bleibt fuer einen spaeteren echten Veroeffentlichungs-Lifecycle reserviert und darf nicht als Synonym fuer technischen Refresh missbraucht werden.
- Finding-Lifecycle verwendet **Close / Reopen**.
- Tenant-Lifecycle verwendet **Archive / Restore**.
- Run-Triage verwendet **Retry / Cancel / Mark investigated**.
- Euphemismen oder unnoetig wechselnde Synonyme wie `Dismiss`, `Release`, `Reset`, oder `Deactivate` sind fuer diese Familien nur mit dokumentierter Ausnahme zulaessig.
### Functional Requirements
- **FR-194-001 Governance inventory**: Alle in Scope liegenden governance actions muessen in einer projektweiten inventory- und family-Sicht erfasst werden.
- **FR-194-002 Exact classification**: Jede inventarisierte governance action muss genau einer Friction-Klasse F0, F1, F2 oder F3 zugeordnet werden.
- **FR-194-003 Family-first consistency**: Gleichartige actions muessen in action families gebuendelt werden, damit die Klasse nicht still pro Surface neu definiert wird.
- **FR-194-004 F0 rule**: F0 actions duerfen weder Pflichtbestaetigung noch reason-capture noch danger-styling verwenden.
- **FR-194-005 F1 rule**: F1 actions verlangen Bestaetigung, aber standardmaessig keinen Pflichtgrund. Danger ist nur zulaessig, wenn die fachliche Wirkung tatsaechlich erhoehtes Risiko traegt.
- **FR-194-006 F2 rule**: F2 actions verlangen Bestaetigung und explizite Erklaerung. Die Operator-Entscheidung muss spaeter nachvollziehbar bleiben.
- **FR-194-007 F3 rule**: F3 actions verlangen Bestaetigung, Pflichtgrund, danger-styling und eine von Standardaktionen getrennte Platzierung.
- **FR-194-008 Reason propagation**: Fuer F2- und F3-actions muss die Begruendung im bestehenden Audit- oder Lifecycle-Nachweis wiederauftauchen. F1 darf keine stille Pflichtbegruendung einfuehren.
- **FR-194-009 Danger discipline**: Danger darf nicht inflationaer auf gewoehnliche Mutationen ausgedehnt werden. Hochwirksame und leichte Zustandsaenderungen muessen klar unterscheidbar bleiben.
- **FR-194-010 Navigation separation**: Navigation, Export, reine Kontextwechsel und harmlose registry-actions muessen von governance actions semantisch getrennt bleiben.
- **FR-194-011 Exception decision family**: `Approve exception`, `Reject exception`, `Renew exception`, und `Revoke exception` muessen als gemeinsame governance-family mit klaren Standardklassen und Gruenden behandelt werden.
- **FR-194-012 Review lifecycle family**: `Publish review`, `Archive review`, und `Create next review` muessen semantisch mit der review-lifecycle-Logik abgestimmt sein. `Export executive pack` bleibt ausdruecklich ausserhalb dieser governance-friction.
- **FR-194-013 Evidence lifecycle family**: Evidence lifecycle actions muessen zwischen technischem refresh und formaler invalidierung unterscheiden. `Refresh evidence` darf nicht wie `Expire snapshot` behandelt werden.
- **FR-194-014 Run triage family**: `Retry`, `Cancel`, und `Mark investigated` muessen projektweit als klar unterscheidbare run-triage-family behandelt werden, unabhaengig davon, auf welcher run-surface sie auftauchen.
- **FR-194-015 Finding lifecycle family**: `Close` und `Reopen` auf findings muessen dasselbe wording- und reason-Modell verwenden, auch wenn sie als header-, row-, oder bulk-action angeboten werden.
- **FR-194-016 Tenant lifecycle family**: `Archive` und `Restore` auf ViewTenant und EditTenant muessen dieselbe confirm-, reason-, und danger-Logik verwenden.
- **FR-194-017 Risk acceptance continuity**: Wo risk-acceptance-Semantik direkt oder indirekt sichtbar wird, darf sie keine konkurrierende lokale Vokabelfamilie erzeugen.
- **FR-194-018 Mutation scope disclosure**: Jede F2- oder F3-action muss den tatsaechlichen Wirkungsscope vor der Ausfuehrung in verstaendlicher Operator-Sprache kommunizieren.
- **FR-194-019 Copy alignment**: Button-Labels, modal-heading, modal-body, success-notification, audit-prosa und run-titel derselben family muessen dieselbe domain-Sprache verwenden.
- **FR-194-020 No silent exceptions**: Jede bewusste Abweichung von Standardklasse, reason-Regel oder Vokabular muss als dokumentierter Sonderfall markiert sein.
- **FR-194-021 Regression gate**: Neue governance actions duerfen nicht ohne Eintrag in die gemeinsame friction- und vocabulary-Matrix, dokumentierten reason-level und exception-status eingefuehrt werden.
- **FR-194-022 Verification coverage**: Browser smoke checks und gezielte page- oder action-Tests muessen die high-priority-families und den regression gate abdecken.
- **FR-194-023 Authorization continuity**: Keine alignment-Massnahme darf bestehende 404- oder 403-Semantik, capability checks oder deny-as-not-found-Verhalten veraendern.
- **FR-194-024 Destructive action safety**: Alle destruktiven oder destruktiv wirkenden governance actions muessen weiterhin ueber bestaetigte Filament execution-actions laufen und serverseitig autorisiert bleiben.
## Governance Action Matrix
| Priority | Action | Primary Surfaces | Standard Friction | Reason Level | Danger Expectation | Vocabulary Default |
|---|---|---|---|---|---|---|
| High | Approve exception | Finding Exceptions Queue | F2 | required | no by default | Approve exception |
| High | Reject exception | Finding Exceptions Queue | F2 | required | visually distinct from approval, but not automatically F3 | Reject exception |
| High | Renew exception | ViewFindingException, related finding actions | F2 | required | no by default | Renew exception |
| High | Revoke exception | ViewFindingException, related finding actions | F3 | required | required | Revoke exception |
| High | Publish review | ViewTenantReview | F2 | required | no by default | Publish review |
| High | Archive review | ViewTenantReview | F3 | required | required | Archive review |
| High | Refresh evidence | ViewEvidenceSnapshot | F1 | none | no | Refresh evidence |
| High | Expire snapshot | ViewEvidenceSnapshot, ListEvidenceSnapshots | F2 | required | required | Expire snapshot |
| High | Retry run | System ViewRun and any future triage-owned admin run surface | F1 | none | no | Retry |
| High | Mark investigated | System ViewRun and any future triage-owned admin run surface | F2 | required | no by default | Mark investigated |
| High | Cancel run | System ViewRun and any future triage-owned admin run surface | F3 | required | required | Cancel |
| Medium | Close finding | ViewFinding, Finding resource actions | F2 | required | no by default | Close |
| Medium | Reopen finding | ViewFinding, Finding resource actions | F2 | required | no by default | Reopen |
| Medium | Archive tenant | ViewTenant, EditTenant | F3 | required | required | Archive |
| Medium | Restore tenant | ViewTenant, EditTenant | F1 | none | no | Restore |
| Low / no-op | Export executive pack | ViewTenantReview | F0 | none | no | Export executive pack |
| Low / no-op | Open queue, open related, show all, close details | Queue, detail, and monitoring surfaces | F0 | none | no | Use explicit navigation verbs only |
## Target Outcomes by Key Surface
1. **Finding Exceptions Queue**: Approval and rejection stop feeling like ad-hoc queue buttons and become one clearly governed decision family.
2. **ViewFindingException**: Renewal and revocation read as lifecycle decisions with calibrated differences in severity.
3. **ViewEvidenceSnapshot**: Refresh and expiry no longer blur together as generic snapshot mutations.
4. **ViewTenantReview**: Publish, export, create-next, and archive become semantically distinct rather than one mixed lifecycle strip.
5. **TenantlessOperationRunViewer / System ViewRun**: Run context stays separate from real intervention, while Retry, Cancel, and Mark investigated become legibly different actions wherever triage exists.
6. **ViewTenant / EditTenant**: Archive and Restore stop drifting between view and edit variants and carry one shared tenant-lifecycle meaning.
## Non-Goals
- Creating new governance states, enums, or persisted workflows beyond the friction and reason rules themselves
- Rewriting header placement rules already governed by Spec 192 and Spec 193
- Changing dispatch, provider-start, or preflight behavior for operations
- Replacing existing audit infrastructure with a new audit domain
- Renaming the product or introducing broad copy churn outside in-scope governance families
- Forcing calm non-governance surfaces into a new friction model they do not need
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds or modifies any Filament Resource, RelationManager, or Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive, RBAC gating, whether the mutation writes an audit log, and any exemption or exception used.
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Finding Exceptions Queue | app/Filament/Pages/Monitoring/FindingExceptionsQueue.php | Scope, return, clear filters, tenant register, Selected context, Review selected | `Inspect exception` slide-over stays the only inspect affordance | `Inspect exception` | none | `Clear filters` | `Approve exception`, `Reject exception`, `Close details`, `Open tenant detail`, `Open finding` | n/a | yes | Review actions move to one governed exception family; Action Surface Contract remains satisfied |
| ViewFindingException | app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php | n/a | n/a | n/a | n/a | n/a | `Renew exception`, `Revoke exception` | n/a | yes | Renew is F2, revoke is F3; no redundant View action |
| ViewFinding | app/Filament/Resources/FindingResource/Pages/ViewFinding.php | Back to origin, related record, open approval queue, Actions group | Canonical record view page | none | Existing finding bulk actions remain family-aligned, not redefined here | n/a | `Close`, `Reopen`, `Request exception`, plus existing workflow actions | n/a | yes | `Request exception` remains navigation into exception governance, not a synonym for approval |
| Evidence snapshots index + detail | app/Filament/Resources/EvidenceSnapshotResource.php and app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php | Index keeps inspection primary; detail keeps `Refresh evidence` and `Expire snapshot` | `recordUrl()` clickable row on index | `Expire snapshot` under More only | none | `Create first snapshot` remains non-governance create CTA | `Refresh evidence`, `Expire snapshot` | n/a | yes | Refresh stays F1; Expire stays governed and danger-separated |
| ViewTenantReview | app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php | Primary lifecycle action, `More`, and `Danger` groups | Canonical record view page | none | none | n/a | `Refresh review`, `Publish review`, `Export executive pack`, `Create next review`, `Archive review` | n/a | yes | Publish and archive must not inherit export semantics |
| TenantlessOperationRunViewer | app/Filament/Pages/Operations/TenantlessOperationRunViewer.php | Scope, back, show all, refresh, Open, optional resume-capture follow-up | Canonical tenantless run detail page | none | none | n/a | Context-first follow-up actions only | n/a | existing run or audit trail | No new destructive triage is added here unless the surface genuinely owns it |
| System ViewRun | app/Filament/System/Pages/Ops/ViewRun.php | Show all operations, go to runbooks, retry, cancel, mark investigated | Canonical system run detail page | none | none | n/a | `Retry`, `Cancel`, `Mark investigated` | n/a | yes | Retry F1, Mark investigated F2, Cancel F3 |
| ViewTenant / EditTenant | app/Filament/Resources/TenantResource/Pages/ViewTenant.php and app/Filament/Resources/TenantResource/Pages/EditTenant.php | View keeps external, setup, and lifecycle groups; edit keeps lifecycle group | Canonical workspace tenant view or edit page | none | none | n/a | `Archive`, `Restore` | Edit page keeps standard save and cancel | yes | Archive and restore must remain one consistent tenant-lifecycle family across both surfaces |
### Key Entities *(include if feature involves data)*
- **Governance Action Family**: A named group of semantically equivalent operator actions, defined by canonical verb, standard friction class, reason level, danger expectation, mutation scope wording, and allowed exceptions.
- **Friction Class Assignment**: The explicit mapping of one operator action to F0, F1, F2, or F3 together with the applicable reason rule and danger rule.
- **Documented Exception**: A reviewed deviation from the standard family rule, including the affected surface, the rationale, and why it cannot safely follow the default pattern.
## Assumptions
- There is currently no standalone direct risk-acceptance page; finding exception governance carries the present risk-acceptance semantics.
- Workspace approval of finding exceptions remains the canonical entry point for formal exception decisions.
- TenantlessOperationRunViewer may stay context-first if it does not genuinely own retry, cancel, or investigated mutations.
- Existing audit and lifecycle services already persist or expose the necessary operator rationale where these actions are currently governed.
## Dependencies
- Spec 192 for record-page header and navigation discipline
- Spec 193 for monitoring surface action hierarchy and workbench semantics
- Existing capability registry, `UiEnforcement`, Policies, and deny-as-not-found semantics
- Existing audit logging and operation triage or lifecycle services for review, evidence, findings, runs, and tenant lifecycle actions
## Risks
1. **Zu viel Friction fuer harmlose actions**: The product becomes slower than necessary. Mitigation: strict F0 and F1 separation and explicit non-goals.
2. **Zu wenig Friction fuer hochwirksame actions**: High-risk governance intervention stays undertoned. Mitigation: F3 defaults are explicit and exceptions must be documented.
3. **Vocabulary wird kosmetisch statt semantisch behandelt**: Labels change but severity logic does not. Mitigation: every vocabulary decision is coupled to family, friction, and reason rules.
4. **Cross-surface drift bleibt bestehen**: The same action behaves differently on queue, detail, and system surfaces. Mitigation: regression gate and shared family matrix.
## Recommended Sequencing
1. Inventory all in-scope governance actions and assign them to action families.
2. Align exception, review, and evidence families first because they contain the densest formal governance decisions.
3. Align run triage and tenant lifecycle families next, preserving panel-specific authorization but removing semantic drift.
4. Add regression protection and browser smoke coverage after the core families are normalized.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of in-scope governance actions on the remediated surfaces are listed in the shared inventory with action family, friction class, reason level, danger expectation, and exception status.
- **SC-002**: 100% of high-priority action families use one consistent confirm depth and one consistent vocabulary set across every covered surface where that family appears.
- **SC-003**: Browser smoke review of all remediated surfaces shows that every F3 action is visually and semantically distinct from nearby F0 or F1 actions, with no undocumented exceptions.
- **SC-004**: Positive and negative authorization checks continue to pass for at least one tenant-plane, one workspace-plane, and one system-plane governance family after the alignment.

View File

@ -0,0 +1,242 @@
# Tasks: Governance Friction Hardening and Operator Vocabulary
**Input**: Design documents from `/specs/194-governance-friction-hardening/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/governance-action-semantics.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes runtime behavior on existing Filament v5 / Livewire v4 operator surfaces, so Pest unit, feature, RBAC, audit, and browser smoke coverage must be added or extended.
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3 -> US4`, with `US1` as the MVP cut after the shared catalog and guard foundation is in place.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create the dedicated support and test entry points for Spec 194 without changing runtime behavior yet.
- [X] T001 Create the governance action support scaffolds in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`, `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionRule.php`, `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceFrictionClass.php`, and `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceReasonPolicy.php`
- [X] T002 [P] Create the Spec 194 unit and guard test scaffolds in `apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php` and `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`
- [X] T003 [P] Create the browser smoke scaffold in `apps/platform/tests/Browser/Spec194GovernanceFrictionSmokeTest.php`
**Checkpoint**: The new support namespace and dedicated Spec 194 test entry points exist, so shared rule work can begin without mixing this slice into unrelated suites.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Codify the shared governance semantics inventory and regression gate that every story depends on.
**⚠️ CRITICAL**: No user story work should start before this phase is complete.
- [X] T004 [P] Add catalog invariant, deterministic F1 reason-policy, and documented-deviation coverage in `apps/platform/tests/Unit/Ui/GovernanceActions/GovernanceActionCatalogTest.php`
- [X] T005 [P] Add family, surface, vocabulary, indirect risk-acceptance continuity, and exception regression coverage in `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`
- [X] T006 [P] Implement the friction and reason enums in `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceFrictionClass.php` and `apps/platform/app/Support/Ui/GovernanceActions/Enums/GovernanceReasonPolicy.php`
- [X] T007 [P] Implement the shared rule value object in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionRule.php`
- [X] T008 Implement the canonical family inventory, surface bindings, deterministic F1 reason defaults, indirect risk-acceptance continuity, and documented deviations in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`
**Checkpoint**: The repo can enumerate in-scope governance actions, classify them, and fail CI on undocumented semantic drift before any page-level refactor starts.
---
## Phase 3: User Story 1 - Make a Formal Exception Decision Safely (Priority: P1) 🎯 MVP
**Goal**: Make queue and detail exception decisions use one predictable friction, reason, and vocabulary contract.
**Independent Test**: Open the finding exceptions queue and one finding exception detail page, then confirm approve, reject, renew, and revoke follow the shared exception-family semantics without changing any review, evidence, run, or tenant lifecycle pages.
### Tests for User Story 1
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T009 [P] [US1] Extend queue selection-state, modal friction, and reason-prompt coverage in `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php` and `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php`
- [X] T010 [P] [US1] Extend exception lifecycle, authorization, header-discipline, audit, and indirect risk-acceptance vocabulary continuity coverage in `apps/platform/tests/Feature/Findings/FindingExceptionWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionRenewalTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionRevocationTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionAuthorizationTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionPolicyTest.php`, and `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Align exception-family reason propagation, canonical audit verbs, and success copy in `apps/platform/app/Services/Findings/FindingExceptionService.php`
- [X] T012 [US1] Refactor queue decision actions to consume catalog rules in `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- [X] T013 [US1] Refactor exception detail lifecycle actions to consume catalog rules and keep revoke as the separated F3 action in `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
**Checkpoint**: Exception governance is independently functional and consistent across queue and detail surfaces.
---
## Phase 4: User Story 2 - Distinguish Governance Lifecycle from Technical Refresh (Priority: P1)
**Goal**: Make review publication or archival and evidence refresh or expiry read as clearly different action families instead of peer mutations.
**Independent Test**: Open a tenant review detail page and an evidence snapshot detail page, then confirm publish and archive are distinct from export, while refresh remains lighter than expire, without changing run or tenant lifecycle surfaces.
### Tests for User Story 2
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T014 [P] [US2] Extend review lifecycle, export separation, authorization, and audit coverage in `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, and `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`
- [X] T015 [P] [US2] Extend evidence refresh-versus-expire, authorization, and audit coverage in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
### Implementation for User Story 2
- [X] T016 [P] [US2] Align review lifecycle reason propagation, audit prose, and notification wording in `apps/platform/app/Services/TenantReviews/TenantReviewLifecycleService.php`
- [X] T017 [US2] Refactor review detail actions so publish and archive follow the catalog while export and create-next stay outside governance friction in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
- [X] T018 [P] [US2] Align evidence lifecycle reason propagation, audit prose, and notification wording in `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- [X] T019 [US2] Refactor evidence list and detail actions so refresh remains F1 and expire follows governed lifecycle rules in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
**Checkpoint**: Review and evidence lifecycle actions are independently functional and semantically distinct from technical refresh or export actions.
---
## Phase 5: User Story 3 - Intervene on Runs with Calibrated Severity (Priority: P2)
**Goal**: Make retry, mark investigated, and cancel communicate different seriousness on triage-owned run surfaces while preserving calm monitoring context elsewhere.
**Independent Test**: Open the system run detail page and the tenantless run viewer, then confirm retry stays lighter than mark investigated and cancel, while the tenantless viewer does not promote undocumented triage actions.
### Tests for User Story 3
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T020 [P] [US3] Extend system run triage severity, reason, and authorization coverage in `apps/platform/tests/Feature/Operations/SystemRunBlockedExecutionNotificationTest.php` and `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`
- [X] T021 [P] [US3] Extend calm-surface and no-triage-regression coverage in `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
### Implementation for User Story 3
- [X] T022 [US3] Align run-triage reason propagation, audit verbs, and operator copy in `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`
- [X] T023 [US3] Refactor the system run header actions so retry, mark investigated, and cancel use calibrated friction, danger, and placement from the catalog in `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`
- [X] T024 [US3] Review the tenantless viewer and keep it context-first unless a documented governance binding is required in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
**Checkpoint**: Run triage is independently functional on the system surface without leaking high-friction semantics onto the calm tenantless viewer.
---
## Phase 6: User Story 4 - Keep Tenant Lifecycle Clear Without Inflating All Actions (Priority: P3)
**Goal**: Keep archive and restore consistent across tenant view and edit surfaces without over-hardening lower-risk lifecycle moves.
**Independent Test**: Open the tenant view and edit surfaces for active and archived tenants, then confirm archive and restore use the same vocabulary, confirm depth, and danger rules on both pages.
### Tests for User Story 4
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T025 [P] [US4] Extend tenant lifecycle visibility, naming, and authorization coverage in `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `apps/platform/tests/Feature/Rbac/TenantLifecycleActionNamingTest.php`, `apps/platform/tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, and `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`
- [X] T026 [P] [US4] Extend tenant lifecycle audit and cross-surface presentation coverage in `apps/platform/tests/Feature/Audit/TenantLifecycleAuditLogTest.php` and `apps/platform/tests/Feature/Filament/TenantLifecyclePresentationAcrossTenantSurfacesTest.php`
### Implementation for User Story 4
- [X] T027 [US4] Refactor tenant lifecycle action definitions to bind archive and restore to the shared catalog in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T028 [US4] Refactor tenant view and edit page lifecycle actions so archive and restore stay aligned across both surfaces in `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
**Checkpoint**: Tenant lifecycle actions are independently functional and consistent across both workspace tenant surfaces.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finish the remaining finding-lifecycle alignment, lock in browser proof, and run focused verification.
- [X] T029 [P] Add finding lifecycle header-, row-, and bulk-action, navigation-separation, destructive-confirmation, authorization, and audit coverage in `apps/platform/tests/Feature/Findings/FindingWorkflowViewActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBulkActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowUiEnforcementTest.php`, `apps/platform/tests/Feature/Findings/FindingRbacTest.php`, and `apps/platform/tests/Feature/Findings/FindingAuditLogTest.php`
- [X] T030 [P] Add cross-surface browser smoke coverage for exception, review, evidence, run, and tenant lifecycle semantics in `apps/platform/tests/Browser/Spec194GovernanceFrictionSmokeTest.php`
- [X] T031 Refactor finding lifecycle header, row, and bulk actions so close and reopen follow the catalog while request-exception stays navigation into governance in `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- [X] T032 Review mutation-scope wording, explicit confirmation copy for destructive families, canonical labels, notifications, indirect risk-acceptance continuity, and documented deviations in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`, and `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T033 Run the focused Sail verification workflow from `specs/194-governance-friction-hardening/quickstart.md` against the changed unit, feature, RBAC, audit, and browser tests in `apps/platform/tests/`
- [X] T034 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for the changed files in `apps/platform/app/` and `apps/platform/tests/`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP cut.
- **User Story 2 (Phase 4)**: Depends on Foundational completion; can run in parallel with US1 after the shared catalog is stable.
- **User Story 3 (Phase 5)**: Depends on Foundational completion; can run in parallel with US1 or US2 once the run family keys exist in the catalog.
- **User Story 4 (Phase 6)**: Depends on Foundational completion; can run in parallel with US2 or US3 because it touches separate tenant lifecycle files.
- **Polish (Phase 7)**: Depends on the desired user stories being complete; it closes the remaining finding lifecycle requirement and final regression proof.
### User Story Dependencies
- **US1**: No dependencies beyond Foundational.
- **US2**: No dependencies beyond Foundational, but it reuses the catalog and guard patterns established for US1.
- **US3**: No dependencies beyond Foundational, but it reuses the same reason and danger semantics contract.
- **US4**: No dependencies beyond Foundational; it consumes the shared lifecycle rules after the catalog exists.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation.
- Update the owning service before finalizing the matching page actions when reason propagation or audit wording changes.
- Keep each story independently shippable before widening the slice.
### Parallel Opportunities
- `T002` and `T003` can run in parallel after `T001`.
- `T004`, `T005`, `T006`, and `T007` can run in parallel before `T008`.
- Within US1, `T009` and `T010` can run in parallel.
- Within US2, `T014` and `T015` can run in parallel, and `T016` and `T018` can run in parallel.
- Within US3, `T020` and `T021` can run in parallel.
- Within US4, `T025` and `T026` can run in parallel.
- Within Phase 7, `T029` and `T030` can run in parallel once all page-level story work is complete.
---
## Parallel Example: User Story 1
```bash
# Parallel test pass for US1
T009 Extend queue selection-state, modal friction, and reason-prompt coverage
T010 Extend exception lifecycle, authorization, header-discipline, and audit coverage
```
## Parallel Example: User Story 2
```bash
# Parallel story work for US2
T014 Extend tenant review lifecycle, export separation, authorization, and audit coverage
T015 Extend evidence refresh-versus-expire, authorization, and audit coverage
T016 Align review lifecycle reason propagation, audit prose, and notification wording
T018 Align evidence lifecycle reason propagation, audit prose, and notification wording
```
## Parallel Example: User Story 3
```bash
# Parallel test pass for US3
T020 Extend system run triage severity, reason, and authorization coverage
T021 Extend calm-surface and no-triage-regression coverage
```
## Parallel Example: User Story 4
```bash
# Parallel test pass for US4
T025 Extend tenant lifecycle visibility, naming, and authorization coverage
T026 Extend tenant lifecycle audit and cross-surface presentation coverage
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational catalog and guard work.
3. Complete Phase 3: User Story 1.
4. Validate the exception-family behavior through the focused US1 tests.
5. Stop and review the shared catalog shape before widening the slice.
### Incremental Delivery
1. Ship US1 to establish the first remediated governance family.
2. Add US2 to normalize review and evidence lifecycle semantics.
3. Add US3 to calibrate run triage without destabilizing calm monitoring surfaces.
4. Add US4 to finish tenant lifecycle alignment.
5. Finish with Phase 7 to align finding lifecycle, add browser proof, and run focused verification.
### Parallel Team Strategy
1. One contributor completes Setup and Foundational tasks.
2. After Foundation is green:
- Contributor A takes US1.
- Contributor B takes US2.
- Contributor C takes US3.
- Contributor D takes US4.
3. Merge back for Phase 7 finding-lifecycle alignment, browser smoke, and verification.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Action Surface Enforcement, Enrollment, and Exception Closure
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-12
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated on first pass against the spec template and closure requirements.
- No open clarification markers remain.
- The spec stays bounded to residual closure, discovery limits, exemptions, and regression protection after Specs 192 to 194.

View File

@ -0,0 +1,219 @@
openapi: 3.1.0
info:
title: Action Surface Closure Logical Contract
version: 0.1.0
description: >-
Logical design contract for Spec 195 residual action-surface closure.
This is a planning artifact that defines the required reviewable shape for
residual pages that sit outside or alongside the primary action-surface
discovery path.
servers:
- url: https://logical-spec.local
description: Non-runtime planning contract
paths:
/internal/action-surfaces/residual:
get:
summary: List Spec 195 residual action-surface closure entries
operationId: listResidualActionSurfaceClosures
responses:
'200':
description: Residual closure entries in validator order
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
type: array
items:
$ref: '#/components/schemas/ResidualActionSurfaceClosure'
/internal/action-surfaces/residual/{surfaceKey}:
get:
summary: Read one Spec 195 residual action-surface closure entry
operationId: getResidualActionSurfaceClosure
parameters:
- name: surfaceKey
in: path
required: true
schema:
$ref: '#/components/schemas/SurfaceKey'
responses:
'200':
description: Residual closure entry
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
$ref: '#/components/schemas/ResidualActionSurfaceClosure'
components:
schemas:
SurfaceKey:
type: string
pattern: '^[a-z0-9_]+$'
description: Stable machine-readable key for one residual surface. The initial seed list is recorded in x-spec-195-notes.seedSurfaceKeys and may be extended by audit.
DiscoveryState:
type: string
enum:
- primary_discovered
- primary_discovered_baseline_exempt
- outside_primary_discovery
ClosureDecision:
type: string
enum:
- generic_contract_enrollment
- intentional_exemption
- separately_governed
- retired_no_longer_relevant
- harmless_special_case
ReasonCategory:
type: string
enum:
- system_triage_surface
- workflow_specific_governance
- break_glass_repair_utility
- read_mostly_context_detail
- disabled_or_actionless_surface
- selector_routing_only
- registration_form_with_dedicated_rbac
- landing_routing_surface
- dashboard_shell_widget_owned
- security_flow_exception
FollowUpAction:
type: string
enum:
- none
- tighten_reason
- add_guard_only
- add_focused_test
- consider_enrollment
EvidenceDescriptor:
type: object
required:
- reference
- proves
properties:
reference:
type: string
proves:
type: string
kind:
type: string
enum:
- guard_test
- feature_livewire_test
- authorization_test
- workflow_spec
- audit_test
- db_only_surface_test
ResidualActionSurfaceClosureBase:
type: object
required:
- surfaceKey
- surfaceName
- pageClass
- panelPlane
- surfaceKind
- discoveryState
- closureDecision
- explicitReason
- evidence
- followUpAction
- mustRemainBaselineExempt
- mustNotRemainBaselineExempt
properties:
surfaceKey:
$ref: '#/components/schemas/SurfaceKey'
surfaceName:
type: string
description: Human-readable review name for the residual surface
pageClass:
type: string
panelPlane:
type: string
enum:
- admin
- tenant
- system
surfaceKind:
type: string
enum:
- system_detail
- system_utility
- selector
- wizard
- landing
- dashboard_shell
- recovery_flow
- read_mostly_context
discoveryState:
$ref: '#/components/schemas/DiscoveryState'
closureDecision:
$ref: '#/components/schemas/ClosureDecision'
reasonCategory:
anyOf:
- $ref: '#/components/schemas/ReasonCategory'
- type: 'null'
explicitReason:
type: string
evidence:
type: array
minItems: 1
items:
$ref: '#/components/schemas/EvidenceDescriptor'
followUpAction:
$ref: '#/components/schemas/FollowUpAction'
mustRemainBaselineExempt:
type: boolean
mustNotRemainBaselineExempt:
type: boolean
ResidualActionSurfaceClosure:
allOf:
- $ref: '#/components/schemas/ResidualActionSurfaceClosureBase'
- oneOf:
- properties:
closureDecision:
const: generic_contract_enrollment
- required:
- reasonCategory
properties:
closureDecision:
type: string
enum:
- intentional_exemption
- separately_governed
- retired_no_longer_relevant
- harmless_special_case
reasonCategory:
$ref: '#/components/schemas/ReasonCategory'
x-spec-195-notes:
seedSurfaceKeys:
- system_dashboard
- system_ops_view_run
- system_ops_runbooks
- repair_workspace_owners
- system_directory_view_tenant
- system_directory_view_workspace
- break_glass_recovery
- choose_workspace
- choose_tenant
- register_tenant
- managed_tenant_onboarding_wizard
- managed_tenants_landing
- tenant_dashboard
consumers:
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
- apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php
- apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php
nonGoals:
- runtime API exposure
- new persistence
- new provider or routing structure
- widening primary action-surface discovery to every Filament page class

View File

@ -0,0 +1,149 @@
# Data Model: Action Surface Enforcement, Enrollment, and Exception Closure
## Overview
This feature introduces no new persisted entity, table, or user-facing workflow model. It adds a derived repository-governance model for residual action-bearing surfaces that currently sit outside clearly catalogued generic-contract coverage.
The goal of the model is to answer five questions for every residual surface:
1. Is the surface discovered by the primary validator path?
2. If not, is the gap explicit?
3. What is the final closure decision?
4. Why is that decision justified?
5. Which existing tests or guards prove the decision is real?
Each entry must also stay human-reviewable, so the inventory carries both a stable machine key and a human-readable surface name.
## Existing Source Truths Reused Without Change
The following truths remain authoritative and are not redefined by this feature:
- existing page and route classes
- existing authorization semantics, capability registries, and `UiEnforcement` rules
- existing `OperationRun`, audit, and break-glass behavior
- existing `ActionSurfaceDiscovery` behavior for declaration-backed generic surfaces
- existing baseline exemptions for discovered pages that are intentionally outside the generic contract
- existing system, onboarding, chooser, and dashboard test suites
This feature changes classification and regression proof only.
## New Derived Planning Models
### ResidualSurfaceInventoryEntry
**Type**: Spec 195 inventory entry
**Source**: one structured in-code inventory plus validator checks
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Stable identifier such as `system_ops_view_run` or `choose_workspace` |
| `surfaceName` | string | Human-readable review name such as `System Ops View Run` or `Choose Workspace` |
| `pageClass` | string | Concrete Filament page class |
| `panelPlane` | string | `admin`, `tenant`, or `system` |
| `surfaceKind` | string | `system_detail`, `system_utility`, `selector`, `wizard`, `landing`, `dashboard_shell`, `recovery_flow`, or `read_mostly_context` |
| `discoveryState` | string | `primary_discovered`, `primary_discovered_baseline_exempt`, or `outside_primary_discovery` |
| `closureDecision` | string | `generic_contract_enrollment`, `intentional_exemption`, `separately_governed`, `retired_no_longer_relevant`, or `harmless_special_case` |
| `reasonCategory` | string or null | Required for every decision except pure enrollment |
| `explicitReason` | string | Short reviewable explanation |
| `evidence` | array<CoverageEvidenceDescriptor> | Structured evidence descriptors that justify the decision |
| `followUpAction` | string | `none`, `tighten_reason`, `add_guard_only`, `add_focused_test`, or `consider_enrollment` |
| `mustRemainBaselineExempt` | boolean | True when the discovered page must stay in `baseline()` |
| `mustNotRemainBaselineExempt` | boolean | True when the surface must not remain in `baseline()` |
### CoverageEvidenceDescriptor
**Type**: derived proof entry
**Source**: existing test and spec references
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Links the evidence to one residual surface |
| `kind` | string | `guard_test`, `feature_livewire_test`, `authorization_test`, `workflow_spec`, `audit_test`, or `db_only_surface_test` |
| `reference` | string | Relative file path or stable spec reference |
| `proves` | string | What the evidence actually proves |
| `gapIfMissing` | boolean | True when Spec 195 should add or tighten coverage |
### DiscoveryBoundaryRule
**Type**: derived validator rule
**Source**: existing `ActionSurfaceDiscovery` behavior plus Spec 195 clarification
| Field | Type | Notes |
|------|------|-------|
| `boundaryKey` | string | Stable identifier for one primary-discovery boundary |
| `appliesTo` | string | `resources`, `relation_managers`, `pages`, `system_table_pages`, or `non_discovered_special_pages` |
| `currentRule` | string | Human-readable statement of what discovery includes or excludes |
| `silentGapRisk` | boolean | Whether a surface can currently evade review if the rule stays implicit |
| `spec195Mitigation` | string | How the residual inventory or guard closes that gap |
### ResidualSurfaceRegressionExpectation
**Type**: guard expectation entry
**Source**: Spec 195 validation rules derived from `mustRemainBaselineExempt`, `mustNotRemainBaselineExempt`, and the other closure-entry fields
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Residual surface under guard |
| `mustHaveClosureDecision` | boolean | Always true for in-scope residuals |
| `mustHaveReasonCategory` | boolean | True when not enrolled |
| `mustHaveEvidence` | boolean | True for every non-retired residual |
| `mustRemainInBaselineExemptions` | boolean | True only for discovered pages still intentionally outside the generic contract |
| `mustNotRemainInBaselineExemptions` | boolean | True for retired surfaces and non-discovered system pages |
| `needsFocusedTest` | boolean | True when existing evidence is not yet strong enough |
## Initial Seed Inventory for Spec 195
This is the planned closure inventory derived from current code and test evidence. The implementation audit added `system_dashboard` to the original 12-row seed because it is an action-bearing system surface that also sits outside primary discovery.
| Surface Key | Surface Name | Page Class | Current State | Planned Closure Decision | Reason Category | Strongest Evidence | Planned Follow-up |
|---|---|---|---|---|---|---|---|
| `system_dashboard` | `System Console Dashboard` | `App\Filament\System\Pages\Dashboard` | outside primary discovery, not exempt | `separately_governed` | `workflow_specific_governance` | `tests/Feature/System/Spec114/ControlTowerDashboardTest.php`, `tests/Feature/Auth/BreakGlassModeTest.php` | `add_guard_only` |
| `system_ops_view_run` | `System Ops View Run` | `App\Filament\System\Pages\Ops\ViewRun` | outside primary discovery, not exempt | `separately_governed` | `system_triage_surface` | `tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php` | `add_guard_only` |
| `system_ops_runbooks` | `System Ops Runbooks` | `App\Filament\System\Pages\Ops\Runbooks` | outside primary discovery, not exempt | `separately_governed` | `workflow_specific_governance` | `tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `tests/Feature/Guards/LivewireTrustedStateGuardTest.php` | `add_guard_only` |
| `repair_workspace_owners` | `Repair Workspace Owners` | `App\Filament\System\Pages\RepairWorkspaceOwners` | outside primary discovery, not exempt | `separately_governed` | `break_glass_repair_utility` | `tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php`, `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` | `add_guard_only` |
| `system_directory_view_tenant` | `System Directory View Tenant` | `App\Filament\System\Pages\Directory\ViewTenant` | outside primary discovery, not exempt | `harmless_special_case` | `read_mostly_context_detail` | current code is read-mostly with contextual links only | `add_focused_test` |
| `system_directory_view_workspace` | `System Directory View Workspace` | `App\Filament\System\Pages\Directory\ViewWorkspace` | outside primary discovery, not exempt | `harmless_special_case` | `read_mostly_context_detail` | current code is read-mostly with contextual links only | `add_focused_test` |
| `break_glass_recovery` | `Break Glass Recovery` | `App\Filament\Pages\BreakGlassRecovery` | primary discovered + baseline exempt, but currently inaccessible and actionless | `retired_no_longer_relevant` | `disabled_or_actionless_surface` | current page code: `canAccess() === false`, empty header actions | `tighten_reason` |
| `choose_workspace` | `Choose Workspace` | `App\Filament\Pages\ChooseWorkspace` | primary discovered + baseline exempt | `harmless_special_case` | `selector_routing_only` | `tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, `tests/Feature/Workspaces/WorkspaceAuditTrailTest.php` | `none` |
| `choose_tenant` | `Choose Tenant` | `App\Filament\Pages\ChooseTenant` | primary discovered + baseline exempt | `harmless_special_case` | `selector_routing_only` | `tests/Feature/Auth/TenantChooserSelectionTest.php`, `tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php` | `none` |
| `register_tenant` | `Register Tenant` | `App\Filament\Pages\Tenancy\RegisterTenant` | primary discovered + baseline exempt | `separately_governed` | `registration_form_with_dedicated_rbac` | `tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php` | `none` |
| `managed_tenant_onboarding_wizard` | `Managed Tenant Onboarding Wizard` | `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard` | primary discovered + baseline exempt | `separately_governed` | `workflow_specific_governance` | Spec 172 + extensive onboarding, audit, RBAC, and secret-safety tests | `none` |
| `managed_tenants_landing` | `Managed Tenants Landing` | `App\Filament\Pages\Workspaces\ManagedTenantsLanding` | primary discovered + baseline exempt | `harmless_special_case` | `landing_routing_surface` | current page code plus indirect workspace routing coverage | `add_focused_test` |
| `tenant_dashboard` | `Tenant Dashboard` | `App\Filament\Pages\TenantDashboard` | primary discovered + baseline exempt | `harmless_special_case` | `dashboard_shell_widget_owned` | `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, arrival-context and visibility tests | `none` |
## Discovery Boundary Rules
### Rule 1 — Generic primary discovery stays declaration-first
- Resources, relation managers, and normal pages remain discovered through the existing primary validator path.
- System pages remain discovered only when they are table-backed and declaration-backed.
- Spec 195 does not change that rule; it documents and guards it.
### Rule 2 — Residual non-discovered system/detail/workflow pages require supplemental closure inventory
- Any in-scope residual page outside primary discovery must still appear in the Spec 195 inventory.
- Being outside primary discovery no longer implies being outside governance.
### Rule 3 — Baseline exemptions remain only for discovered pages still intentionally outside the generic contract
- `baseline()` remains the compatibility mechanism for discovered pages without generic declarations.
- Spec 195 inventory adds the stronger closure semantics and structured evidence.
- Retired pages should leave `baseline()`.
## Resolution Rules
1. Every residual surface gets exactly one closure decision.
2. Non-enrolled residual surfaces must include a reason category, explicit reason, and at least one structured evidence descriptor.
3. Discovered pages that stay outside the generic contract may still remain in `baseline()`, but only if the Spec 195 inventory explains why.
4. Non-discovered pages must never rely on `baseline()` alone for closure; the residual inventory is the authoritative closure record.
5. `retired_no_longer_relevant` surfaces must not keep active baseline exemptions.
6. `harmless_special_case` is reserved for routing-only, read-mostly, or shell-like surfaces whose risk stays low and explicit.
7. `separately_governed` is reserved for surfaces with dedicated workflow rules, tests, or guards that already meaningfully constrain behavior.
8. If audit reveals a new residual surface outside the initial seed, it must be added to the inventory, contract surface key set, and guard expectations before the feature is considered complete. `system_dashboard` is the first such audited addition in this spec.
## Safety Rules
- No residual closure entry may weaken existing route scope, capability enforcement, audit behavior, or confirmation semantics.
- No residual surface may be marked harmless merely because it has low coverage; low coverage requires new tests, not a softer category.
- No special workflow may remain exempt only by historical memory; explicit evidence is required.
- No new residual page in the relevant namespaces may merge without a closure decision and structured evidence.

View File

@ -0,0 +1,286 @@
# Implementation Plan: Action Surface Enforcement, Enrollment, and Exception Closure
**Branch**: `195-action-surface-closure` | **Date**: 2026-04-12 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/spec.md`
**Note**: This plan keeps the implementation inside the existing Filament v5 / Livewire v4 page layer, the current `ActionSurfaceDiscovery` + `ActionSurfaceValidator` + `ActionSurfaceExemptions` infrastructure, and the current focused RBAC, system-ops, onboarding, chooser, and dashboard test suites. It explicitly avoids adding a new runtime action-surface framework or new persistence.
## Summary
Close the residual action-surface governance gap left after Specs 192 to 194 by preserving the current primary discovery boundary, adding one explicit residual-closure inventory for non-discovered and baseline-exempt special surfaces, assigning every remaining residual page exactly one closure decision, tightening stale exemptions, and extending guard coverage so no new action-bearing residual surface can enter the repo without an explicit decision. The plan favors explicit inventory plus focused tests over forcing every system, wizard, selector, or dashboard surface into the generic `actionSurfaceDeclaration()` contract.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers
**Storage**: PostgreSQL through existing workspace-owned, tenant-owned, and system-visible models; no schema change planned
**Testing**: Pest feature tests, existing guard tests, existing Livewire page tests, and focused browser smoke only if a residual surface genuinely needs it; all run through Laravel Sail
**Target Platform**: Laravel monolith web application under `apps/platform`, spanning admin `/admin`, tenant-context `/admin/t/{tenant}/...`, and system `/system` surfaces
**Project Type**: web application
**Performance Goals**: Keep residual-surface validation repo-local and deterministic, preserve DB-only render behavior on existing monitoring and dashboard surfaces, avoid new render-time outbound I/O, and avoid extra polling or runtime indirection
**Constraints**: No new persistence, no new action-surface runtime framework, no provider or route-family changes, no authorization-plane changes, no silent exemptions, no weakening of 404/403 semantics, no change to existing destructive-action confirmation or audit behavior, and no new PHP enum unless validator-checked strings prove insufficient
**Scale/Scope**: an initial seed of 12 residual target surfaces, plus the additionally audited `system_dashboard` residual and any future residual pages uncovered by audit, including 6 current system/detail or utility surfaces outside primary discovery and not baseline-exempt, plus 7 currently baseline-exempt special flows or dashboard-like surfaces with uneven dedicated coverage
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature governs residual UI enforcement only and does not change inventory, backup, or snapshot truth. |
| Read/write separation | PASS | PASS | Residual surfaces reuse existing writes only; confirmation, audit, and focused tests remain unchanged. |
| Graph contract path | N/A | N/A | No Graph contract or provider endpoint change is introduced. |
| Deterministic capabilities | PASS | PASS | Existing capability registries and server-side checks stay authoritative. |
| Workspace + tenant isolation | PASS | PASS | Closure decisions do not widen scope; non-member access remains `404`, member-without-capability remains `403`. |
| RBAC-UX authorization semantics | PASS | PASS | Existing Gates, Policies, capability helpers, and destructive confirmations remain in force. |
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` surfaces and DB-only repairs remain governed exactly as they are today. |
| Data minimization | PASS | PASS | No new persistence or mirrored truth is planned; all closure metadata stays derived in code and tests. |
| Proportionality / anti-bloat | PASS | PASS | The plan adds one bounded residual inventory and validator pass, not a new framework. |
| UI semantics / few layers | PASS | PASS | The solution uses explicit inventory records and tests rather than presenters or a new semantic stack. |
| Filament-native UI | PASS | PASS | Existing Filament pages, actions, tables, and page tests remain the implementation path. |
| Surface taxonomy / action-surface discipline | PASS | PASS | The plan closes uncatalogued residuals explicitly without redefining Specs 192 to 194. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain inside the current Filament v5 + Livewire v4 stack. |
| Provider registration location | PASS | PASS | No panel/provider registration change is planned; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No globally searchable resource is added or modified. |
| Destructive action safety | PASS | PASS | Existing destructive or recovery actions keep `->requiresConfirmation()` and current authorization. |
| Asset strategy | PASS | PASS | No new global or on-demand assets are required; existing `cd apps/platform && php artisan filament:assets` deploy handling remains sufficient. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The plan stays entirely on Filament v5 + Livewire v4 and introduces no legacy API mix.
- **Provider registration location**: No provider changes are required; Laravel 11+ panel providers remain in `bootstrap/providers.php`.
- **Global search**: No resource search behavior changes. Residual surfaces are pages, dashboards, selectors, or system utilities, not new searchable resources.
- **Destructive actions**: Existing dangerous actions such as `Cancel`, `Repair owner state`, onboarding completion steps, and registration or recovery mutations remain routed through confirmed Filament actions with server-side authorization and existing audit behavior.
- **Asset strategy**: No new assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Extend the current guard layer, reuse the existing focused system, auth, onboarding, dashboard, and RBAC suites as explicit coverage evidence, and add only the minimum new tests needed to close weak or currently uncatalogued residuals.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/research.md`.
Key decisions:
- Preserve the current primary discovery boundary instead of auto-discovering every system or workflow page.
- Add one parallel `spec195ResidualSurfaceInventory()` to `ActionSurfaceExemptions` rather than rewriting `baseline()` or stretching `ActionSurfaceDeclaration()` to every surface type.
- Model closure decisions and reason categories as validator-checked strings in the inventory instead of adding new PHP enums or persistence.
- Default residual system pages to `separately_governed` or `harmless_special_case` unless a surface already fits the existing declaration-backed list/detail contract naturally.
- Reuse existing dedicated tests as coverage evidence for onboarding, selectors, runbooks, system triage, and dashboard shells; add focused tests only for weakly covered pages such as `ManagedTenantsLanding` and system directory detail pages.
- Treat `BreakGlassRecovery` as a stale exemption candidate and retire it if it remains inaccessible and actionless.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/`:
- `research.md`: decisions and rejected alternatives for residual closure, discovery boundaries, and exemption cleanup
- `data-model.md`: derived closure inventory, evidence, and regression-expectation models
- `contracts/action-surface-closure.logical.openapi.yaml`: internal logical contract for residual-surface closure decisions and guard expectations
- `quickstart.md`: implementation and verification sequence for the feature
Design highlights:
- Keep the generic `ActionSurfaceDeclaration()` system limited to the surfaces it already fits well: declaration-backed resources, pages, relation managers, and explicitly enrolled system table pages.
- Represent every residual surface through one explicit closure inventory entry recording class, plane, discovery status, closure decision, reason category, explicit reason, structured evidence, and follow-up testing needs.
- Keep `ActionSurfaceDiscovery` explicit about what it does and does not discover; close the gap through supplemental validator inventory rather than broad auto-discovery.
- Use existing page-local behavior and focused tests for system triage, runbooks, onboarding, chooser flows, and dashboard shells instead of creating shared runtime resolvers.
- Remove or reclassify stale baseline exemptions rather than renaming historical drift.
## Phase 1 — Agent Context Update
Planned command:
- `.specify/scripts/bash/update-agent-context.sh copilot`
This feature does not introduce a new technology stack, but the required agent-context refresh still runs after the technical context and design artifacts are complete.
## Project Structure
### Documentation (this feature)
```text
specs/195-action-surface-closure/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── contracts/
│ └── action-surface-closure.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── BreakGlassRecovery.php # AUDIT / likely retire as stale exemption
│ │ │ ├── ChooseWorkspace.php # REUSE / classify as harmless special case
│ │ │ ├── ChooseTenant.php # REUSE / classify as harmless special case
│ │ │ ├── TenantDashboard.php # REUSE / classify page shell explicitly
│ │ │ ├── Tenancy/
│ │ │ │ └── RegisterTenant.php # REUSE / separately governed
│ │ │ └── Workspaces/
│ │ │ ├── ManagedTenantOnboardingWizard.php # REUSE / separately governed
│ │ │ └── ManagedTenantsLanding.php # AUDIT / likely add focused coverage
│ │ └── System/
│ │ └── Pages/
│ │ ├── RepairWorkspaceOwners.php # AUDIT / separately governed closure
│ │ ├── Directory/
│ │ │ ├── ViewTenant.php # AUDIT / likely harmless or separate governance
│ │ │ └── ViewWorkspace.php # AUDIT / likely harmless or separate governance
│ │ └── Ops/
│ │ ├── Runbooks.php # REUSE / separately governed closure
│ │ └── ViewRun.php # REUSE / separately governed closure
│ └── Support/
│ └── Ui/
│ └── ActionSurface/
│ ├── ActionSurfaceDiscovery.php # REUSE / boundary remains explicit
│ ├── ActionSurfaceExemptions.php # MODIFY
│ └── ActionSurfaceValidator.php # MODIFY
└── tests/
└── Feature/
├── Guards/
│ ├── ActionSurfaceContractTest.php # MODIFY
│ ├── ActionSurfaceValidatorTest.php # MODIFY
│ ├── Spec194GovernanceActionSemanticsGuardTest.php # REUSE
│ └── Spec195ResidualActionSurfaceClosureGuardTest.php # NEW
├── Auth/
│ ├── BreakGlassWorkspaceOwnerRecoveryTest.php # REUSE / possible extend
│ └── TenantChooserSelectionTest.php # REUSE
├── Workspaces/
│ ├── ChooseWorkspacePageTest.php # REUSE
│ ├── ManagedTenantsWorkspaceRoutingTest.php # REUSE / possible extend
│ └── Spec195ManagedTenantsLandingTest.php # NEW
├── Rbac/
│ ├── RegisterTenantAuthorizationTest.php # REUSE
│ ├── OnboardingWizardUiEnforcementTest.php # REUSE
│ └── TenantDashboardArrivalContextVisibilityTest.php # REUSE
├── System/
│ ├── Spec113/AuthorizationSemanticsTest.php # REUSE / runbooks auth semantics
│ ├── Spec114/OpsTriageActionsTest.php # REUSE / system triage semantics
│ ├── OpsRunbooks/FindingsLifecycleBackfillStartTest.php # REUSE
│ └── Spec195/SystemDirectoryResidualSurfaceTest.php # NEW
├── Filament/
│ └── TenantDashboardDbOnlyTest.php # REUSE
└── Onboarding/
└── OnboardingDraftAccessTest.php # REUSE / explicit wizard governance evidence
```
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith under `apps/platform`. Modify only the existing action-surface support layer plus targeted tests. Do not create a new runtime registry, new persistence, or new shared page abstraction.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Cross-surface residual closure inventory and reason-category vocabulary (BLOAT-001 trigger) | The feature must explicitly distinguish enrolled, intentionally exempt, separately governed, retired, and harmless residual surfaces across code paths that the primary discovery system does not cover. | Leaving only free-form baseline reason strings and scattered tests would not let CI distinguish stale exemptions, uncatalogued system pages, or legitimate separately governed workflows. |
## Proportionality Review
- **Current operator problem**: Reviewers cannot tell whether residual system, utility, workflow, selector, landing, and dashboard surfaces are intentionally outside the generic contract or simply missed by discovery.
- **Existing structure is insufficient because**: `ActionSurfaceDiscovery` plus `baseline()` exemptions cover declaration-backed surfaces and a small discovered-exempt set, but they do not explain non-discovered system/detail pages or distinguish harmless, separate, retired, and true exemption states.
- **Narrowest correct implementation**: Add one bounded residual inventory plus validator checks, keep the current discovery boundary explicit, reuse existing dedicated test suites as evidence, and add only the minimum new tests for weakly covered residuals.
- **Ownership cost created**: One more derived inventory in the action-surface support layer, one new guard test, a few focused closure tests, and ongoing review discipline for future residual pages.
- **Alternative intentionally rejected**: Auto-discovering every system and workflow page or forcing every residual surface into `actionSurfaceDeclaration()` was rejected because the current contract is list/detail-oriented and many residual surfaces are legitimate special workflows rather than malformed generic surfaces.
- **Release truth**: current-release governance closure and regression prevention
## Implementation Strategy
### Phase A — Codify the residual closure inventory and explicit discovery boundary
Goal: make every residual surface reviewable in CI without widening the runtime framework.
Changes:
- Add `spec195ResidualSurfaceInventory()` to `ActionSurfaceExemptions` with one entry per seeded or newly audited residual target surface.
- Extend `ActionSurfaceValidator` with Spec 195 validation for allowed closure decisions, allowed reason categories, duplicate keys, required structured evidence, and explicit primary-discovery status.
- Keep `ActionSurfaceDiscovery` behavior unchanged, but make the validator assert when a residual surface is outside primary discovery and lacks a supplemental closure decision.
- Add `Spec195ResidualActionSurfaceClosureGuardTest.php` and extend existing guard tests so the residual inventory becomes mandatory.
- Keep `baseline()` for backward-compatible discovered-page exemptions, but align its live entries with the new Spec 195 inventory.
Tests:
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` with Spec 195 expectations.
- Add `Spec195ResidualActionSurfaceClosureGuardTest.php`.
### Phase B — Close uncatalogued system and utility surfaces
Goal: explicitly classify the system pages that are currently neither discovered nor baseline-exempt.
Changes:
- Classify `Dashboard` as `separately_governed`, backed by the existing control-tower and break-glass test suites rather than forcing it into the generic declaration contract.
- Classify `ViewRun` as `separately_governed`, backed by Spec 114 triage tests and Spec 194 governance-action guards.
- Classify `Runbooks` as `separately_governed`, backed by Spec 113 auth semantics, runbook start/preflight tests, trusted-state guards, and Ops-UX coverage.
- Classify `RepairWorkspaceOwners` as `separately_governed`, backed by break-glass recovery and table-standard tests.
- Classify `System Directory ViewTenant` and `System Directory ViewWorkspace` as `harmless_special_case` if they remain read-mostly contextual drilldowns; otherwise promote them to `separately_governed` with focused tests.
- Do not force these pages into `actionSurfaceDeclaration()` unless implementation audit finds a natural, already-fitting declaration shape.
Tests:
- Reuse `Spec114/OpsTriageActionsTest.php`, `Spec113/AuthorizationSemanticsTest.php`, `FindingsLifecycleBackfillStartTest.php`, and `BreakGlassWorkspaceOwnerRecoveryTest.php`.
- Add `Spec195/SystemDirectoryResidualSurfaceTest.php` for the current weakest system-detail coverage.
### Phase C — Reclassify special workflows, selectors, landings, and dashboard shells
Goal: turn existing baseline exemptions into explicit closure decisions rather than historical placeholders.
Changes:
- Reclassify `BreakGlassRecovery` as `retired_no_longer_relevant` if it remains inaccessible and actionless; otherwise keep it as `intentional_exemption` with a security-flow reason category.
- Classify `ChooseWorkspace` and `ChooseTenant` as `harmless_special_case` routing surfaces.
- Classify `RegisterTenant` as `separately_governed` because its mutation path, authorization, and bootstrap audit behavior already have focused coverage.
- Keep `ManagedTenantOnboardingWizard` as `separately_governed` and explicitly bind it to Spec 172 plus onboarding/RBAC/audit suites.
- Classify `ManagedTenantsLanding` explicitly and add focused coverage because it currently has the weakest dedicated test evidence among the special surfaces.
- Classify `TenantDashboard` as a `harmless_special_case` page shell or a light `separately_governed` shell, while leaving widget-level governance to the existing widget and arrival-context tests.
Tests:
- Reuse chooser, registration, onboarding, and dashboard tests already in `Auth`, `Workspaces`, `Rbac`, `Onboarding`, and `Filament` suites.
- Add `Spec195ManagedTenantsLandingTest.php` if current routing coverage is insufficiently explicit for the closure inventory.
### Phase D — Final guard hardening and verification flow
Goal: ensure new residual surfaces cannot appear silently after Spec 195 lands.
Changes:
- Fail CI when a new residual surface in the relevant namespaces is action-bearing but has no Spec 195 closure inventory entry.
- Fail CI when a discovered baseline exemption lacks a reason category or explicit evidence reference in the Spec 195 inventory.
- Fail CI when a retired or no-longer-relevant surface still keeps a live baseline exemption.
- Run focused verification through Sail and format touched files with Pint.
Tests:
- New residual closure guard plus the focused reused suites above.
- No full test suite is required to complete the planning phase, but the implementation quickstart defines the minimum targeted verification pack.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Residual closure turns into a second UI framework | Medium | Low | Keep the solution to one derived inventory plus validator checks and focused tests. |
| Old exemptions survive with new labels only | High | Medium | Require explicit reason categories, explicit reasons, structured evidence, and stale-entry cleanup in the guard. |
| Special workflows are over-normalized into the wrong contract | Medium | Medium | Default special workflows to `separately_governed` or `harmless_special_case` unless the existing declaration model already fits. |
| System detail pages remain invisible to reviewers because discovery still skips them | High | Medium | Add explicit residual inventory entries and validator assertions for all out-of-discovery targets. |
| ManagedTenantsLanding remains weakly covered and ambiguous | Medium | Medium | Add a focused Spec 195 landing test and explicit classification. |
## Test Strategy
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` so Spec 195 becomes an explicit CI-enforced rule instead of an informal review note.
- Add `Spec195ResidualActionSurfaceClosureGuardTest.php` to validate closure completeness, reason-category presence, discovery-state truth, and stale-exemption cleanup.
- Reuse existing system triage, runbook, break-glass, chooser, registration, onboarding, and dashboard tests as named coverage evidence for separately governed or harmless surfaces.
- Add only the minimum new targeted tests needed for current coverage gaps, expected to be `SystemDirectoryResidualSurfaceTest.php` and `Spec195ManagedTenantsLandingTest.php`.
- Keep all verification through Sail and run Pint after focused tests.
## Constitution Check (Post-Design)
Re-check result: PASS.
- Livewire v4.0+ compliance remains intact because all touched surfaces stay inside the existing Filament v5 + Livewire v4 stack.
- Provider registration remains unchanged in `bootstrap/providers.php`.
- Global search behavior is unchanged because no searchable resource is added or modified.
- Destructive and recovery actions keep `->requiresConfirmation()` plus current authorization and audit behavior.
- No new assets are introduced; existing `filament:assets` deployment behavior remains sufficient.

View File

@ -0,0 +1,160 @@
# Quickstart: Action Surface Enforcement, Enrollment, and Exception Closure
## Goal
Implement Spec 195 by making every residual action-bearing surface explicitly reviewable without widening the generic action-surface runtime contract.
## Implementation Sequence
### 1. Add the Spec 195 residual closure inventory
Touch:
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
Do:
- Add `spec195ResidualSurfaceInventory()` with one entry per residual target surface.
- Add validator support for:
- allowed closure decisions
- allowed reason categories
- required structured evidence descriptors
- duplicate key detection
- truthful `discoveryState`
- stale baseline-exemption detection
Do not:
- auto-discover every system page
- introduce new persistence
- add a new runtime resolver or registry outside the existing action-surface support layer
### 2. Close the non-discovered system/detail surfaces explicitly
Audit and classify:
- `App\Filament\System\Pages\Dashboard`
- `App\Filament\System\Pages\Ops\ViewRun`
- `App\Filament\System\Pages\Ops\Runbooks`
- `App\Filament\System\Pages\RepairWorkspaceOwners`
- `App\Filament\System\Pages\Directory\ViewTenant`
- `App\Filament\System\Pages\Directory\ViewWorkspace`
Expected direction:
- `Dashboard`, `ViewRun`, `Runbooks`, `RepairWorkspaceOwners` => `separately_governed`
- `ViewTenant`, `ViewWorkspace` => `harmless_special_case` if they remain read-mostly contextual drilldowns
Only move one of these pages into `actionSurfaceDeclaration()` if the implementation audit shows it already fits the existing declaration-backed list/detail model naturally.
### 3. Reclassify the currently baseline-exempt special pages
Audit and classify:
- `App\Filament\Pages\BreakGlassRecovery`
- `App\Filament\Pages\ChooseWorkspace`
- `App\Filament\Pages\ChooseTenant`
- `App\Filament\Pages\Tenancy\RegisterTenant`
- `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`
- `App\Filament\Pages\Workspaces\ManagedTenantsLanding`
- `App\Filament\Pages\TenantDashboard`
Expected direction:
- `BreakGlassRecovery` => retire if it remains inaccessible and actionless; otherwise keep it as `intentional_exemption` with `security_flow_exception`
- `ChooseWorkspace`, `ChooseTenant` => `harmless_special_case`
- `RegisterTenant`, `ManagedTenantOnboardingWizard` => `separately_governed`
- `ManagedTenantsLanding` => explicit closure plus focused test
- `TenantDashboard` => `harmless_special_case` for the page shell while widget behavior stays governed by existing tests
### 4. Add CI regression protection
Touch:
- `tests/Feature/Guards/ActionSurfaceContractTest.php`
- `tests/Feature/Guards/ActionSurfaceValidatorTest.php`
- `tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
Do:
- fail when a residual target has no closure entry
- fail when a non-enrolled residual target has no reason category
- fail when a discovered residual page remains in `baseline()` but has no matching Spec 195 inventory entry
- fail when a retired residual surface still keeps a baseline exemption
### Reviewer Classification Workflow
Use this path whenever an audit uncovers a residual page that is not already in the seed inventory.
1. Identify whether the page is inside primary discovery or outside it.
2. Add or update the residual inventory entry with `surfaceKey`, `surfaceName`, `pageClass`, `panelPlane`, `surfaceKind`, `discoveryState`, `closureDecision`, `reasonCategory` where relevant, `explicitReason`, structured `evidence`, and `followUpAction`.
3. If the page legitimately fits the generic contract, extend the existing contract tests instead of inventing a new local rule.
4. If the page remains separate, harmless, exempt, or retired, add or update focused guard and page-level evidence before merge.
5. Run the focused verification pack and confirm the reviewer can classify the page from guard output alone.
6. Treat the validator failure output as part of the workflow: missing Spec 195 entries should name the class and the concrete file path reviewers must classify next.
### 5. Fill current coverage gaps only where needed
Likely new tests:
- `tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`
- `tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
Reuse existing evidence instead of duplicating it for:
- `ViewRun`
- `Runbooks`
- `RepairWorkspaceOwners`
- chooser flows
- tenant registration
- onboarding wizard
- tenant dashboard shell
## Suggested Test Pack
Run the minimum targeted verification pack through Sail.
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/LivewireTrustedStateGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/FilamentTableStandardsGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec113/AuthorizationSemanticsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/TenantChooserSelectionTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspacePageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceAuditTrailTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftAccessTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Review Checklist
1. Confirm that the primary discovery count does not grow unexpectedly when Spec 195 lands.
2. Confirm that every in-scope residual surface has exactly one closure decision.
3. Confirm that non-discovered system/detail pages are now reviewable through the residual inventory and guard output.
4. Confirm that discovered special pages still exempted from the generic contract now carry explicit reason categories, explicit reasons, and structured evidence.
5. Confirm that `BreakGlassRecovery` is either retired from active exemptions or explicitly kept as `intentional_exemption` with `security_flow_exception` and current evidence.
6. Confirm that the weakest surfaces, expected to be `ManagedTenantsLanding` and system directory detail pages, have explicit focused tests.
7. Confirm that any newly audited residual surface beyond the initial seed was added to the inventory, contract, and guard expectations.
## Deployment Notes
- No migration is expected.
- No provider registration change is expected.
- No new assets are expected.
- Existing `cd apps/platform && php artisan filament:assets` deploy handling remains sufficient but unchanged.

View File

@ -0,0 +1,103 @@
# Research: Action Surface Enforcement, Enrollment, and Exception Closure
## Decision: Preserve the current primary discovery boundary and close the gap with a supplemental residual inventory
### Rationale
`ActionSurfaceDiscovery` already has a clear shape: it discovers resources, relation managers, normal pages, and system pages only when those system pages are declaration-backed table surfaces. The real problem in Spec 195 is not that this boundary exists, but that the repo does not currently make the boundary explicit enough for residual system/detail/workflow surfaces that live outside it.
Keeping the primary discovery boundary stable avoids turning the generic contract into a catch-all framework and lets Spec 195 solve the actual problem: uncatalogued outliers.
### Alternatives considered
- Auto-discover every class under `app/Filament/System/Pages`: rejected because many of those pages are not list/detail contract surfaces and would force the generic contract into shapes it does not currently model well.
- Expand `ActionSurfaceDiscovery` to scan every wizard, dashboard, and selector page in all namespaces: rejected because it would blur the difference between generic contract coverage and legitimate special workflows.
## Decision: Add a parallel `spec195ResidualSurfaceInventory()` instead of refactoring `baseline()` or stretching `ActionSurfaceDeclaration()`
### Rationale
The existing `baseline()` API is a simple string-reason allowlist for discovered pages that intentionally lack declarations. It is useful, but too narrow for Spec 195 because Spec 195 must also classify non-discovered system/detail pages and distinguish closure outcomes like `separately_governed`, `harmless_special_case`, and `retired_no_longer_relevant`.
The narrowest solution is to add one parallel inventory specifically for residual closure. That keeps the current baseline exemption behavior stable while giving the validator the structured data it needs.
### Alternatives considered
- Change `baseline()` into a structured object registry: rejected because it would create avoidable churn in existing tests and validator behavior for a problem that only Spec 195 needs to solve.
- Encode the entire Spec 195 closure model inside `ActionSurfaceDeclaration()`: rejected because many residual surfaces are not natural declaration-backed list/detail surfaces.
## Decision: Represent closure decisions and reason categories as validator-checked strings, not new PHP enums or persisted data
### Rationale
Spec 195 adds review and CI truth, not product-domain behavior. The closure states matter for planning and enforcement, but they do not need to become persisted entities or first-class runtime business state.
Using validator-checked string values in the inventory mirrors the existing Spec 192 and Spec 193 inventory style and avoids adding new runtime types whose only purpose would be internal categorization.
### Alternatives considered
- Add new PHP enums for closure decisions and reason categories: rejected because the validator can enforce the allowed string values without importing extra runtime structure.
- Persist residual closure rows in the database: rejected because this is repository governance truth, not user-facing data truth.
## Decision: Default residual system pages to `separately_governed` or `harmless_special_case` instead of forcing generic contract enrollment
### Rationale
The system residuals called out by the spec do not currently behave like the declaration-backed table/resource surfaces that Specs 192 and 193 govern. `ViewRun` is a system decision detail page, `Runbooks` is a workflow utility hub, `RepairWorkspaceOwners` is a break-glass repair utility, and the directory detail pages are read-mostly context pages.
Existing focused tests already exercise many of these surfaces directly. The narrowest correct implementation is therefore explicit classification plus explicit evidence, not generic normalization for its own sake.
### Alternatives considered
- Enroll `ViewRun`, `Runbooks`, and `RepairWorkspaceOwners` into the current `actionSurfaceDeclaration()` system immediately: rejected because the current contract is list/detail-slot oriented and would fit some of these surfaces awkwardly.
- Leave the system pages uncatalogued because dedicated tests already exist: rejected because that is exactly the gray zone Spec 195 exists to close.
## Decision: Use existing focused test suites as closure evidence and add only gap tests
### Rationale
The repo already has strong dedicated coverage for `Runbooks`, `ViewRun`, `RepairWorkspaceOwners`, registration, choosers, onboarding, and dashboard behavior. Spec 195 should leverage that fact instead of duplicating equivalent tests under a new surface framework.
The only clear weak spot from current evidence is `ManagedTenantsLanding`, and system directory detail pages also need more explicit closure-level assertions than they have today.
### Alternatives considered
- Add one brand-new comprehensive browser suite over every residual surface: rejected because many of these surfaces are already deeply covered through feature or Livewire tests.
- Add only inventory validation with no page-level follow-up: rejected because the weakest residuals still need focused proof.
## Decision: Treat `BreakGlassRecovery` as a stale-exemption candidate rather than assuming it is still an active governed surface
### Rationale
The current `BreakGlassRecovery` page has `canAccess()` returning false and no header actions. That is strong evidence that it may no longer be an action-bearing surface in the way the old baseline exemption reason implies.
Spec 195 should explicitly verify whether it is still a live residual surface. If not, it should be retired from the active exemption set instead of remaining as historical noise.
### Alternatives considered
- Keep the existing exemption reason unchanged because it references dedicated security specs: rejected because the current code suggests the page may no longer be a live action surface.
- Force the page into the residual inventory as a live intentional exemption without re-auditing the code: rejected because stale exemptions are one of the specs explicit problems.
## Decision: Keep selectors and dashboard shells outside the generic contract but classify them explicitly
### Rationale
`ChooseWorkspace`, `ChooseTenant`, and `TenantDashboard` are real operator surfaces, but they are not contract-style list/detail pages in the sense governed by the earlier specs. Selectors are routing surfaces; the dashboard page is a shell whose meaningful actions live in widgets and downstream routes.
Spec 195 should classify them explicitly so they are no longer invisible to review, while still avoiding artificial normalization.
### Alternatives considered
- Treat selectors and dashboards as non-surfaces and ignore them: rejected because they clearly influence operator workflows and currently appear in the residual exemption tail.
- Enroll them in the generic contract anyway: rejected because the generic contract is not the right fit for routing-only or widget-shell surfaces.
## Decision: No new provider, asset, route, or persistence work is needed
### Rationale
All evidence points to Spec 195 being a repository-governance and test-hardening slice. The existing pages, routes, panels, and tests already provide the runtime behavior. The missing part is explicit closure inventory and regression enforcement.
### Alternatives considered
- Add a new service provider or config file just for residual-surface closure: rejected because the existing action-surface support layer already provides the correct home.
- Add new assets or UI primitives for special surfaces: rejected because the implementation does not need new rendering infrastructure.

View File

@ -0,0 +1,323 @@
# Feature Specification: Action Surface Enforcement, Enrollment, and Exception Closure
**Feature Branch**: `195-action-surface-closure`
**Created**: 2026-04-12
**Status**: Proposed
**Input**: User description: "Spec 195 - Action Surface Enforcement, Enrollment & Exception Closure"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: After Specs 192, 193, and 194, the remaining action-bearing system, utility, flow, landing, and special workflow surfaces are not all clearly inside one reviewable regime. Some are covered by the generic action-surface contract, some are protected by focused rules elsewhere, and some still sit in historical gray zones.
- **Today's failure**: Reviewers cannot always tell whether a residual surface is intentionally enrolled, intentionally exempt, separately governed, retired, or simply missed by discovery. This leaves room for silent outliers and historical exemptions to bypass the central guard.
- **User-visible improvement**: Operators and reviewers get a clean closure state for every remaining residual surface. Sensitive system and utility actions stop living in undocumented gray zones, while already good special surfaces remain allowed without being silently outside discipline.
- **Smallest enterprise-capable version**: Build one residual inventory, assign exactly one closure decision to every remaining outlier, harden discovery and exemption boundaries, and add lightweight regression protection so new outliers cannot appear silently.
- **Explicit non-goals**: No new header hierarchy rules, no new monitoring semantics, no new governance-friction taxonomy beyond what Spec 194 already owns, no universal dashboard or widget framework, no large UI redesign, and no blanket removal of all exemptions without surface-by-surface review.
- **Permanent complexity imported**: A small closure-decision vocabulary, explicit exemption reason categories, a reviewable residual inventory, clearer discovery boundaries, and focused regression tests.
- **Why now**: The UX and action semantics block is already defined in Specs 192 to 194. What remains is the technical governance closure needed so the repo can stop carrying silent edge cases.
- **Why not local**: Local cleanup on one page at a time would not prevent future surfaces, hidden discovery gaps, or inherited exemptions from drifting outside the contract again.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Cross-surface governance breadth risk and taxonomy creep risk. Defense: the spec is explicitly limited to residual closure, does not introduce a new product workflow, and preserves legitimate separately governed surfaces instead of forcing uniformity.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- Existing system operations run detail and runbooks surfaces
- Existing system repair utilities for workspace owner recovery
- Existing system directory tenant and workspace detail surfaces
- Existing break-glass recovery, chooser, and registration flows
- Existing managed-tenant onboarding, landing, and dashboard surfaces
- Any residual surface still represented in the current action-surface discovery, exemption, or guard inventory after Specs 192 to 194
- **Data Ownership**:
- This feature introduces no new domain tables, records, or business entities.
- Existing tenant-owned, workspace-owned, and system-visible records remain owned exactly as they are today.
- Closure decisions, exemption reasons, and discovery boundaries are review and enforcement truth only; they do not introduce a new user-facing data model.
- **RBAC**:
- Tenant admin surfaces continue to require tenant membership plus the existing tenant-scoped capabilities.
- Workspace admin surfaces continue to require workspace membership plus the existing workspace-scoped capabilities.
- System surfaces continue to require platform or system capabilities.
- This feature does not widen access; it only forces explicit governance classification for residual action-bearing surfaces.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Tenant-context surfaces remain strictly bound to the active tenant. Workspace surfaces may retain an entitled tenant context as a quiet scope signal or filter, but the closure decision for a surface must never depend on a hidden tenant context. System surfaces never inherit tenant context as an authorization expansion.
- **Explicit entitlement checks preventing cross-tenant leakage**: Discovery, enrollment, or exemption review may inventory surfaces across tenant, workspace, and system planes, but any enrolled or separately governed surface must continue to use the current deny-as-not-found and capability checks. Review artifacts must classify surfaces without exposing inaccessible tenant or workspace detail.
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| System Ops ViewRun | Primary Decision Surface | Decide whether a system run needs retry, cancellation, investigation, or only inspection | Run identity, current outcome, intervention availability, and safety context | Runbooks, deep diagnostics, related operational history | Primary because this is the system-level intervention point for one active run | Follows operations triage, not generic record browsing | Removes ambiguity about whether run interventions are governed or accidental utilities |
| System Ops Runbooks | Secondary Context Surface | Choose an approved operational response path after diagnosis | Available runbook choices, current system scope, and whether action launch is allowed | Procedure detail and historical execution context | Not primary because it supports a later intervention decision instead of owning the first diagnosis | Follows guided operational response | Prevents runbook launch actions from living as undocumented side utilities |
| Repair Workspace Owners | Primary Decision Surface | Repair a broken ownership state such as missing or duplicate ownership | Workspace identity, defect state, and the currently allowed repair | Membership evidence, prior repair history, and audit context | Primary because the surface exists to support a sensitive repair decision | Follows workspace recovery workflow | Prevents dangerous repair actions from living in historical exception space |
| System Directory tenant and workspace detail | Secondary Context Surface | Inspect system-level directory context and open the correct downstream surface | Scope identity, current state, and whether the page is read-only or action-bearing | Related downstream admin context and supporting diagnostics | Not primary because these pages are mostly inspection-first unless a small safe action exists | Follows inspection and routing workflow | Keeps read-mostly system detail pages calm while still classifying any light actions |
| Break Glass Recovery | Primary Decision Surface | Recover operator access during an exceptional lockout or recovery event | Recovery state, available recovery path, and safety warnings | Supporting diagnostics and deeper recovery evidence | Primary because the whole surface exists for a formal exceptional-access decision | Follows emergency access recovery workflow | Makes clear that this is a governed exception rather than an accidental bypass surface |
| ChooseWorkspace and ChooseTenant | Secondary Context Surface | Choose the correct scope before continuing work | Available scopes and why each scope is selectable | Downstream page detail only after selection | Not primary because selection is routing, not governance mutation | Follows scope entry workflow | Prevents selector surfaces from being silently ignored just because they are not record pages |
| RegisterTenant and ManagedTenantOnboardingWizard | Primary Decision Surface | Advance or complete tenant registration and onboarding safely | Current step, blocking prerequisites, and next required action | Supporting evidence, validation detail, and downstream setup context | Primary because the operator is making guided setup decisions step by step | Follows onboarding workflow | Keeps wizard exceptions explicit instead of forcing them into a record-page model |
| ManagedTenantsLanding and TenantDashboard | Secondary Context Surface | Open the right tenant or next task from a scoped landing surface | Scope summary, key status, and next safe navigation choice | Deeper evidence and operational detail only after drilldown | Not primary because these are arrival and routing surfaces, not the final decision point | Follows landing and context-setting workflow | Prevents dashboard and landing actions from remaining unclassified just because they are broad entry points |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| System Ops ViewRun | Detail / Decision | System run triage detail | Retry, cancel, mark investigated, or open related runbook | Direct detail page for one run | forbidden | Supporting links and runbooks stay secondary or grouped by meaning | Cancel and other strongest interventions stay visibly separated and confirmed | Existing system operations collection route | Existing system run detail route | System scope, run state, intervention eligibility | Operations / system run | Whether intervention is possible now and which intervention is justified | Must end as enrolled or separately governed; no silent special case |
| System Ops Runbooks | Utility / Workflow hub | Guided system intervention utility | Open the appropriate runbook | Direct runbooks surface with explicit open action | forbidden | Supporting utilities stay grouped and subordinate to runbook choice | Any dangerous launch or escalation remains separated within the workflow | Existing system runbooks entry route | Same runbooks surface or guided runbook drilldown | System scope and current operational context | Runbooks / runbook | Which governed intervention paths are available | Separately governed is allowed if it is explicit and tested |
| Repair Workspace Owners | Utility / Repair | Sensitive repair utility | Repair missing or duplicate ownership state | Direct repair utility page | forbidden | Refresh and diagnostics stay quiet and secondary | Repair mutations remain isolated, confirmed, and capability-gated | Existing system repair utilities entry route | Same repair surface | Workspace identity and defect state | Workspace ownership repair | Whether a dangerous repair is warranted | Cannot remain a historical or implicit exemption |
| System Directory ViewTenant | Detail / Context | System directory tenant detail | Open related admin context or inspect state | Direct tenant detail page | forbidden | Context links remain secondary | No destructive action unless separately justified | Existing system directory tenant collection route | Existing system directory tenant detail route | System scope and tenant identity | Directory tenant | Whether the page is read-only, safe, or mutation-bearing | Harmless special case or separately governed if a light action remains |
| System Directory ViewWorkspace | Detail / Context | System directory workspace detail | Open related admin context or inspect state | Direct workspace detail page | forbidden | Context links remain secondary | No destructive action unless separately justified | Existing system directory workspace collection route | Existing system directory workspace detail route | System scope and workspace identity | Directory workspace | Whether the page is read-only, safe, or mutation-bearing | Harmless special case or separately governed if a light action remains |
| Break Glass Recovery | Workflow / Exception | Exceptional recovery workflow | Continue governed access recovery | Guided recovery flow | forbidden | Supporting diagnostics and help links stay secondary | Recovery mutations remain isolated and confirmed | Existing break-glass recovery entry route | Same recovery workflow route | Recovery state and access block reason | Break glass recovery | Whether safe recovery is available now | Separate governance is acceptable because this is an exceptional workflow |
| ChooseWorkspace and ChooseTenant | Workflow / Selector | Scope selection surface | Choose scope and continue | Row, tile, or explicit select action | required | Navigation and help stay secondary | none | Existing chooser route | Downstream chosen workspace or tenant route | Available scope only | Workspace chooser / tenant chooser | Which scopes are available and selectable | Intentional exemption or harmless special case is acceptable if explicitly recorded |
| RegisterTenant and ManagedTenantOnboardingWizard | Workflow / Wizard | Guided onboarding workflow | Continue the next required setup step | Step-based wizard progression | forbidden | Supporting validation and help stay secondary | Any irreversible setup step remains confirmed and authorized | Existing registration or onboarding entry route | Existing onboarding wizard route | Workspace or tenant scope, current step, prerequisite state | Tenant registration / onboarding | Which prerequisite blocks the next step | Separate governance is acceptable because the wizard already owns focused workflow rules |
| ManagedTenantsLanding and TenantDashboard | Landing / Dashboard | Scoped landing and routing surface | Open the next tenant or next task | Card, row, or explicit open action | allowed | Navigation shortcuts remain contextual and subordinate to scope summary | none unless a direct mutation exists and is explicitly governed | Existing managed-tenants landing or dashboard route | Downstream tenant detail, onboarding, or task route | Active workspace or tenant scope | Managed tenants / tenant dashboard | What needs attention next without pretending the landing is the final decision point | Separately governed or harmless special case, but never silent |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| System Ops ViewRun | Platform operator | Decide whether to intervene on one run | System run triage detail | Does this run need action now, and which action is justified? | Run identity, outcome, retryability, cancellation state, investigation need | Runbooks, linked runs, and deeper diagnostics | execution outcome, lifecycle attention, retryability | Existing run mutation scope only | Retry, Resume, Mark investigated when allowed | Cancel and equivalent hard interventions |
| System Ops Runbooks | Platform operator | Choose an approved intervention path | Guided system utility | Which runbook matches the current operational need? | Runbook choices and current context | Procedure detail and downstream execution evidence | intervention readiness, operational context | Existing system utility scope only | Open runbook or start guided intervention | Any launch that performs a strong intervention remains separated |
| Repair Workspace Owners | Platform or workspace recovery operator | Repair broken workspace ownership | Repair utility | Is the workspace ownership state broken enough to justify repair? | Missing-owner or duplicate-owner truth and currently allowed repair | Membership detail and prior recovery evidence | defect state, repair eligibility | TenantPilot only | Repair or merge actions when justified | All repair mutations are dangerous and confirmed |
| System Directory ViewTenant | Platform operator | Inspect system-level tenant context | Context detail | Is this a read-only inspection surface or a small safe context surface? | Tenant identity, state, and related context | Deeper downstream admin detail | lifecycle or presence state only if relevant | read-only unless a light action is explicitly allowed | Open related admin context | none by default |
| System Directory ViewWorkspace | Platform operator | Inspect system-level workspace context | Context detail | Is this a read-only inspection surface or a small safe context surface? | Workspace identity, state, and related context | Deeper downstream admin detail | lifecycle or presence state only if relevant | read-only unless a light action is explicitly allowed | Open related admin context | none by default |
| Break Glass Recovery | Exceptional access operator | Recover access safely during lockout or recovery | Exceptional recovery workflow | Which recovery step is allowed now, and what risk does it carry? | Recovery state, available path, and safety warning | Deeper evidence and supporting diagnostics | recovery readiness, access state | TenantPilot only | Continue recovery or confirm recovery step | Any access-granting recovery mutation |
| ChooseWorkspace and ChooseTenant | Operator entering scope | Choose the correct scope and continue | Selector surface | Which scope can I enter now? | Available scopes and selection affordance | none until downstream navigation | scope availability only | no mutation beyond selection | Select scope | none |
| RegisterTenant and ManagedTenantOnboardingWizard | Workspace operator | Advance registration and onboarding | Guided onboarding workflow | What step is blocked, and what must I do next? | Current step, missing prerequisite, next allowed action | Supporting validation detail | prerequisite readiness, onboarding progress | TenantPilot only | Continue, validate, submit when allowed | Any irreversible registration or setup completion step |
| ManagedTenantsLanding and TenantDashboard | Workspace or tenant operator | Route into the right next task | Landing and dashboard surface | What needs attention next, and where should I open it? | Scope summary, next task, current state highlights | Deeper operational evidence after drilldown | readiness, attention state, lifecycle highlights | Usually read-only routing; explicit if any direct mutation exists | Open tenant, continue onboarding, open next task | none by default |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: yes
- **Current operator problem**: The repo still has a small but important tail of residual action-bearing surfaces where nobody can quickly tell whether the generic contract applies, whether a focused exception is legitimate, or whether a surface simply escaped discovery.
- **Existing structure is insufficient because**: Specs 192 to 194 define the behavior of governed surfaces, but they do not close the remaining gap between rulebook and discovery coverage. Without explicit closure decisions, historical exemptions and discovery limits can continue to create silent outliers.
- **Narrowest correct implementation**: Add one residual inventory, one explicit closure-decision matrix, minimal exemption reason categories, and lightweight regression guards. Do not create a new runtime workflow engine, persistence model, or broad UI framework.
- **Ownership cost**: Ongoing review of new residual surfaces, upkeep of explicit exemption reasons, a small amount of CI guard maintenance, and focused tests that prove closure decisions remain intentional.
- **Alternative intentionally rejected**: Pure page-by-page cleanup was rejected because it would leave discovery boundaries, exemptions, and future outliers unresolved. Blanket enrollment of every special surface was rejected because it would destroy legitimate separately governed patterns without improving safety.
- **Release truth**: current-release governance closure and regression prevention
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Close the residual inventory (Priority: P1)
As a reviewer, I want every remaining action-bearing surface after Specs 192 to 194 to appear in one closure inventory with exactly one final decision, so no gray zone remains.
**Why this priority**: This is the core purpose of the spec. If any residual surface stays ambiguous, the closure is incomplete.
**Independent Test**: Review the residual inventory alone and verify that every listed surface has exactly one closure decision plus a rationale or coverage note.
**Acceptance Scenarios**:
1. **Given** the repo after Specs 192 to 194, **When** the residual inventory is reviewed, **Then** every in-scope residual surface appears exactly once.
2. **Given** an inventoried residual surface, **When** a reviewer checks its record, **Then** the surface is classified as enrolled, intentionally exempt, separately governed, retired, or harmless special case with no ambiguity.
---
### User Story 2 - Remove silent system and utility exceptions (Priority: P1)
As a platform operator, I want sensitive system and utility surfaces to have an explicit governance status, so dangerous actions do not live outside review discipline by accident.
**Why this priority**: The riskiest remaining residuals are not ordinary record pages. If these surfaces remain implicit exceptions, the main safety gap stays open.
**Independent Test**: Review the named high-risk system and utility surfaces and verify that each one is either enrolled in the generic contract or explicitly handled as a justified separate case.
**Acceptance Scenarios**:
1. **Given** a sensitive residual system or utility surface, **When** its closure decision is reviewed, **Then** the surface no longer relies on an implied or historical exemption.
2. **Given** a residual surface stays outside the generic contract, **When** the reviewer checks why, **Then** the reason and separate coverage are explicit and reviewable.
---
### User Story 3 - Keep only justified exemptions (Priority: P2)
As a code reviewer, I want every remaining exemption to carry a reason category and coverage note, so I can distinguish a justified special case from leftover drift.
**Why this priority**: Exemptions are acceptable only when they are conscious, minimal, and reviewable.
**Independent Test**: Review the exemption list alone and confirm that each remaining exemption has a short reason category, a clear closure decision, and a note about where its safety or behavior is covered.
**Acceptance Scenarios**:
1. **Given** an existing exemption entry, **When** it is reviewed under Spec 195, **Then** it either gains an explicit reason category and coverage note or it is removed.
2. **Given** an exemption no longer reflects a real residual surface, **When** the closure pass is complete, **Then** that exemption no longer remains in the active inventory.
---
### User Story 4 - Block future unclassified residuals (Priority: P2)
As a future implementer, I want a lightweight guard and review path that fail fast when a new residual surface or exemption appears without classification, so the repo cannot drift back into silent exceptions.
**Why this priority**: The spec only closes the block if it stays closed.
**Independent Test**: Add a representative new residual surface or exemption in a controlled test path and verify that review guidance or CI fails until a closure decision is supplied.
**Acceptance Scenarios**:
1. **Given** a newly added action-bearing residual surface, **When** it lacks enrollment or closure classification, **Then** the guard fails.
2. **Given** a newly added exemption entry, **When** it lacks a reason category or coverage note, **Then** the guard fails.
---
### User Story 5 - Preserve good special surfaces without forced churn (Priority: P3)
As a product reviewer, I want already well-governed special surfaces to remain outside the generic contract when that is the cleanest choice, so closure does not become needless uniformity.
**Why this priority**: The spec is about removing gray zones, not punishing legitimate special workflows.
**Independent Test**: Review an existing wizard, recovery workflow, or dashboard surface that already has focused coverage and verify that it can remain separately governed without being mislabeled as a defect.
**Acceptance Scenarios**:
1. **Given** a residual surface already covered by dedicated rules and focused tests, **When** Spec 195 is applied, **Then** the surface may remain separately governed with an explicit note.
2. **Given** a harmless read-mostly special case, **When** it is reviewed, **Then** it is recorded as such instead of being forced into the generic contract for aesthetic symmetry.
### Edge Cases
- A surface may expose only one apparently harmless action such as routing or light inspection; it still must be explicitly classified and cannot stay outside the inventory by assumption alone.
- A wizard, landing, or dashboard may live outside the default discovery path; if it is action-bearing, the discovery boundary must still explain how it is classified.
- A historical exemption may point to a surface that is no longer action-bearing or no longer exists; it must be retired instead of remaining as dead noise.
- A read-mostly system detail surface may later gain a mutating action; the regression guard must force a new closure review at that moment.
- A surface may appear to fit multiple closure categories; the final inventory must still choose exactly one category.
- A surface may be separately governed because its actions already have focused tests; that must be recorded explicitly rather than inferred from institutional memory.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new user-facing workflow domain, and no new persisted truth. It classifies and hardens existing action-bearing surfaces and their review path. Existing write actions keep their current preview, confirmation, audit, and run-observability behavior. Any sensitive DB-only repair or recovery action that intentionally skips `OperationRun` must remain auditable and explicitly recorded as separately governed or intentionally exempt.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature deliberately adds only the smallest cross-cutting layer required now: a residual closure inventory, a closure-decision vocabulary, explicit exemption reason categories, and regression guards. It does not add new persistence, a generic action framework, or new business states.
**Constitution alignment (OPS-UX):** Existing actions that already create or reuse `OperationRun` keep their current run lifecycle and notification semantics. Spec 195 only requires that any residual surface remaining outside the generic contract still points to focused run or audit coverage where relevant, so separate governance is never a trust gap.
**Constitution alignment (RBAC-UX):** The affected authorization planes are workspace admin `/admin`, tenant-context admin `/admin/t/{tenant}/...`, and platform `/system`. Non-members or users lacking entitled scope remain `404`, members lacking capability remain `403`, and moving a surface into enrolled, exempt, or separately governed status does not change server-side authorization. Destructive actions remain confirmed and capability-gated. Regression coverage must include at least one positive and one negative authorization path across residual surfaces touched by the closure pass.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not change authentication handshake behavior.
**Constitution alignment (BADGE-001):** This feature does not introduce a new badge domain. Existing badge semantics remain centralized. Any residual surface that displays status continues to use the current centralized status language.
**Constitution alignment (UI-FIL-001):** Any UI remediation under this spec continues to use native Filament pages, actions, grouped actions, and existing enforcement helpers. The feature must not introduce a local button framework, page-local danger language, or new styling vocabulary. Approved exceptions remain explicit special workflows such as wizards, recovery flows, or guided utilities.
**Constitution alignment (UI-NAMING-001):** Existing operator verbs such as `Retry`, `Cancel`, `Mark investigated`, `Repair`, `Merge`, `Recover access`, `Select`, `Register tenant`, and `Continue onboarding` must remain domain-first and consistent across buttons, confirmation copy, notifications, and audit prose. Closure categories such as enrolled or separately governed are review vocabulary, not new operator-facing nouns.
**Constitution alignment (DECIDE-001):** Every affected residual surface must declare whether it is a Primary Decision Surface, Secondary Context Surface, or a low-risk special surface. The first-decision information for each primary surface must remain visible by default, while diagnostics and deep evidence remain on demand. Separate governance is valid only when one operator task still remains clear inside that surface's own workflow.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** This spec classifies each residual surface by action-surface class, detailed surface type, likely next action, inspect model, row-click policy, action placement, canonical route family, scope signals, canonical noun, and exception rationale. Residual surfaces may differ by type, but none may remain uncatalogued.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Where residual surfaces expose header, row, bulk, selector, or workflow actions, navigation, mutation, contextual signals, and dangerous actions must remain structurally separated. Any grouped action set must remain meaningful rather than a mixed catch-all. System, wizard, recovery, and dashboard exceptions are acceptable only when they are genuine workflow types, not convenience shortcuts.
**Constitution alignment (OPSURF-001):** Default-visible content on residual surfaces must stay operator-first. System and utility surfaces must show the current operational truth before asking the operator to act. Any mutating action must continue to communicate its scope before execution, and dangerous actions must keep the existing safe-execution pattern of context, safety checks, confirmation, and execution.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from page class to contract coverage is insufficient because residual surfaces can fall outside the generic discovery path. The new closure layer must therefore remain explicit and reviewable, but it must avoid duplicating runtime truth or creating a separate user-facing model. Tests should prove that residual surfaces are classified and guarded, not just that a thin registry exists.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract must be explicitly marked as satisfied for any residual surface that enters the generic contract. Surfaces that remain outside it must cite an explicit exemption or separate-governance rationale. Each affected surface must still have one primary inspect or open model, no redundant View affordance, no empty grouped-action placeholder, and destructive actions that continue to use confirmed execution. UI-FIL-001 remains satisfied because the spec reuses native Filament surfaces and explicit exception handling rather than inventing new local primitives.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature is a governance and closure spec, not a layout rewrite. Existing views, forms, wizards, and dashboards keep their current page composition unless a residual surface needs a small targeted alignment to meet the already established action-surface rules. No surface is rebuilt purely for visual symmetry.
### Closure Principles
1. **No silent residual surfaces**: Every action-bearing residual surface must be enrolled, intentionally exempt, separately governed, retired, or harmless.
2. **Exemption is a decision, not an accident**: An exemption is valid only when it is explicit, justified, minimal, and reviewable.
3. **Discovery boundaries must be explicit**: If the generic discovery path cannot see a surface class, that limit must be modeled and protected rather than treated as a repo accident.
4. **Separate coverage counts**: A surface may stay outside the generic contract if dedicated specs, tests, or focused guards already protect it sufficiently.
5. **No forced sameness**: Good special workflows do not need to be flattened into the generic contract when separate governance is the cleaner and safer answer.
6. **Closure over expansion**: This spec closes the remaining block after Specs 192 to 194; it does not open a new architecture program.
### Closure Decision Matrix
- **Generic contract enrollment**: The surface is brought into the generic action-surface contract and must satisfy the earlier rules directly.
- **Intentional exemption**: The surface stays outside the generic contract with a short, reviewable reason category.
- **Separately governed**: The surface stays outside the generic contract because focused specs, tests, or guards already govern it sufficiently.
- **Retired / no longer relevant**: The surface is no longer an active residual and must leave the live inventory.
- **Harmless special case**: The surface is action-bearing but small, low-risk, and intentionally classified as not needing the full generic contract.
### Functional Requirements
- **FR-195-001 Residual inventory**: The repo MUST maintain one complete inventory of every remaining action-bearing residual surface still outside clearly settled coverage from Specs 192 to 194.
- **FR-195-002 Exact closure decision**: Every inventoried residual surface MUST have exactly one closure decision: `generic contract enrollment`, `intentional exemption`, `separately governed`, `retired / no longer relevant`, or `harmless special case`.
- **FR-195-003 No gray-zone rule**: No action-bearing residual surface in scope MAY remain undocumented, unclassified, or implicitly outside governance.
- **FR-195-004 Residual review scope**: The closure pass MUST explicitly review the known residual areas, including system run detail, system runbooks, workspace-owner repair, system directory detail surfaces, break-glass recovery, chooser flows, registration and onboarding flows, landing surfaces, dashboard surfaces, and any equivalent residual surface represented in the current inventory or guard path.
- **FR-195-005 System and utility decision rule**: Every residual system or utility surface with mutating actions MUST be either enrolled in the generic contract or explicitly recorded as intentionally exempt or separately governed with clear rationale.
- **FR-195-006 Exemption minimization**: Historical or diffuse exemptions that no longer represent a justified residual surface MUST be removed or reclassified.
- **FR-195-007 Exemption reason categories**: Every remaining exemption MUST carry one short reason category explaining why enrollment is not the correct closure decision.
- **FR-195-008 Separate-governance recognition**: A residual surface MAY remain outside the generic contract only when the inventory explicitly records the dedicated spec, focused tests, or guard path that already govern it.
- **FR-195-009 Harmless special-case discipline**: A surface MAY be classified as a harmless special case only when its action scope is limited, its operational risk is low, and that judgment is explicit in the inventory.
- **FR-195-010 Discovery-boundary explicitness**: The generic discovery path MUST define its boundaries clearly enough that a reviewer can tell which surface classes are discovered automatically and which require supplemental enrollment or explicit outside-contract classification.
- **FR-195-011 No discovery accident**: A residual surface MUST NOT bypass governance solely because it lives in a system panel, wizard, selector, recovery flow, landing surface, dashboard, or another namespace outside the default discovery roots.
- **FR-195-012 System-panel closure**: Residual system-panel surfaces MUST explicitly declare whether they are decision surfaces, context surfaces, or separately governed utilities, and any dangerous actions on them MUST remain classified and reviewable.
- **FR-195-013 Flow and wizard closure**: Registration, onboarding, chooser, and recovery flows MAY remain outside the generic contract, but their closure decision and focused coverage MUST be explicit.
- **FR-195-014 Landing and dashboard closure**: Landing and dashboard surfaces with actions MUST be explicitly classified as enrolled, separately governed, or harmless; being broad or summary-oriented is not itself an exemption.
- **FR-195-015 Retired-surface cleanup**: If an exempted or inventoried surface is retired or no longer action-bearing, it MUST be removed from the live residual inventory so dead entries cannot mask real gaps.
- **FR-195-016 Review artifact completeness**: Each residual inventory entry MUST record the surface name, authorization plane, surface type, closure decision, reason category where relevant, and where its coverage lives.
- **FR-195-017 Guard against unclassified surfaces**: The repo MUST add or extend a lightweight guard that fails when a new action-bearing residual surface appears without an explicit closure decision.
- **FR-195-018 Guard against reasonless exemptions**: The repo MUST add or extend a lightweight guard that fails when a new exemption appears without a reason category and coverage note.
- **FR-195-019 Review path clarity**: Contributor and reviewer workflows MUST make it obvious how to classify a new residual surface before merge.
- **FR-195-020 No forced normalization**: Residual surfaces that are already safely separately governed MUST NOT be forced into the generic contract solely to reduce taxonomy variety.
- **FR-195-021 Contract continuity for enrolled surfaces**: Any residual surface moved into the generic contract MUST satisfy the already established contract from the earlier action-surface specs rather than introducing a new local rule set.
- **FR-195-022 Dedicated coverage proof**: Any surface marked intentionally exempt, separately governed, or harmless special case MUST have explicit rationale and enough focused coverage to make the decision reviewable in CI and code review.
- **FR-195-023 Authorization continuity**: Closure classification, discovery hardening, or exemption cleanup MUST NOT weaken route scope, capability enforcement, deny-as-not-found behavior, or destructive-action confirmation.
- **FR-195-024 Audit and safety continuity**: Residual destructive or recovery actions MUST remain auditable, confirmed, and safety-gated regardless of whether the surface is enrolled, exempt, or separately governed.
- **FR-195-025 Block completion**: After Spec 195, the residual action-surface block following Specs 192 to 194 MUST have no remaining action-bearing surface without a visible closure decision.
## Target Outcomes by Key Residual Area
- **System Ops ViewRun**: No longer a silent special case. It ends either as a generic-contract surface or as an explicitly separately governed system triage surface with focused coverage.
- **System Ops Runbooks**: Operational utilities are classified intentionally instead of existing as factual but ungoverned launch points.
- **Repair Workspace Owners**: A risky repair utility receives an explicit closure state and does not remain as an inherited historical exception.
- **System Directory tenant and workspace detail**: Read-mostly detail pages are explicitly classified as harmless, enrolled, or separately governed rather than left unreviewed.
- **Break Glass Recovery**: The exceptional-access workflow remains legitimate only if its special handling is explicit and reviewable.
- **Chooser, registration, onboarding, landing, and dashboard surfaces**: Every such surface receives a closure decision so routing and workflow entry points are no longer missing from the action-surface map.
## Non-Goals
- Creating a new fourth main rule set after Specs 192 to 194
- Redefining header hierarchy, monitoring semantics, or governance-friction classes
- Building a universal widget, dashboard, or workflow meta-contract
- Rewriting already calm surfaces for stylistic consistency alone
- Removing every exemption without checking whether the exemption is legitimate
- Introducing new business entities, new workflow states, or new operator concepts unrelated to closure
## Assumptions
- Specs 192, 193, and 194 remain the authoritative sources for surface behavior; Spec 195 only closes the residual governance gap around discovery, enrollment, exemptions, and regression protection.
- Some recovery, wizard, selector, and dashboard surfaces will remain outside the generic contract because they are inherently special workflow types.
- Existing action semantics, authorization logic, audit behavior, and run-observability rules on the underlying actions are already correct and will be preserved rather than redesigned here.
- The cleanest outcome is explicit closure, not universal enrollment.
## Dependencies
- Constitution rule set for Action Surface Discipline
- Spec 192 - Record Page Header Discipline and Contextual Navigation
- Spec 193 - Monitoring Surface Action Hierarchy and Workbench Semantics
- Spec 194 - Governance Friction Hardening and Operator Vocabulary
## UI Action Matrix *(mandatory when Filament is changed)*
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| System Ops ViewRun | Existing system run detail page | `Refresh`, `Retry` or `Resume` when applicable, `Open runbooks`, `Mark investigated`, `Cancel` when applicable | not applicable | none | none | not applicable | One run intervention may be primary if justified; strongest intervention stays separated | not applicable | yes for interventions | Must be enrolled or separately governed; no silent exemption |
| System Ops Runbooks | Existing system runbooks surface | `Open runbook`, guided intervention entry points, quiet navigation | Explicit open action or card selection | `Open runbook` | none | Context-specific single CTA if no runbooks apply | Same guided utility actions | not applicable | yes when a launch mutates state | Separate governance is acceptable if explicit and tested |
| Repair Workspace Owners | Existing system repair utility | `Refresh diagnosis`, `Repair owner state`, `Merge duplicate ownership` | not applicable | none | none | not applicable | Repair actions remain grouped and confirmed | not applicable | yes | High-risk utility; must not remain historical exception |
| System Directory ViewTenant and ViewWorkspace | Existing system directory detail pages | Quiet contextual links only unless a light safe action exists | not applicable | none | none | not applicable | Read-mostly contextual actions only | not applicable | no for read-only, yes if mutation exists | Candidate harmless special case or separate governance |
| Break Glass Recovery | Existing break-glass workflow | `Continue recovery`, `Confirm recovery step`, `Cancel` or equivalent safe exit | Guided step progression | none | none | Single recovery CTA when entering the flow | Recovery-step actions only | Wizard navigation rather than save/cancel | yes | Explicit separate governance expected |
| ChooseWorkspace and ChooseTenant | Existing selector surfaces | No competing header mutations; quiet back/help only | Row, tile, or select action is the primary inspect/open model | `Select` | none | Single CTA only when no accessible scopes are available | not applicable | not applicable | no | Intentional exemption or harmless special case acceptable if explicit |
| RegisterTenant and ManagedTenantOnboardingWizard | Existing registration and onboarding surfaces | `Continue`, `Validate`, `Submit`, `Cancel` as step-appropriate | Step progression inside the wizard | none | none | Single start CTA from entry state | Wizard step actions only | Save/continue and cancel where the workflow uses form steps | yes for mutating steps | Separate governance expected because the workflow already owns its path |
| ManagedTenantsLanding and TenantDashboard | Existing landing and dashboard surfaces | `Open tenant`, `Continue onboarding`, `Open next task` where present | Card, row, or explicit open affordance | One safe shortcut at most | none | One CTA when the landing is otherwise empty | Contextual navigation only | not applicable | usually no, unless a direct mutation exists | Must be explicitly classified as separately governed or harmless if not enrolled |
### Key Entities *(include if feature involves data)*
- **Residual Action Surface**: An operator-facing surface that still carries actions after Specs 192 to 194 and therefore requires an explicit closure decision.
- **Closure Decision**: The single final classification for a residual surface: generic contract enrollment, intentional exemption, separately governed, retired / no longer relevant, or harmless special case.
- **Exemption Reason Category**: A short explanation that makes an exemption reviewable and keeps it from becoming a catch-all for unresolved drift.
- **Discovery Boundary**: The explicit statement of which surface classes the generic discovery path can see automatically and which require supplemental handling.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of residual action-bearing surfaces remaining after Specs 192 to 194 are listed in the closure inventory with exactly one final closure decision.
- **SC-002**: 0 active exemption entries remain without a reason category and explicit coverage note.
- **SC-003**: The regression guard fails on every representative test case where a new residual surface or exemption is introduced without classification.
- **SC-004**: A reviewer can determine, from the inventory and focused coverage alone, whether any residual surface is enrolled, intentionally exempt, separately governed, retired, or harmless without reconstructing intent from code history.

View File

@ -0,0 +1,183 @@
---
description: "Task list for Spec 195 action-surface closure implementation"
---
# Tasks: Action Surface Enforcement, Enrollment, and Exception Closure
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/195-action-surface-closure/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md, contracts/action-surface-closure.logical.openapi.yaml
**Tests**: Runtime behavior changes in this repo require Pest coverage. This task list includes guard, feature, and focused residual-surface tests.
**Operations**: This feature does not add new long-running or queued work. Existing `OperationRun` and audit behavior on residual surfaces must remain unchanged.
**RBAC**: Existing 404 vs 403 semantics, capability checks, and destructive-action confirmations remain mandatory across the admin, tenant-context admin, and system planes.
**UI Naming**: No new operator-facing vocabulary is introduced; existing action copy remains domain-first and consistent.
**Operator Surfaces**: The affected surfaces are already classified in the spec and must be kept aligned with those classifications during implementation.
**Filament UI Action Surfaces**: This feature governs existing Filament pages and utilities without introducing a new page framework.
**Proportionality / Anti-Bloat**: Keep the implementation to one bounded residual inventory, validator checks, and focused tests. Do not add new persistence, enums, or runtime registries unless the implementation proves they are unavoidable.
## Phase 1: Setup (Shared Review Inputs)
**Purpose**: Confirm the current residual-surface scope and evidence sources before editing the shared support layer.
- [X] T001 Audit the current primary discovery and exemption entry points in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, and `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
- [X] T002 [P] Audit the existing focused evidence sources for residual surfaces in `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php`, `apps/platform/tests/Feature/Auth/TenantChooserSelectionTest.php`, `apps/platform/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, and `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared Spec 195 inventory and validation scaffolding that all user stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Add shared residual-inventory schema assertions for Spec 195 in `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
- [X] T004 [P] Add shared contract-harness expectations for Spec 195 residual inventory consumption in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- [X] T005 Implement the `spec195ResidualSurfaceInventory()` skeleton, required `surfaceName` field, and allowed closure vocabulary in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
- [X] T006 Implement a validator-side residual-candidate boundary helper that preserves the existing primary discovery contract in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
- [X] T007 Extend `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` with reusable Spec 195 schema validation for allowed values, duplicate keys, evidence presence, and truthful discovery state
**Checkpoint**: Shared residual inventory and validator scaffolding are ready; user story work can now proceed.
---
## Phase 3: User Story 1 - Close the residual inventory (Priority: P1) 🎯 MVP
**Goal**: Make every in-scope residual surface appear exactly once with one closure decision, reason category where needed, and explicit evidence.
**Independent Test**: Review the final residual inventory and guard output alone and verify that the initial seed plus any additional audited residual surfaces have exactly one decision, no duplicates, and no missing evidence.
### Tests for User Story 1
- [X] T008 [P] [US1] Add completeness expectations for all Spec 195 residual surface keys in `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
- [X] T009 [P] [US1] Add contract assertions for unique closure decisions, structured evidence descriptors, and baseline-alignment expectations in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Populate the initial seed and any newly audited Spec 195 residual closure entries with `surfaceName`, discovery state, closure decision, reason category, `explicitReason`, structured `evidence`, and `followUpAction` in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
- [X] T011 [US1] Wire Spec 195 residual inventory consumption into `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` so every residual surface is checked exactly once
**Checkpoint**: User Story 1 is complete when the repo has a single complete Spec 195 inventory and its completeness is guard-tested.
---
## Phase 4: User Story 2 - Remove silent system and utility exceptions (Priority: P1)
**Goal**: Explicitly classify the currently uncatalogued system and utility surfaces so dangerous or decision-bearing system pages no longer live outside review discipline.
**Independent Test**: Review the system residual entries plus focused system tests and verify that `ViewRun`, `Runbooks`, `RepairWorkspaceOwners`, `ViewTenant`, and `ViewWorkspace` all have explicit closure states and no silent fallback path.
### Tests for User Story 2
- [X] T012 [P] [US2] Add residual guard coverage for `App\Filament\System\Pages\Ops\ViewRun`, `App\Filament\System\Pages\Ops\Runbooks`, and `App\Filament\System\Pages\RepairWorkspaceOwners` in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
- [X] T013 [P] [US2] Add focused read-mostly closure coverage for `App\Filament\System\Pages\Directory\ViewTenant` and `App\Filament\System\Pages\Directory\ViewWorkspace` in `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`
### Implementation for User Story 2
- [X] T014 [US2] Audit `apps/platform/app/Filament/System/Pages/Ops/ViewRun.php`, `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, and `apps/platform/app/Filament/System/Pages/RepairWorkspaceOwners.php` and, if implementation reality differs from the design seed, update the matching entries in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, and `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
- [X] T015 [US2] Audit `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php` and `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and, if the pages are not truly read-mostly harmless cases, update the matching entries in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, and `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`
- [X] T016 [US2] Tighten `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` so outside-primary-discovery system and utility pages cannot pass without Spec 195 closure data and, if any audited system page is promoted into generic contract enrollment, extend `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php` to enforce inherited contract continuity
**Checkpoint**: User Story 2 is complete when the system/detail residual tail is explicitly classified and validated instead of relying on discovery accidents.
---
## Phase 5: User Story 3 - Keep only justified exemptions (Priority: P2)
**Goal**: Remove stale exemptions and require every remaining discovered exception to carry an explicit reason category and coverage note.
**Independent Test**: Review the discovered-page exemption set alone and verify that every remaining entry has a reason category, explicit reason, structured evidence, and no stale `BreakGlassRecovery` carry-over.
### Tests for User Story 3
- [X] T017 [P] [US3] Add stale-exemption and reason-category failure cases in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
- [X] T018 [P] [US3] Add stale-exemption and baseline-removal assertions for `apps/platform/app/Filament/Pages/BreakGlassRecovery.php` in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 3
- [X] T019 [US3] Audit `apps/platform/app/Filament/Pages/BreakGlassRecovery.php` and either retire it from live baseline handling or keep it as a live `intentional_exemption` with `security_flow_exception` in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, and `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
- [X] T020 [US3] Tighten stale-baseline cleanup rules for retired or reasonless discovered exceptions in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` and `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
- [X] T021 [US3] Extend `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` to reject reasonless discovered exemptions and retired surfaces that still remain in `baseline()`
**Checkpoint**: User Story 3 is complete when every surviving discovered-page exception is explicit, reasoned, and evidence-backed.
---
## Phase 6: User Story 4 - Block future unclassified residuals (Priority: P2)
**Goal**: Fail fast when future residual surfaces or exemptions appear without an explicit closure decision.
**Independent Test**: Introduce representative missing-classification and missing-reason cases in the guard suite and verify CI-style failures occur with actionable output.
### Tests for User Story 4
- [X] T022 [P] [US4] Create regression cases for missing closure decision, missing reason category, missing structured evidence, and stale baseline entries in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
- [X] T023 [P] [US4] Extend `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` with a representative uncatalogued residual-surface failure path
### Implementation for User Story 4
- [X] T024 [US4] Extend `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` with namespace-scoped residual-candidate checks that preserve the existing generic discovery boundary and require Spec 195 inventory coverage for qualifying pages
- [X] T025 [US4] Improve `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` failure output and update `specs/195-action-surface-closure/quickstart.md` so missing Spec 195 classifications report actionable class and file context plus the reviewer classification workflow
**Checkpoint**: User Story 4 is complete when new residual surfaces or exemptions cannot merge silently without a closure decision.
---
## Phase 7: User Story 5 - Preserve good special surfaces without forced churn (Priority: P3)
**Goal**: Keep legitimately special selector, registration, onboarding, landing, and dashboard surfaces outside the generic contract where that is the safer and clearer choice.
**Independent Test**: Review the special-surface entries plus focused tests and verify that selectors, onboarding, registration, landing, and dashboard shells can remain separately governed or harmless without being mislabeled as defects.
### Tests for User Story 5
- [X] T026 [P] [US5] Create focused landing-surface coverage in `apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
- [X] T027 [P] [US5] Extend special-surface evidence coverage with explicit positive and negative authorization paths in `apps/platform/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, and `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
- [X] T028 [P] [US5] Extend selector-surface evidence coverage in `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php` and `apps/platform/tests/Feature/Auth/TenantChooserSelectionTest.php`
### Implementation for User Story 5
- [X] T029 [US5] If audit changes the design-seed classification for `apps/platform/app/Filament/Pages/ChooseWorkspace.php`, `apps/platform/app/Filament/Pages/ChooseTenant.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Tenancy/RegisterTenant.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, or `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, update the matching entry in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` and the paired expectation in `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`
- [X] T030 [US5] Align `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` so discovered special surfaces can remain `separately_governed` or `harmless_special_case` without forced `actionSurfaceDeclaration()` enrollment
**Checkpoint**: User Story 5 is complete when good special surfaces remain explicitly governed without unnecessary normalization.
---
## Phase 8: Polish & Cross-Cutting Verification
**Purpose**: Run the focused verification pack, format the touched files, and confirm the implementation matches the planning contract.
- [X] T031 Run the focused Spec 195 Sail verification pack from `specs/195-action-surface-closure/quickstart.md` against `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`, `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php`, `apps/platform/tests/Feature/Guards/LivewireTrustedStateGuardTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/Auth/BreakGlassWorkspaceOwnerRecoveryTest.php`, `apps/platform/tests/Feature/Auth/TenantChooserSelectionTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceAuditTrailTest.php`, `apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, `apps/platform/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php`, `apps/platform/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php`, `apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingDraftAccessTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `apps/platform/tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php`, `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`, and `apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
- [X] T032 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`, `apps/platform/tests/Feature/Guards/Spec195ResidualActionSurfaceClosureGuardTest.php`, `apps/platform/tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php`, and `apps/platform/tests/Feature/Workspaces/Spec195ManagedTenantsLandingTest.php`
- [X] T033 Verify the final residual inventory matches `specs/195-action-surface-closure/contracts/action-surface-closure.logical.openapi.yaml`, includes human-readable `surfaceName` values, and covers the initial seed plus any newly audited residual surfaces described in `specs/195-action-surface-closure/data-model.md`
---
## Dependencies
- Setup tasks T001-T002 precede all implementation work.
- Foundational tasks T003-T007 block all user stories.
- User Story 1 depends on Phase 2 and unlocks the actual residual inventory.
- User Story 2, User Story 3, and User Story 5 depend on User Story 1 because they classify concrete residual entries inside the shared inventory.
- User Story 4 depends on User Story 1, User Story 2, User Story 3, and User Story 5 because the regression guard must validate the final closure state.
- Polish tasks T031-T033 depend on all user stories being complete.
## Parallel Execution Examples
- After T001, run T002 in parallel with any remaining setup review.
- In Phase 2, T003 and T004 can run in parallel before T005-T007 land.
- In User Story 1, T008 and T009 can run in parallel.
- In User Story 2, T012 and T013 can run in parallel.
- In User Story 3, T017 and T018 can run in parallel.
- In User Story 4, T022 and T023 can run in parallel.
- In User Story 5, T026, T027, and T028 can run in parallel.
## Implementation Strategy
- Start with Phase 2 and User Story 1 to establish the residual inventory and validator contract.
- Deliver User Story 2 next to close the highest-risk system and utility surfaces first.
- Follow with User Story 3 and User Story 5 to normalize discovered exceptions and preserve legitimate special workflows.
- Complete User Story 4 only after the closure states are finalized so the new guard enforces the finished model instead of a moving target.
- Finish with the focused Sail verification pack and Pint formatting from Phase 8.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Hard Filament Nativity Cleanup
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-13
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated after initial draft on 2026-04-13.
- Framework-specific language appears only where the feature itself and constitution require naming the native admin contract; the spec does not prescribe code-level implementation choices, new abstractions, or dependency changes.
- No clarification questions were required from the user because scope, non-goals, and acceptance expectations were already explicit.

View File

@ -0,0 +1,395 @@
openapi: 3.1.0
info:
title: Filament Nativity Cleanup Logical Contract
version: 0.1.0
description: >-
Logical planning contract for Spec 196. This artifact defines the expected
state ownership, filter semantics, scope guarantees, and row projections for
the three cleaned UI surfaces. It is not a runtime API definition.
servers:
- url: https://logical-spec.local
description: Non-runtime planning contract
paths:
/internal/ui/inventory-items/{inventoryItemId}/dependencies:
get:
summary: Read dependency section state for one inventory item detail surface
operationId: getInventoryItemDependenciesView
parameters:
- name: inventoryItemId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Dependency detail-surface state and rows
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
$ref: '#/components/schemas/DependencyEdgesView'
'404':
description: Returned when the actor is not entitled to the tenant or inventory-item scope.
/internal/ui/tenants/{tenantExternalId}/required-permissions:
get:
summary: Read required-permissions page state for one route-scoped tenant
operationId: getTenantRequiredPermissionsView
parameters:
- name: tenantExternalId
in: path
required: true
schema:
type: string
- name: status
in: query
required: false
schema:
$ref: '#/components/schemas/RequiredPermissionsStatus'
- name: type
in: query
required: false
schema:
$ref: '#/components/schemas/PermissionTypeFilter'
- name: features
in: query
required: false
schema:
type: array
items:
type: string
- name: search
in: query
required: false
schema:
type: string
responses:
'200':
description: Required-permissions page state, summary, and rows
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
$ref: '#/components/schemas/RequiredPermissionsView'
'404':
description: Returned when workspace or tenant membership is absent for the route-scoped tenant.
/internal/ui/evidence-overview:
get:
summary: Read workspace evidence overview table state and rows
operationId: getEvidenceOverviewView
parameters:
- name: tenantId
in: query
required: false
description: Optional entitled tenant prefilter; unauthorized tenant identifiers must not reveal row existence.
schema:
anyOf:
- type: integer
- type: 'null'
- name: search
in: query
required: false
schema:
type: string
responses:
'200':
description: Workspace evidence overview state and rows
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
$ref: '#/components/schemas/EvidenceOverviewView'
'404':
description: Returned when workspace membership is absent for the evidence overview surface.
components:
schemas:
DependencyDirection:
type: string
enum:
- all
- inbound
- outbound
RelationshipTypeKey:
type: string
description: Recognized relationship type key from the existing dependency domain.
RequiredPermissionsStatus:
type: string
enum:
- missing
- present
- error
- all
PermissionTypeFilter:
type: string
enum:
- all
- application
- delegated
DependencyEdgesState:
type: object
required:
- inventoryItemId
- tenantId
- direction
properties:
inventoryItemId:
type: integer
tenantId:
type: integer
direction:
$ref: '#/components/schemas/DependencyDirection'
relationshipType:
anyOf:
- $ref: '#/components/schemas/RelationshipTypeKey'
- type: 'null'
DependencyEdgeRow:
type: object
required:
- relationshipType
- targetType
- renderedTarget
- isMissing
- missingTitle
properties:
relationshipType:
type: string
targetType:
type: string
targetId:
anyOf:
- type: string
- type: 'null'
renderedTarget:
type: object
additionalProperties: true
isMissing:
type: boolean
missingTitle:
type: string
DependencyEdgesView:
type: object
required:
- state
- rows
properties:
state:
$ref: '#/components/schemas/DependencyEdgesState'
rows:
type: array
items:
$ref: '#/components/schemas/DependencyEdgeRow'
RequiredPermissionsState:
type: object
required:
- routeTenantExternalId
- status
- type
- features
- search
- routeTenantAuthoritative
- seededFromQuery
properties:
routeTenantExternalId:
type: string
status:
$ref: '#/components/schemas/RequiredPermissionsStatus'
type:
$ref: '#/components/schemas/PermissionTypeFilter'
features:
type: array
uniqueItems: true
description: Normalized unique list of known feature keys.
items:
type: string
search:
type: string
routeTenantAuthoritative:
type: boolean
const: true
seededFromQuery:
type: boolean
RequiredPermissionsSummary:
type: object
required:
- counts
- freshness
- featureImpacts
- copyPayloads
- issues
properties:
counts:
type: object
additionalProperties:
type: integer
overall:
anyOf:
- type: string
- type: 'null'
freshness:
type: object
additionalProperties: true
featureImpacts:
type: array
items:
type: object
additionalProperties: true
copyPayloads:
type: object
additionalProperties:
type: string
issues:
type: array
items:
type: object
additionalProperties: true
PermissionReviewRow:
type: object
required:
- permissionKey
- type
- status
properties:
permissionKey:
type: string
type:
type: string
status:
type: string
description:
type: string
features:
type: array
items:
type: string
details:
type: object
additionalProperties: true
RequiredPermissionsView:
type: object
required:
- state
- summary
- rows
properties:
state:
$ref: '#/components/schemas/RequiredPermissionsState'
summary:
$ref: '#/components/schemas/RequiredPermissionsSummary'
rows:
type: array
items:
$ref: '#/components/schemas/PermissionReviewRow'
EvidenceOverviewState:
type: object
required:
- workspaceId
- authorizedTenantIds
- tenantFilter
- search
- seededFromQuery
properties:
workspaceId:
type: integer
authorizedTenantIds:
type: array
items:
type: integer
tenantFilter:
anyOf:
- type: integer
- type: 'null'
search:
type: string
seededFromQuery:
type: boolean
EvidenceOverviewRow:
type: object
required:
- tenantId
- tenantName
- snapshotId
- artifactTruth
- freshness
- missingDimensions
- staleDimensions
- nextStep
- viewUrl
properties:
tenantId:
type: integer
tenantName:
type: string
snapshotId:
type: integer
artifactTruth:
type: object
additionalProperties: true
freshness:
type: object
additionalProperties: true
generatedAt:
anyOf:
- type: string
- type: 'null'
missingDimensions:
type: integer
staleDimensions:
type: integer
nextStep:
type: string
viewUrl:
type: string
EvidenceOverviewView:
type: object
required:
- state
- rows
properties:
state:
$ref: '#/components/schemas/EvidenceOverviewState'
rows:
type: array
items:
$ref: '#/components/schemas/EvidenceOverviewRow'
x-spec-196-notes:
consumerScope: illustrative core consumers only; Blade views and focused verification files are tracked in plan.md, quickstart.md, and tasks.md
consumers:
- apps/platform/app/Filament/Resources/InventoryItemResource.php
- apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
- apps/platform/app/Filament/Pages/TenantRequiredPermissions.php
- apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php
- apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
- apps/platform/tests/Feature/InventoryItemDependenciesTest.php
- apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php
- apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php
invariants:
- route tenant stays authoritative on required-permissions
- evidence overview only exposes entitled tenant rows
- dependency rendering remains tenant-isolated and DB-only
- query values may seed initial state but not stay the primary contract
nonGoals:
- runtime API exposure
- new persistence
- new provider or route families
- global context shell redesign
- monitoring page-state architecture rewrite
- audit log selected-record or inspect duality cleanup
- finding exceptions queue dual-inspect cleanup
- baseline compare matrix or other special-visualization work
- verification report viewer families or onboarding verification report variants
- normalized diff or settings viewer families
- restore preview, restore results, or enterprise-detail layout rework
- raw anchor-to-component link consistency sweeps
- badge-only, banner-only, or style-only polish work
- new CI guardrail, review-enforcement, or constitution frameworks

View File

@ -0,0 +1,212 @@
# Data Model: Hard Filament Nativity Cleanup
## Overview
This feature introduces no new persisted entity, table, enum, or product-domain source of truth. It refactors three existing UI surfaces by replacing pseudo-native interaction contracts with native page-owned or component-owned state.
The data model for planning is therefore a set of derived UI-state and row-projection models that answer four questions:
1. What state is authoritative for each cleaned surface?
2. Which source truths continue to produce the rows and summaries?
3. Which values may be seeded from deeplinks, and which values must remain route- or entitlement-authoritative?
4. Which invariants must remain true after the cleanup?
## Existing Source Truths Reused Without Change
The following truths remain authoritative and are not redefined by this feature:
- `InventoryItem`, `InventoryLink`, `DependencyQueryService`, and `DependencyTargetResolver` for dependency edges and rendered targets
- the current tenant-context inventory route and inventory-record scope rules
- `TenantRequiredPermissionsViewModelBuilder`, `TenantPermission`, permission configuration, and provider guidance links for required-permissions truth
- the route-scoped tenant on `/admin/tenants/{tenant:external_id}/required-permissions`
- `EvidenceSnapshot`, `TenantReview`, `ArtifactTruthPresenter`, and the current workspace-context entitlement rules for evidence overview rows
- existing capability registries, `WorkspaceContext`, tenant membership checks, and current deny-as-not-found boundaries
This feature changes how these truths are controlled and rendered, not what they mean.
## New Derived Planning Models
### DependencyEdgesTableState
**Type**: embedded detail-surface state
**Source**: Livewire component state on inventory item detail
| Field | Type | Notes |
|------|------|-------|
| `inventoryItemId` | int | Required current detail record key |
| `tenantId` | int | Required tenant-context key derived from the current panel or record scope |
| `direction` | string | Allowed values: `all`, `inbound`, `outbound`; default `all` |
| `relationshipType` | string or null | Null means all relationship types; otherwise one allowed relationship type key |
**Validation rules**
- `inventoryItemId` must resolve to the current authorized record.
- `tenantId` must match the current tenant-context scope.
- `direction` must stay inside the three allowed values.
- `relationshipType` must be null or a recognized relationship type value.
### DependencyEdgeRow
**Type**: derived row projection
**Source**: `DependencyQueryService` plus `DependencyTargetResolver`
| Field | Type | Notes |
|------|------|-------|
| `relationshipType` | string | Canonical relationship family for grouping or filter matching |
| `targetType` | string | Current target kind, including `missing` when unresolved |
| `targetId` | string or null | External or internal target identifier |
| `renderedTarget` | array | Existing rendered badge and link payload |
| `isMissing` | boolean | Derived from `targetType === missing` |
| `missingTitle` | string | Existing descriptive fallback text for unresolved targets |
**Invariants**
- Row membership must stay tenant-isolated.
- Missing-target rendering must preserve current operator hints.
- Render-time behavior must remain DB-only with no Graph access.
### RequiredPermissionsTableState
**Type**: page-owned derived table state
**Source**: native Filament table filters and search on `TenantRequiredPermissions`
| Field | Type | Notes |
|------|------|-------|
| `routeTenantExternalId` | string | Authoritative tenant scope from the route |
| `status` | string | Allowed values: `missing`, `present`, `error`, `all` |
| `type` | string | Allowed values: `all`, `application`, `delegated` |
| `features` | list<string> | Zero or more selected feature keys |
| `search` | string | Native table search text |
| `seededFromQuery` | boolean | True only during initial mount when deeplink values were present |
**Validation rules**
- The route tenant always wins over tenant-like query values.
- Query values may seed `status`, `type`, `features`, and `search` only at initial mount.
- `features` must be a normalized unique list of known feature keys.
### RequiredPermissionsSummaryProjection
**Type**: derived page summary model
**Source**: `TenantRequiredPermissionsViewModelBuilder` evaluated against the currently active normalized filter state
| Field | Type | Notes |
|------|------|-------|
| `counts` | object | Existing counts for missing application, missing delegated, present, and error rows |
| `overall` | string or null | Existing overall readiness state |
| `freshness` | object | Existing freshness payload including stale or not stale |
| `featureImpacts` | list<object> | Existing per-feature impact summary |
| `copyPayloads` | object | Existing application and delegated copy payloads |
| `issues` | list<object> | Existing derived guidance and next-step content |
**Invariants**
- Summary and table rows must be derived from the same active filter state.
- Copy payload semantics must remain consistent with current expectations.
- Tenant scope must not be mutable through filter state.
### PermissionReviewRow
**Type**: derived table row
**Source**: `TenantRequiredPermissionsViewModelBuilder`
| Field | Type | Notes |
|------|------|-------|
| `permissionKey` | string | Stable permission identifier |
| `type` | string | `application` or `delegated` |
| `status` | string | Current permission review status |
| `description` | string | Human-readable permission description |
| `features` | list<string> | Feature tags associated with the permission |
| `details` | object | Existing supporting metadata used for inline review only |
### EvidenceOverviewTableState
**Type**: workspace-context table state
**Source**: native Filament table search and optional query-seeded entitled tenant prefilter
| Field | Type | Notes |
|------|------|-------|
| `workspaceId` | int | Required current workspace context |
| `authorizedTenantIds` | list<int> | Entitled tenant ids available to the actor |
| `tenantFilter` | int or null | Current entitled tenant prefilter, nullable when not active |
| `search` | string | Native table search across tenant-facing row labels |
| `seededFromQuery` | boolean | True only when the initial request carried a prefilter |
**Validation rules**
- `tenantFilter` must be null or one of the actor's entitled tenant ids.
- Missing workspace membership continues to produce `404`.
- Non-entitled tenant ids must not leak through filter state, row counts, or drilldowns.
### EvidenceOverviewRow
**Type**: derived workspace report row
**Source**: current snapshot query plus `ArtifactTruthPresenter`
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | int | Entitled tenant identifier |
| `tenantName` | string | Current display label |
| `snapshotId` | int | Current active snapshot id for drilldown |
| `artifactTruth` | object | Existing truth badge and explanation payload |
| `freshness` | object | Existing freshness badge payload |
| `generatedAt` | string or null | Timestamp label |
| `missingDimensions` | int | Existing burden metric |
| `staleDimensions` | int | Existing burden metric |
| `nextStep` | string | Existing next-step text |
| `viewUrl` | string | Current tenant evidence drilldown URL |
**Invariants**
- Row drilldowns must stay workspace-safe and tenant-entitlement-safe.
- Derived-state memoization must remain effective.
- Render-time behavior must remain DB-only.
### CleanupAdmissionCandidate
**Type**: planning-only admission check
**Source**: implementation audit only when a possible extra hit is discovered
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Stable human-readable identifier |
| `path` | string | File or route path for the potential extra surface |
| `matchesProblemClass` | boolean | Must be true to qualify |
| `opensArchitectureQuestion` | boolean | Must be false to qualify |
| `decision` | string | `include` or `defer` |
| `reason` | string | Explicit justification for the decision |
## State Transition Rules
### Rule 1 - Deeplink seed to native active state
- Initial request query values may seed filter state on `TenantRequiredPermissions` and `EvidenceOverview`.
- After initial mount, active state belongs to the native page table or component, not to `request()`.
### Rule 2 - Route scope remains authoritative
- `TenantRequiredPermissions` may never replace its route tenant from query values.
- Inventory dependency state may never replace the current detail record or tenant context.
- Evidence overview may never reveal non-entitled tenant rows through a prefilter.
### Rule 3 - No new persistence or mirrored helper truth
- Filter state stays session-backed or Livewire-backed only where Filament already provides that behavior.
- No new database table, JSON helper artifact, or persisted UI-state mirror is introduced.
## Safety Rules
- No cleaned surface may introduce a second wrapper contract that simply restyles the current non-native behavior.
- No cleaned surface may widen current workspace or tenant scope behavior.
- No cleaned surface may lose current empty-state meaning, next-step clarity, or inspect destination correctness.
- No page or component may call Graph or other remote APIs during render as part of this cleanup.
## Planned Test Mapping
| Model / Rule | Existing Coverage | Planned Additions |
|---|---|---|
| `DependencyEdgesTableState` | `tests/Feature/InventoryItemDependenciesTest.php`, dependency tenant-isolation and query-service tests | native component test for direction and relationship interaction |
| `RequiredPermissionsTableState` | `tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, unit filter normalization tests | page-level native table test |
| `RequiredPermissionsSummaryProjection` | current unit tests for freshness, overall state, feature impacts, and copy payloads | page-level summary consistency assertions |
| `EvidenceOverviewTableState` | `tests/Feature/Evidence/EvidenceOverviewPageTest.php` | native table assertions and any new table-standard guard alignment |
| `EvidenceOverviewRow` DB-only invariant | `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` | update assertions to reflect native table rendering without losing memoization guarantees |

View File

@ -0,0 +1,296 @@
# Implementation Plan: Hard Filament Nativity Cleanup
**Branch**: `196-hard-filament-nativity-cleanup` | **Date**: 2026-04-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/spec.md`
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 page layer, the current derived view-model services, the existing dependency query and target-resolution services, and the current focused RBAC and reporting tests. It explicitly avoids adding a new runtime UI framework, new persistence, or a broader shell or monitoring-state architecture.
## Summary
Remove the three hard nativity bypasses called out by Spec 196 by reusing repo-proven native Filament patterns. Convert `EvidenceOverview` and `TenantRequiredPermissions` into page-owned native table surfaces with native filter state and unchanged scope semantics. Replace the GET-form dependency micro-UI on inventory item detail with an embedded Livewire table component that owns direction and relationship state inside the current detail surface. Preserve existing domain truth, authorization, empty states, and drilldowns, and prove the cleanup through focused feature, Livewire, RBAC, and Filament guard coverage.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers
**Storage**: PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned
**Testing**: Pest feature, Livewire, unit, and existing guard tests run through Laravel Sail; browser smoke only if an implementation detail proves impossible to cover with existing feature or Livewire layers
**Target Platform**: Laravel monolith web application under `apps/platform`, spanning tenant-context admin routes under `/admin/t/{tenant}/...`, tenant-specific admin routes under `/admin/tenants/{tenant:external_id}/...`, and workspace-context canonical admin routes under `/admin/...`
**Project Type**: web application
**Performance Goals**: Preserve DB-only render behavior, keep dependency and evidence rendering free of Graph calls, avoid request-reload control flows, preserve current row-count and summary derivation cost, and avoid introducing extra persistence or polling
**Constraints**: No new persistence, no new enum or status family, no new wrapper microframework, no global shell or monitoring-state refactor, no provider or panel registration changes, no weakening of current 404 or 403 semantics, no destructive-action expansion, and no new asset pipeline work
**Scale/Scope**: 3 core surfaces, 1 embedded tenant detail micro-surface, 1 tenant workflow page, 1 workspace report page, and a focused verification pack touching roughly 12 existing or new test files; optional extra hits are allowed only if no new architecture question opens
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Inventory dependencies and evidence overview remain read-only views over existing inventory and evidence truth. |
| Read/write separation | PASS | PASS | The cleanup changes interaction contracts only. Existing follow-up writes remain on their current confirmed destinations. |
| Graph contract path | N/A | N/A | No new Graph calls or contract-registry changes are introduced. |
| Deterministic capabilities | PASS | PASS | Existing capability registries, tenant access checks, and page authorization remain authoritative. |
| Workspace + tenant isolation | PASS | PASS | Tenant required permissions keeps the route tenant authoritative; evidence overview keeps workspace-context entitlement filtering; inventory detail remains tenant-context scoped. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, in-scope capability denial remains unchanged, and no new mutation path bypasses server-side authorization. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` flow is introduced. Existing run-linked destinations remain unchanged. |
| Data minimization | PASS | PASS | No new persisted UI-state mirror or helper artifact is added, and DB-only rendering remains required. |
| Proportionality / anti-bloat | PASS | PASS | The design reuses existing Filament patterns and adds no new persistence or generic UI layer. |
| UI semantics / few layers | PASS | PASS | The plan maps directly from current domain truth to native UI primitives without a new presenter framework. |
| Filament-native UI | PASS | PASS | All three target surfaces move toward native Filament tables, filters, or shared primitives and away from pseudo-native contracts. |
| Surface taxonomy / decision-first roles | PASS | PASS | Inventory dependencies remains a secondary context sub-surface; tenant required permissions and evidence overview remain primary decision surfaces. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain inside the current Filament v5 + Livewire v4 stack. |
| Provider registration location | PASS | PASS | No provider changes are required; Laravel 11+ provider registration remains in `apps/platform/bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No searchable resource is added or modified. `TenantRequiredPermissions` and `EvidenceOverview` are pages, and inventory resource search behavior is unchanged. |
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing destructive follow-up actions remain on their current confirmed surfaces. |
| Asset strategy | PASS | PASS | No new global or on-demand assets are required. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The implementation remains entirely inside Filament v5 + Livewire v4 and does not introduce legacy Filament or Livewire APIs.
- **Provider registration location**: No provider changes are required; panel providers remain registered in `apps/platform/bootstrap/providers.php`.
- **Global search**: No resource search behavior changes. `InventoryItemResource` already has a view page, but this spec does not change its global-search status. `TenantRequiredPermissions` and `EvidenceOverview` remain pages, not searchable resources.
- **Destructive actions**: No new destructive actions are added. Existing linked destinations retain their current confirmation and authorization behavior.
- **Asset strategy**: No new assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient and unchanged.
- **Testing plan**: Cover the cleanup through `InventoryItemDependenciesTest`, a new Livewire or table-component dependency test, `TenantRequiredPermissionsTrustedStateTest`, a new required-permissions page-table test, `EvidenceOverviewPageTest`, `EvidenceOverviewDerivedStateMemoizationTest`, and guard coverage such as `FilamentTableStandardsGuardTest` where native table adoption becomes guardable.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/research.md`.
Key decisions:
- Reuse the repo's existing native page-table pattern from `ReviewRegister` and `InventoryCoverage` for `TenantRequiredPermissions` and `EvidenceOverview`.
- Keep `TenantRequiredPermissions` and `EvidenceOverview` on derived data and current services instead of adding new projections, tables, or materialized helper models.
- Replace inventory dependency GET-form controls with an embedded Livewire `TableComponent` because the surface is detail-context and not a true relation manager or a standalone page.
- Treat query parameters as one-time seed or deeplink inputs only; after mount, native page or component state owns filter interaction.
- No additional low-risk same-class hit is confirmed in planning; default implementation scope stays at the three named core surfaces unless implementation audit finds one trivial match that does not widen scope.
- Extend existing focused tests and the current Filament table guard where possible instead of introducing a new browser-only verification layer.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/`:
- `research.md`: implementation-shape decisions and rejected alternatives for each surface
- `data-model.md`: derived UI-state and row-projection models for dependency scope, required-permissions filtering, and evidence overview rows
- `contracts/filament-nativity-cleanup.logical.openapi.yaml`: internal logical contract for page state, derived rows, scope rules, and deeplink semantics
- `quickstart.md`: implementation and verification sequence for the feature
Design highlights:
- `EvidenceOverview` adopts `InteractsWithTable` + `HasTable` and keeps derived rows via a records callback similar to `InventoryCoverage`.
- `TenantRequiredPermissions` adopts a native table and native table-owned filter state while keeping summary, copy, and guidance sections above the table body.
- Inventory dependencies stays embedded on inventory detail but moves its interactive controls into a dedicated Livewire table component rather than a request-driven Blade fragment.
- Existing domain services stay authoritative: dependency rows still come from `DependencyQueryService` and `DependencyTargetResolver`; permission truth still comes from `TenantRequiredPermissionsViewModelBuilder` when an adapter is needed; evidence truth still comes from `ArtifactTruthPresenter` and current snapshot queries.
- No new schema, enum, or shared microframework is introduced.
## Project Structure
### Documentation (this feature)
```text
specs/196-hard-filament-nativity-cleanup/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── contracts/
│ └── filament-nativity-cleanup.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── TenantRequiredPermissions.php # MODIFY
│ │ │ └── Monitoring/
│ │ │ └── EvidenceOverview.php # MODIFY
│ │ └── Resources/
│ │ └── InventoryItemResource.php # MODIFY
│ ├── Livewire/
│ │ └── InventoryItemDependencyEdgesTable.php # NEW
│ └── Services/
│ └── Intune/
│ └── TenantRequiredPermissionsViewModelBuilder.php # MODIFY or REVIEW FOR ADAPTERS
├── resources/
│ └── views/
│ └── filament/
│ ├── components/
│ │ └── dependency-edges.blade.php # MODIFY
│ └── pages/
│ ├── tenant-required-permissions.blade.php # MODIFY
│ └── monitoring/
│ └── evidence-overview.blade.php # MODIFY
└── tests/
├── Feature/
│ ├── InventoryItemDependenciesTest.php # MODIFY
│ ├── Evidence/
│ │ └── EvidenceOverviewPageTest.php # MODIFY
│ ├── Filament/
│ │ ├── EvidenceOverviewDerivedStateMemoizationTest.php # MODIFY
│ │ ├── InventoryItemDependencyEdgesTableTest.php # NEW
│ │ └── TenantRequiredPermissionsPageTest.php # NEW
│ ├── Guards/
│ │ └── FilamentTableStandardsGuardTest.php # MODIFY
│ └── Rbac/
│ └── TenantRequiredPermissionsTrustedStateTest.php # MODIFY
└── Unit/
├── TenantRequiredPermissionsFilteringTest.php # REUSE
├── TenantRequiredPermissionsCopyPayloadTest.php # REUSE
├── TenantRequiredPermissionsOverallStatusTest.php # REUSE
├── TenantRequiredPermissionsFeatureImpactTest.php # REUSE
└── TenantRequiredPermissionsFreshnessTest.php # REUSE
```
**Structure Decision**: Keep the work entirely inside the existing Laravel/Filament monolith under `apps/platform`. Add at most one new Livewire table component for the dependency sub-surface, then modify the three target page or resource files and focused tests. Do not add a new service layer, persistence shape, or cross-surface UI abstraction.
## Complexity Tracking
No constitution violation or BLOAT-triggered structural expansion is planned. The feature deliberately avoids new persistence, new enums, new UI taxonomies, or new cross-page infrastructure.
## Proportionality Review
Not triggered beyond the spec-level review already completed. The implementation plan adds no new enum, presenter framework, persisted entity, or registry. The narrowest correct implementation is to reuse native Filament tables and one embedded `TableComponent`.
## Implementation Strategy
Execution sequence for this plan is test-first at two levels: complete the shared test and guard scaffolding before story work starts, then land each story's focused tests before its implementation changes.
### Phase 0.5 - Establish shared test and guard scaffolding
Goal: create the blocking Spec 196 test entry points and shared guard coverage before surface refactors begin.
Changes:
- Create the new focused test entry points for the dependency table component and required-permissions page table.
- Extend shared guard coverage for new native page-table expectations and faux-control regressions.
- Add shared regression coverage for mount-only query seeding versus authoritative scope on required permissions and evidence overview.
Tests:
- This phase establishes the focused test harness and is itself the blocking prerequisite for later story delivery.
### Phase A - Replace the inventory dependency GET form with an embedded Livewire table component
Goal: keep the dependencies surface on inventory item detail, but move direction and relationship controls into native component state instead of a request-driven Blade fragment.
Changes:
- Introduce `App\Livewire\InventoryItemDependencyEdgesTable` as a Filament `TableComponent` that owns direction and relationship filter state.
- Keep the surface embedded in the current `InventoryItemResource` detail section rather than moving it to a standalone route or relation manager.
- Move the current request-query dependency fetch into the component so the Blade fragment no longer parses `request()` or submits a GET form.
- Preserve existing target rendering, missing-target labels, and tenant-isolated dependency resolution through `DependencyQueryService` and `DependencyTargetResolver`.
- Keep render-time behavior DB-only and preserve the no-Graph-call guard.
Tests:
- Extend the listed story tests before landing implementation changes.
- Modify `tests/Feature/InventoryItemDependenciesTest.php` to assert the preserved result logic while removing dependence on manual query-string filter submission.
- Add `tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` to cover direction changes, relationship narrowing, missing-target rendering, and tenant isolation through the native component.
- Reuse existing unit and feature tests around `DependencyQueryService`, `DependencyTargetResolver`, and tenant isolation as domain and safety regression coverage.
### Phase B - Convert `TenantRequiredPermissions` into a native page-owned table and filter contract
Goal: remove pseudo-native filter controls while preserving the page's summary, guidance, copy payloads, and tenant-authoritative routing semantics.
Changes:
- Add `HasTable` and `InteractsWithTable` to `App\Filament\Pages\TenantRequiredPermissions`.
- Replace the manual public filter properties and `updated*()` handlers with native table filters and native table search, using a derived-records callback because permission rows are view-model based rather than Eloquent-backed.
- Keep the route tenant authoritative and allow query parameters only to seed initial filter state when the page first mounts.
- Keep the summary, copy, and guidance blocks, but derive their values from the same normalized filter state that drives the native table rows.
- Preserve the current behavior where copy payloads remain driven by the intended filter dimensions and do not silently widen tenant scope.
Tests:
- Extend the listed story tests before landing implementation changes.
- Modify `tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` to keep route-tenant authority and safe deeplink behavior after native filter adoption.
- Add `tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` to cover native filter behavior, summary consistency, and no-results states.
- Reuse current unit tests for filtering, freshness, feature impacts, overall status, and copy payload derivation as unchanged domain-truth guards.
- Extend `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` if the page becomes subject to shared page-table standards.
### Phase C - Convert `EvidenceOverview` into a native workspace table
Goal: remove the hand-built report table and make filtering, empty state, and row inspection native without changing workspace-safe scope behavior.
Changes:
- Add `HasTable` and `InteractsWithTable` to `App\Filament\Pages\Monitoring\EvidenceOverview`.
- Move row generation out of the Blade table contract and into a native table records callback, following the derived-row pattern already used by `InventoryCoverage`.
- Convert the current `tenantFilter` query handling into native filter state seeded from an entitled tenant prefilter only.
- Add native table search across tenant-facing row labels.
- Keep the existing row inspect destination to tenant evidence detail through a single native inspect model.
- Replace the Blade table markup with a page wrapper that renders the native table and keeps any lightweight surrounding layout only if still needed.
Tests:
- Extend the listed story tests before landing implementation changes.
- Modify `tests/Feature/Evidence/EvidenceOverviewPageTest.php` to assert native table output, native search behavior, workspace safety, entitled-tenant filtering, and current drilldowns.
- Modify `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` to keep DB-only derived-state guarantees after table conversion.
- Extend `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` if the new page-owned table should now satisfy shared table standards.
### Phase D - Verification, guard alignment, and explicit scope stop
Goal: confirm the cleanup remains bounded to the three core surfaces and that the repo's existing guard layer reflects newly native table surfaces where appropriate.
Changes:
- Extend guard coverage only where native table adoption now makes a page eligible for existing table standards.
- Run focused Sail verification for the modified feature, RBAC, and guard tests.
- Record the release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md`, including cleaned surfaces, deferred themes, optional extra hits, and touched follow-up specs.
- Document any optional additional same-class hit only if it was truly included; otherwise record that no extra candidate was confirmed.
- Stop immediately if implementation reaches shared micro-UI family, monitoring-state, or shell-context architecture.
Tests:
- Focused feature and Livewire test pack for the three surfaces.
- Existing RBAC and derived-state regression tests retained.
- Pint run after touched files are complete.
## Risk Assessment
### Risk 1 - Scope creep into shared monitoring or detail-micro-UI architecture
Mitigation:
- Keep `EvidenceOverview` limited to native table conversion, not broader monitoring-shell cleanup.
- Keep inventory dependencies embedded on the existing detail page and do not generalize a new micro-UI framework.
- Reject any additional surface that opens shared-family or shell questions.
### Risk 2 - Deeplink or initial-state regressions on required permissions and evidence overview
Mitigation:
- Treat query values strictly as initial seed state.
- Keep route tenant and entitled tenant scope authoritative.
- Preserve and extend current trusted-state tests.
### Risk 3 - Derived-data performance or DB-only regressions after native table adoption
Mitigation:
- Reuse the repo's existing derived-records page pattern from `InventoryCoverage`.
- Preserve current eager-loading and memoization behavior.
- Keep the current no-Graph and DB-only tests in the verification pack.
### Risk 4 - Over-correcting custom read-only rendering into an unnecessary generic surface
Mitigation:
- Keep only the controls and state contract native.
- Allow custom read-only cell or row presentation to remain where it carries real domain value.
- Avoid relation-manager or standalone-page moves for the dependency section.
## Implementation Order Recommendation
1. Establish the shared test and guard scaffolding first so story work starts from the same blocking regression baseline captured in the task plan.
2. Replace inventory dependencies second, with the focused story tests landing before the implementation changes.
3. Convert `TenantRequiredPermissions` third, again extending the story tests before code changes.
4. Convert `EvidenceOverview` fourth, with its focused page and derived-state tests updated before the refactor lands.
5. Run the final focused verification pack, formatting, and release close-out last, and only then consider whether any optional same-class extra hit truly qualifies.

View File

@ -0,0 +1,165 @@
# Quickstart: Hard Filament Nativity Cleanup
## Goal
Implement Spec 196 by replacing three pseudo-native UI contracts with native Filament or Livewire interaction models while preserving current scope, summaries, empty states, and drilldown behavior.
## Implementation Sequence
### 1. Prepare shared test and guard scaffolding
Touch:
- `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`
- `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
- `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`
- `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
Do:
- create the new focused surface-test entry points before story implementation starts
- add the shared guard expectations for new native page-table and faux-control regressions
- add the shared mount-only query-seeding regression coverage that later story work depends on
### 2. Replace the inventory dependency GET form with an embedded `TableComponent`
Touch:
- `apps/platform/app/Filament/Resources/InventoryItemResource.php`
- `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php`
- `apps/platform/resources/views/filament/components/dependency-edges.blade.php`
Do:
- extend the focused dependency tests before landing implementation changes
- embed a native Filament `TableComponent` inside the existing inventory detail section
- move direction and relationship state into the component
- fetch dependency rows through current dependency services
- keep missing-target rendering and target-link behavior intact
Do not:
- create a new standalone route for dependencies
- convert the surface into a RelationManager
- keep `request()` as the primary interaction-state source
### 3. Convert `TenantRequiredPermissions` to a native page-owned filter and table contract
Touch:
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
- `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`
- `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` only if a small adapter is needed
Do:
- extend the focused required-permissions tests before landing implementation changes
- add `HasTable` and `InteractsWithTable`
- replace pseudo-native filter controls with native filters and native search
- derive the summary, guidance, and copy payload blocks from the same normalized filter state that drives the table rows
- keep the route tenant authoritative and allow query values only as initial seed state
Do not:
- let query values redefine tenant scope
- split the page into a new resource or standalone workflow
- introduce a wrapper abstraction that merely hides the old filter bar
### 4. Convert `EvidenceOverview` to a native page-owned table
Touch:
- `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
- `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`
Do:
- extend the focused evidence overview tests before landing implementation changes
- add `HasTable` and `InteractsWithTable`
- move current row construction into a native table records callback
- convert the current tenant query prefilter into a native filter seeded from entitled query input only
- add native search across tenant-facing row labels
- keep row inspect behavior pointed at the existing tenant evidence drilldown
- keep empty-state behavior explicit and native
Do not:
- introduce a new read model or persistence layer
- widen the workspace-context route into a tenant-context route
- make remote calls during render
### 5. Run the final focused verification pack and formatting
Touch:
- `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`
- `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`
- `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
- `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`
- `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`
- `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` if newly applicable
Do:
- preserve current scope and authorization assertions
- replace GET-form assumptions with native Livewire or table-state assertions
- keep DB-only and no-Graph render guarantees
- keep unit tests for permission filtering and copy payload logic as domain-truth guards
- run the full focused Sail pack and `pint` only after the three story slices are complete
### 6. Stop on scope boundaries
If implementation touches any of the following, stop and defer instead of half-solving them here:
- shared detail micro-UI contract work
- monitoring page-state architecture
- global context shell behavior
- verification report viewer families
- diff, settings, restore preview, or enterprise-detail layout families
### 7. Record the release close-out in this quickstart
When implementation is complete, update this file with a short close-out note that records:
- which surfaces were actually cleaned
- whether any optional same-class extra hit was included or explicitly rejected
- which related themes stayed out of scope and were deferred
- which follow-up specs or artifacts were touched
## Suggested Test Pack
Run the minimum targeted verification pack through Sail.
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRequiredPermissionsPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceOverviewPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/FilamentTableStandardsGuardTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFilteringTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsOverallStatusTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/TenantRequiredPermissionsFreshnessTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Smoke Checklist
1. Open an inventory item detail page and confirm dependency direction and relationship changes happen without a foreign apply-and-reload workflow.
2. Open tenant required permissions and confirm the filter surface feels native, while summary counts, guidance, and copy flows remain correct.
3. Open evidence overview and confirm the table behaves like a native Filament report with clear empty state and row inspect behavior.
4. Confirm no cleaned surface leaks scope through query manipulation.
5. Confirm no implementation expanded into monitoring-state, shell, or shared micro-UI redesign work.
## Deployment Notes
- No migration is expected.
- No provider registration change is expected.
- No new assets are expected.
- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged.

View File

@ -0,0 +1,90 @@
# Research: Hard Filament Nativity Cleanup
## Decision: Reuse the repo's existing native page-table pattern for `EvidenceOverview` and `TenantRequiredPermissions`
### Rationale
The codebase already has two strong native examples for page-owned tables outside normal resource index pages: `ReviewRegister` and `InventoryCoverage`. Both use `InteractsWithTable`, `HasTable`, native Filament filters, Filament-managed filter state, native empty states, and one consistent inspect model. That makes them the narrowest repo-consistent replacement for the two current page-level bypasses.
`EvidenceOverview` is currently a hand-built Blade report table, and `TenantRequiredPermissions` is currently a custom page with pseudo-native filter controls. Both are better modeled as page-owned native tables than as bespoke Blade contracts.
### Alternatives considered
- Keep the current Blade table and filter bars, but restyle them more convincingly: rejected because that preserves the separate contract instead of removing it.
- Move either surface into a Resource or RelationManager: rejected because both already have correct route and page ownership; only their internal interaction model is wrong.
## Decision: Keep both page-level surfaces on derived data instead of adding new projections or schema
### Rationale
`EvidenceOverview` rows are already derived from `EvidenceSnapshot`, `TenantReview`, and `ArtifactTruthPresenter`, while `TenantRequiredPermissions` rows and summaries are already derived through `TenantRequiredPermissionsViewModelBuilder`. The current product truth is sufficient. The problem is not missing data infrastructure, but the non-native way the data is exposed.
Using derived table records keeps the implementation proportional and avoids importing persistence or a second source of truth for UI state.
### Alternatives considered
- Add dedicated read models or materialized projections for overview rows: rejected because the spec is cleanup, not reporting-architecture expansion.
- Convert the permission or evidence pages into query-first Eloquent resources: rejected because the current derived summaries and guidance would still need a second layer and would not simplify the domain.
## Decision: Replace inventory dependency GET controls with an embedded Livewire `TableComponent`
### Rationale
The dependency surface is not a standalone page and not a true Eloquent relationship that should become a RelationManager. It is a detail-context sub-surface inside inventory item view. The narrowest native replacement is therefore an embedded Livewire `TableComponent` that owns direction and relationship state, renders native filters, and stays inside the current inventory detail section.
The repo already uses Filament `TableComponent` in `BackupSetPolicyPickerTable`, which proves the pattern is acceptable and reusable here.
### Alternatives considered
- Convert the dependency section into a RelationManager: rejected because dependency edges are query-driven, not a direct relationship manager surface.
- Move dependencies to a new standalone page: rejected because it would break the current inspect-one-record workflow and widen scope.
- Keep a custom Blade fragment with `wire:model` on raw inputs: rejected because that still leaves a pseudo-native control surface instead of a real native table contract.
## Decision: Query parameters may seed initial state, but they do not remain the authoritative interaction contract
### Rationale
Both `TenantRequiredPermissions` and `EvidenceOverview` have valid deeplink or workflow-continuity reasons to accept initial query values. The spec explicitly allows that. What needs to change is ongoing ownership of page-body state. After first mount, filter state must live in native page or component state rather than continuing to be reconstructed from `request()` on every interaction.
This preserves existing deeplink behavior without letting query values become a shadow state system.
### Alternatives considered
- Remove all query seeding entirely: rejected because the current product does rely on deeplink and continuity behavior.
- Keep query parameters as the main contract forever: rejected because that is the bypass pattern the spec exists to remove.
## Decision: Preserve custom read-only presentation where it carries domain value, but make control state native
### Rationale
The spec is not a repo-wide custom Blade purge. Some read-only rendering still carries useful domain formatting, especially for dependency target badges, missing-target hints, permission guidance blocks, and evidence explanation text. The actual harm sits in fake controls, manual GET submission, and hand-built primary table contracts.
The narrowest implementation therefore replaces the primary control and table contracts while allowing domain-specific read-only cells or layout blocks to remain when they do not create a second state system.
### Alternatives considered
- Force every touched surface into generic Filament markup only: rejected because it risks over-correction and would expand scope into broader micro-UI standardization.
- Leave custom presentation and custom control markup mixed together: rejected because it would keep the core nativity problem alive.
## Decision: No additional same-class low-risk hit is confirmed during planning
### Rationale
The planning audit found the three target surfaces clearly. It did not identify a fourth candidate that is both obviously the same problem class and clearly small enough to include without opening shared-family or shell questions. That means the safe default is to keep the implementation scope locked to the three named surfaces and only admit an extra hit if implementation discovers a truly trivial match.
### Alternatives considered
- Expand planning scope now to include visually similar custom Blade surfaces elsewhere in monitoring or verification: rejected because those families carry broader architecture and product-semantics questions already marked out of scope.
## Decision: Extend existing focused tests and guardrails rather than introducing a new browser-centric verification layer
### Rationale
The repo already has meaningful coverage for all three areas: dependency rendering and tenant isolation, required-permissions trusted-state behavior and view-model derivation, and evidence overview authorization and DB-only rendering. The cleanup should lean on that existing coverage, then add only the missing surface-level native-table or component assertions.
This keeps the feature aligned with `TEST-TRUTH-001` and avoids creating a heavier verification framework than the change requires.
### Alternatives considered
- Add a new browser suite for all three surfaces as the primary proof: rejected because most required outcomes are already testable with feature and Livewire tests.
- Rely only on manual smoke checks: rejected because the repo rules require automated coverage for changed behavior.

Some files were not shown because too many files have changed in this diff Show More