Compare commits

..

6 Commits

Author SHA1 Message Date
9f6985291e feat: implement spec 192 record page header discipline (#226)
## Summary
- implement Spec 192 across the targeted Filament record, detail, and edit pages with explicit action-surface inventory and guard coverage
- add the focused Spec 192 browser smoke, feature tests, and spec artifacts under `specs/192-record-header-discipline`
- improve unhandled promise rejection diagnostics by correlating 419s to the underlying Livewire request URL
- disable panel-wide database notification polling on the admin, tenant, and system panels and cover the mitigation with focused tests

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php tests/Feature/Filament/FilamentNotificationsAssetsTest.php tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php tests/Feature/Filament/AdminSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- manual integrated-browser verification of the Spec 192 surfaces and the notification-polling mitigation

## Notes
- Livewire v4 / Filament v5 compliance remains unchanged.
- Provider registration stays in `bootstrap/providers.php`.
- No Global Search behavior was expanded.
- No destructive action confirmation semantics were relaxed.
- The full test suite was not run in this PR.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #226
2026-04-11 21:20:41 +00:00
74210bac2e feat: add baseline compare operator modes (#224)
## Summary
- add adaptive baseline compare presentation modes with `auto`, `dense`, and `compact` route handling on the existing matrix page
- compress support surfaces with staged filters, grouped legends, last-updated and passive refresh cues, compact single-tenant results, and dense multi-tenant scan rendering
- extend the matrix builder plus Pest and browser smoke coverage for visible-set-only compact and dense workflows

## Filament / Laravel notes
- Livewire v4 compliance preserved; no legacy Livewire v3 patterns introduced
- provider registration is unchanged; no `bootstrap/providers.php` changes were needed for this feature
- no globally searchable resources were changed by this branch
- no destructive actions were added; the existing compare action remains simulation-only and non-destructive
- asset strategy is unchanged; no new Filament assets were introduced

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- `80` tests passed with `673` assertions
- integrated browser smoke run on `http://localhost/admin/baseline-profiles/20/compare-matrix`

## Scope
- Spec 191 implementation
- spec contract updates in `spec.md`, `tasks.md`, and the logical OpenAPI contract

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #224
2026-04-11 15:48:22 +00:00
f7bbea2623 191-baseline-compare-operator-mode (#223)
## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #223
2026-04-11 12:51:46 +00:00
65e10a2020 Spec 190: tighten baseline compare matrix scanability (#222)
## Summary
- tighten the baseline compare matrix working surface with active filter scope summaries and clearer visible-set disclosure
- improve matrix scanability with a sticky subject column, calmer attention-first cell styling, and Filament form-based filter controls
- replace the misleading perpetual refresh loading state with a passive auto-refresh note and add focused regression coverage

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

## Notes
- this PR only contains the Spec 190 implementation changes on `190-baseline-compare-matrix`
- follow-up spec drafting for high-density operator mode was intentionally left out of this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #222
2026-04-11 12:32:10 +00:00
eca19819d1 feat: add workspace baseline compare matrix (#221)
## Summary
- add a workspace-scoped baseline compare matrix page under baseline profiles
- derive matrix tenant summaries, subject rows, cell states, freshness, and trust from existing snapshots, compare runs, and findings
- add confirmation-gated `Compare assigned tenants` actions on the baseline detail and matrix surfaces without introducing a workspace umbrella run
- preserve matrix navigation context into tenant compare and finding drilldowns and add centralized matrix badge semantics
- include spec, plan, data model, contracts, quickstart, tasks, and focused feature/browser coverage for Spec 190

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- completed an integrated-browser smoke flow locally for matrix render, differ filter, finding drilldown round-trip, and `Compare assigned tenants` confirmation/action

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #221
2026-04-11 10:20:25 +00:00
2f45ff5a84 feat: add portfolio triage review state tracking (#220)
## Summary
- add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking
- surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview
- extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows
- suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged

## Validation
- targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior
- code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- full suite was not re-run in this final step
- branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #220
2026-04-10 21:35:17 +00:00
92 changed files with 11889 additions and 369 deletions

View File

@ -165,6 +165,12 @@ ## Active Technologies
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
- 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 (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 (feat/005-bulk-operations)
@ -199,8 +205,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 189-portfolio-triage-review-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns
- 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
- 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
- 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
- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -15,6 +15,7 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks;
@ -109,6 +110,13 @@ class BaselineCompareLanding extends Page
/** @var array<string, mixed>|null */
public ?array $summaryAssessment = null;
/** @var array<string, mixed>|null */
public ?array $navigationContextPayload = null;
public ?int $matrixBaselineProfileId = null;
public ?string $matrixSubjectKey = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -130,6 +138,12 @@ public static function canAccess(): bool
public function mount(): void
{
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$baselineProfileId = request()->query('baseline_profile_id');
$subjectKey = request()->query('subject_key');
$this->matrixBaselineProfileId = is_numeric($baselineProfileId) ? (int) $baselineProfileId : null;
$this->matrixSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
$this->refreshStats();
}
@ -244,6 +258,9 @@ protected function getViewData(): array
}
return [
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
'matrixSubjectKey' => $this->matrixSubjectKey,
'hasCoverageWarnings' => $hasCoverageWarnings,
'evidenceGapsCountValue' => $evidenceGapsCountValue,
'hasEvidenceGaps' => $hasEvidenceGaps,
@ -302,9 +319,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
*/
protected function getHeaderActions(): array
{
return [
$this->compareNowAction(),
];
$actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('backToOrigin')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
}
$actions[] = $this->compareNowAction();
return $actions;
}
private function compareNowAction(): Action
@ -389,7 +416,7 @@ private function compareNowAction(): Action
->actions($run instanceof OperationRun ? [
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($run, $tenant)),
->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
] : [])
->send();
});
@ -436,4 +463,15 @@ private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats
return $aggregate;
}
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
return CanonicalNavigationContext::fromRequest(request());
}
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
return CanonicalNavigationContext::fromRequest($request);
}
}

View File

@ -0,0 +1,756 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class BaselineCompareMatrix extends Page implements HasForms
{
use InteractsWithForms;
use InteractsWithRecord;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string $resource = BaselineProfileResource::class;
protected static ?string $breadcrumb = 'Compare matrix';
protected string $view = 'filament.pages.baseline-compare-matrix';
public string $requestedMode = 'auto';
/**
* @var list<string>
*/
public array $selectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $selectedStates = [];
/**
* @var list<string>
*/
public array $selectedSeverities = [];
public string $tenantSort = 'tenant_name';
public string $subjectSort = 'deviation_breadth';
public ?string $focusedSubjectKey = null;
/**
* @var list<string>
*/
public array $draftSelectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $draftSelectedStates = [];
/**
* @var list<string>
*/
public array $draftSelectedSeverities = [];
public string $draftTenantSort = 'tenant_name';
public string $draftSubjectSort = 'deviation_breadth';
/**
* @var array<string, mixed>
*/
public array $matrix = [];
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
}
public function mount(int|string $record): void
{
$this->record = $this->resolveRecord($record);
$this->hydrateFiltersFromRequest();
$this->refreshMatrix();
$this->form->fill($this->filterFormState());
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make([
'default' => 1,
'xl' => 2,
])
->schema([
Grid::make([
'default' => 1,
'lg' => 5,
])
->schema([
Select::make('draftSelectedPolicyTypes')
->label('Policy types')
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->multiple()
->searchable()
->preload()
->native(false)
->placeholder('All policy types')
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.'
: null)
->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter',
])
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedStates')
->label('Technical states')
->options(fn (): array => $this->matrixOptions('stateOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All technical states')
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedSeverities')
->label('Severity')
->options(fn (): array => $this->matrixOptions('severityOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All severities'),
])
->columnSpan([
'xl' => 1,
]),
Grid::make([
'default' => 1,
'md' => 2,
'xl' => 1,
])
->schema([
Select::make('draftTenantSort')
->label('Tenant sort')
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
->default('tenant_name')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
Select::make('draftSubjectSort')
->label('Subject sort')
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
->default('deviation_breadth')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
])
->columnSpan([
'xl' => 1,
]),
]),
]);
}
protected function authorizeAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
public function getTitle(): string
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return 'Compare matrix: '.$profile->name;
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$profile = $this->getRecord();
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
->label('Compare assigned tenants')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Compare assigned tenants')
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
->action(fn (): mixed => $this->compareAssignedTenants());
$compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction(
$compareAssignedTenantsAction,
fn (): ?Workspace => $this->workspace(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
return [
Action::make('backToBaselineProfile')
->label('Back to baseline profile')
->color('gray')
->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')),
$compareAssignedTenantsAction,
];
}
public function applyFilters(): void
{
$this->selectedPolicyTypes = $this->normalizeQueryList($this->draftSelectedPolicyTypes);
$this->selectedStates = $this->normalizeQueryList($this->draftSelectedStates);
$this->selectedSeverities = $this->normalizeQueryList($this->draftSelectedSeverities);
$this->tenantSort = $this->normalizeTenantSort($this->draftTenantSort);
$this->subjectSort = $this->normalizeSubjectSort($this->draftSubjectSort);
$this->redirect($this->filterUrl(), navigate: true);
}
public function resetFilters(): void
{
$this->selectedPolicyTypes = [];
$this->selectedStates = [];
$this->selectedSeverities = [];
$this->tenantSort = 'tenant_name';
$this->subjectSort = 'deviation_breadth';
$this->focusedSubjectKey = null;
$this->draftSelectedPolicyTypes = [];
$this->draftSelectedStates = [];
$this->draftSelectedSeverities = [];
$this->draftTenantSort = 'tenant_name';
$this->draftSubjectSort = 'deviation_breadth';
$this->redirect($this->filterUrl(), navigate: true);
}
public function refreshMatrix(): void
{
$user = auth()->user();
abort_unless($user instanceof User, 403);
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [
'policyTypes' => $this->selectedPolicyTypes,
'states' => $this->selectedStates,
'severities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
'focusedSubjectKey' => $this->focusedSubjectKey,
]);
}
public function pollMatrix(): void
{
$this->refreshMatrix();
}
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return BaselineCompareLanding::getUrl(
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
panel: 'tenant',
tenant: $tenant,
);
}
public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl(
'view',
[
'record' => $findingId,
...$this->navigationContext($tenant, $subjectKey)->toQuery(),
],
tenant: $tenant,
);
}
public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string
{
return OperationRunLinks::tenantlessView(
$runId,
$this->navigationContext(
$tenantId !== null ? $this->tenant($tenantId) : null,
$subjectKey,
),
);
}
public function clearSubjectFocusUrl(): string
{
return static::getUrl($this->routeParameters([
'subject_key' => null,
]), panel: 'admin');
}
public function modeUrl(string $mode): string
{
return $this->filterUrl([
'mode' => $this->normalizeRequestedMode($mode),
]);
}
public function filterUrl(array $overrides = []): string
{
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
}
public function activeFilterCount(): int
{
return count($this->selectedPolicyTypes)
+ count($this->selectedStates)
+ count($this->selectedSeverities)
+ ($this->focusedSubjectKey !== null ? 1 : 0);
}
public function hasStagedFilterChanges(): bool
{
return $this->draftFilterState() !== $this->appliedFilterState();
}
public function canUseCompactMode(): bool
{
return $this->visibleTenantCount() <= 1;
}
public function presentationModeLabel(string $mode): string
{
return match ($mode) {
'dense' => 'Dense mode',
'compact' => 'Compact mode',
default => 'Auto mode',
};
}
/**
* @return array<string, int|string>
*/
public function activeFilterSummary(): array
{
$summary = [];
if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes);
}
if ($this->selectedStates !== []) {
$summary['Technical states'] = count($this->selectedStates);
}
if ($this->selectedSeverities !== []) {
$summary['Severity'] = count($this->selectedSeverities);
}
if ($this->focusedSubjectKey !== null) {
$summary['Focused subject'] = $this->focusedSubjectKey;
}
return $summary;
}
/**
* @return array<string, int|string>
*/
public function stagedFilterSummary(): array
{
$summary = [];
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
}
if ($this->draftSelectedStates !== $this->selectedStates) {
$summary['Technical states'] = count($this->draftSelectedStates);
}
if ($this->draftSelectedSeverities !== $this->selectedSeverities) {
$summary['Severity'] = count($this->draftSelectedSeverities);
}
if ($this->draftTenantSort !== $this->tenantSort) {
$summary['Tenant sort'] = $this->draftTenantSort;
}
if ($this->draftSubjectSort !== $this->subjectSort) {
$summary['Subject sort'] = $this->draftSubjectSort;
}
return $summary;
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
return array_merge($this->matrix, [
'profile' => $this->getRecord(),
'currentFilters' => [
'mode' => $this->requestedMode,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort,
'subject_sort' => $this->subjectSort,
'subject_key' => $this->focusedSubjectKey,
],
'draftFilters' => [
'policy_type' => $this->draftSelectedPolicyTypes,
'state' => $this->draftSelectedStates,
'severity' => $this->draftSelectedSeverities,
'tenant_sort' => $this->draftTenantSort,
'subject_sort' => $this->draftSubjectSort,
],
'presentationState' => $this->presentationState(),
]);
}
private function hydrateFiltersFromRequest(): void
{
$this->requestedMode = $this->normalizeRequestedMode(request()->query('mode', 'auto'));
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
$this->tenantSort = $this->normalizeTenantSort(request()->query('tenant_sort', 'tenant_name'));
$this->subjectSort = $this->normalizeSubjectSort(request()->query('subject_sort', 'deviation_breadth'));
$subjectKey = request()->query('subject_key');
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
$this->draftSelectedPolicyTypes = $this->selectedPolicyTypes;
$this->draftSelectedStates = $this->selectedStates;
$this->draftSelectedSeverities = $this->selectedSeverities;
$this->draftTenantSort = $this->tenantSort;
$this->draftSubjectSort = $this->subjectSort;
}
/**
* @return array<string, mixed>
*/
private function filterFormState(): array
{
return [
'draftSelectedPolicyTypes' => $this->draftSelectedPolicyTypes,
'draftSelectedStates' => $this->draftSelectedStates,
'draftSelectedSeverities' => $this->draftSelectedSeverities,
'draftTenantSort' => $this->draftTenantSort,
'draftSubjectSort' => $this->draftSubjectSort,
];
}
/**
* @return array<string, string>
*/
private function matrixOptions(string $key): array
{
$options = $this->matrix[$key] ?? null;
return is_array($options) ? $options : [];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function draftFilterState(): array
{
return [
'selectedPolicyTypes' => $this->normalizeQueryList($this->draftSelectedPolicyTypes),
'selectedStates' => $this->normalizeQueryList($this->draftSelectedStates),
'selectedSeverities' => $this->normalizeQueryList($this->draftSelectedSeverities),
'tenantSort' => $this->normalizeTenantSort($this->draftTenantSort),
'subjectSort' => $this->normalizeSubjectSort($this->draftSubjectSort),
];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function appliedFilterState(): array
{
return [
'selectedPolicyTypes' => $this->selectedPolicyTypes,
'selectedStates' => $this->selectedStates,
'selectedSeverities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
];
}
/**
* @return list<string>
*/
private function normalizeQueryList(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string {
if (! is_string($item)) {
return null;
}
$normalized = trim($item);
return $normalized !== '' ? $normalized : null;
}, $values))));
}
private function normalizeRequestedMode(mixed $value): string
{
return in_array((string) $value, ['auto', 'dense', 'compact'], true)
? (string) $value
: 'auto';
}
private function normalizeTenantSort(mixed $value): string
{
return in_array((string) $value, ['tenant_name', 'deviation_count', 'freshness_urgency'], true)
? (string) $value
: 'tenant_name';
}
private function normalizeSubjectSort(mixed $value): string
{
return in_array((string) $value, ['deviation_breadth', 'policy_type', 'display_name'], true)
? (string) $value
: 'deviation_breadth';
}
private function compareAssignedTenantsDisabledReason(): ?string
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
if (($reference['referenceState'] ?? null) !== 'ready') {
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
}
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
return 'No visible assigned tenants are available for compare.';
}
return null;
}
private function compareAssignedTenants(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
$summary = sprintf(
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
(int) $result['queuedCount'],
(int) $result['alreadyQueuedCount'],
(int) $result['blockedCount'],
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')
->actions([
Action::make('open_operations')
->label('Open operations')
->url(OperationRunLinks::index(
context: $this->navigationContext(),
allTenants: true,
)),
])
->send();
} else {
Notification::make()
->title('No baseline compares were started')
->body($summary)
->warning()
->send();
}
$this->refreshMatrix();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function routeParameters(array $overrides = []): array
{
return array_filter([
'record' => $this->getRecord(),
'mode' => $this->requestedMode !== 'auto' ? $this->requestedMode : null,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort !== 'tenant_name' ? $this->tenantSort : null,
'subject_sort' => $this->subjectSort !== 'deviation_breadth' ? $this->subjectSort : null,
'subject_key' => $this->focusedSubjectKey,
...$overrides,
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
}
private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$subjectKey ??= $this->focusedSubjectKey;
return CanonicalNavigationContext::forBaselineCompareMatrix(
profile: $profile,
filters: $this->routeParameters(),
tenant: $tenant,
subjectKey: $subjectKey,
);
}
private function tenant(int $tenantId): ?Tenant
{
return Tenant::query()
->whereKey($tenantId)
->where('workspace_id', (int) $this->getRecord()->workspace_id)
->first();
}
private function workspace(): ?Workspace
{
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
}
/**
* @return array<string, mixed>
*/
private function presentationState(): array
{
$resolvedMode = $this->resolvePresentationMode($this->visibleTenantCount());
return [
'requestedMode' => $this->requestedMode,
'resolvedMode' => $resolvedMode,
'visibleTenantCount' => $this->visibleTenantCount(),
'activeFilterCount' => $this->activeFilterCount(),
'hasStagedFilterChanges' => $this->hasStagedFilterChanges(),
'autoRefreshActive' => (bool) ($this->matrix['hasActiveRuns'] ?? false),
'lastUpdatedAt' => $this->matrix['lastUpdatedAt'] ?? null,
'canOverrideMode' => $this->visibleTenantCount() > 0,
'compactModeAvailable' => $this->canUseCompactMode(),
];
}
private function visibleTenantCount(): int
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
return (int) ($reference['visibleTenantCount'] ?? 0);
}
private function resolvePresentationMode(int $visibleTenantCount): string
{
if ($this->requestedMode === 'dense') {
return 'dense';
}
if ($this->requestedMode === 'compact' && $visibleTenantCount <= 1) {
return 'compact';
}
return $visibleTenantCount > 1 ? 'dense' : 'compact';
}
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Resources;
use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
@ -25,6 +26,9 @@
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -43,6 +47,7 @@
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
@ -135,7 +140,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one state-sensitive primary action, moves snapshot and compare-matrix navigation into contextual related context, and groups secondary actions under "More".');
}
public static function getEloquentQuery(): Builder
@ -318,6 +323,15 @@ public static function infolist(Schema $schema): Schema
])
->columns(2)
->columnSpanFull(),
Section::make('Related context')
->schema([
ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (BaselineProfile $record): array => self::detailRelatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Metadata')
->schema([
TextEntry::make('createdByUser.name')
@ -333,6 +347,37 @@ public static function infolist(Schema $schema): Schema
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public static function detailRelatedContextEntries(BaselineProfile $record): array
{
$entries = [];
$snapshotEntry = app(RelatedNavigationResolver::class)
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $record)[0] ?? null;
if ($snapshotEntry instanceof RelatedContextEntry) {
$entries[] = $snapshotEntry->toArray();
}
$entries[] = RelatedContextEntry::available(
key: 'compare_matrix',
label: 'Compare matrix',
value: 'Review compare matrix',
secondaryValue: $record->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot
? 'Use the latest consumable snapshot to inspect compare outcomes.'
: 'Open the matrix to inspect compare readiness and previous results.',
targetUrl: self::compareMatrixUrl($record),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open compare matrix',
contextBadge: 'Comparison',
)->toArray();
return $entries;
}
public static function table(Table $table): Table
{
$workspace = self::resolveWorkspace();
@ -447,10 +492,16 @@ public static function getPages(): array
'index' => Pages\ListBaselineProfiles::route('/'),
'create' => Pages\CreateBaselineProfile::route('/create'),
'view' => Pages\ViewBaselineProfile::route('/{record}'),
'compare-matrix' => BaselineCompareMatrix::route('/{record}/compare-matrix'),
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
];
}
public static function compareMatrixUrl(BaselineProfile|int $profile): string
{
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
}
/**
* @return array<string, string>
*/

View File

@ -16,15 +16,13 @@
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
@ -37,24 +35,19 @@ class ViewBaselineProfile extends ViewRecord
protected function getHeaderActions(): array
{
return [
Action::make('view_active_snapshot')
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
->color('gray'),
$this->captureAction(),
$this->compareNowAction(),
EditAction::make()
->visible(fn (): bool => $this->hasManageCapability()),
ActionGroup::make([
$this->compareAssignedTenantsAction(),
EditAction::make()
->visible(fn (): bool => $this->hasManageCapability()),
])
->label('More')
->icon('heroicon-m-ellipsis-vertical')
->color('gray'),
];
}
private function activeSnapshotEntry(): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
}
private function captureAction(): Action
{
/** @var BaselineProfile $profile */
@ -76,6 +69,7 @@ private function captureAction(): Action
->label($label)
->icon('heroicon-o-camera')
->color('primary')
->hidden(fn (): bool => $this->profileHasConsumableSnapshot())
->requiresConfirmation()
->modalHeading($label)
->modalDescription($modalDescription)
@ -188,6 +182,8 @@ private function compareNowAction(): Action
return Action::make('compareNow')
->label($label)
->icon('heroicon-o-play')
->color('primary')
->hidden(fn (): bool => ! $this->profileHasConsumableSnapshot())
->requiresConfirmation()
->modalHeading($label)
->modalDescription($modalDescription)
@ -307,6 +303,71 @@ private function compareNowAction(): Action
});
}
private function compareAssignedTenantsAction(): Action
{
$action = Action::make('compareAssignedTenants')
->label('Compare assigned tenants')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Compare assigned tenants')
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
$summary = sprintf(
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
(int) $result['queuedCount'],
(int) $result['alreadyQueuedCount'],
(int) $result['blockedCount'],
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')
->actions([
Action::make('open_operations')
->label('Open operations')
->url(OperationRunLinks::index(allTenants: true)),
])
->send();
return;
}
Notification::make()
->title('No baseline compares were started')
->body($summary)
->warning()
->send();
});
return WorkspaceUiEnforcement::forAction(
$action,
fn (): ?Workspace => Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
}
/**
* @return array<int, string>
*/
@ -407,4 +468,48 @@ private function profileHasConsumableSnapshot(): bool
return $profile->resolveCurrentConsumableSnapshot() !== null;
}
private function compareAssignedTenantsDisabledReason(): ?string
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
if (! $this->profileHasConsumableSnapshot()) {
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
}
if ($this->visibleAssignedTenantCount($profile) === 0) {
return 'No visible assigned tenants are available for compare.';
}
return null;
}
private function visibleAssignedTenantCount(BaselineProfile $profile): int
{
$user = auth()->user();
if (! $user instanceof User) {
return 0;
}
$tenantIds = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->pluck('tenant_id')
->all();
if ($tenantIds === []) {
return 0;
}
$resolver = app(CapabilityResolver::class);
return Tenant::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('id', $tenantIds)
->get(['id'])
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
->count();
}
}

View File

@ -7,6 +7,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Models\Tenant;
@ -18,6 +19,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
@ -115,7 +117,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence as the primary action, keeps Expire snapshot visibly separated as danger, and renders operation/review-pack navigation in contextual related context.');
}
public static function getEloquentQuery(): Builder
@ -181,6 +183,15 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
])
->columns(2),
Section::make('Related context')
->schema([
ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (EvidenceSnapshot $record): array => static::relatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Evidence dimensions')
->schema([
RepeatableEntry::make('items')
@ -213,6 +224,48 @@ public static function infolist(Schema $schema): Schema
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public static function relatedContextEntries(EvidenceSnapshot $record): array
{
$entries = [];
if (is_numeric($record->operation_run_id)) {
$entries[] = RelatedContextEntry::available(
key: 'operation_run',
label: 'Operation',
value: sprintf('#%d', (int) $record->operation_run_id),
secondaryValue: 'Open the latest evidence refresh operation.',
targetUrl: OperationRunLinks::tenantlessView((int) $record->operation_run_id),
targetKind: 'canonical_page',
priority: 10,
actionLabel: OperationRunLinks::openLabel(),
contextBadge: 'Operations',
)->toArray();
}
$pack = $record->reviewPacks()
->latest('created_at')
->first();
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'review_pack',
label: 'Review pack',
value: sprintf('#%d', (int) $pack->getKey()),
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
targetKind: 'direct_record',
priority: 20,
actionLabel: 'View review pack',
contextBadge: 'Reporting',
)->toArray();
}
return $entries;
}
public static function table(Table $table): Table
{
return $table

View File

@ -5,12 +5,9 @@
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Notifications\Notification;
@ -29,30 +26,11 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->icon('heroicon-o-eye')
->color('gray')
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
Actions\Action::make('view_review_pack')
->label('View review pack')
->icon('heroicon-o-document-text')
->color('gray')
->url(function (): ?string {
$pack = $this->latestReviewPack();
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
return null;
}
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
})
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
UiEnforcement::forAction(
Actions\Action::make('refresh_snapshot')
->label('Refresh evidence')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
@ -92,11 +70,4 @@ protected function getHeaderActions(): array
->apply(),
];
}
private function latestReviewPack(): ?ReviewPack
{
return $this->record->reviewPacks()
->latest('created_at')
->first();
}
}

View File

@ -7,6 +7,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\FindingExceptionEvidenceReference;
use App\Models\Tenant;
@ -20,6 +21,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -34,6 +36,7 @@
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
@ -115,7 +118,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
}
public static function getEloquentQuery(): Builder
@ -217,6 +220,15 @@ public static function infolist(Schema $schema): Schema
])
->columns(3),
]),
Section::make('Related context')
->schema([
ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (FindingException $record): array => static::relatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Evidence references')
->schema([
RepeatableEntry::make('evidenceReferences')
@ -245,6 +257,44 @@ public static function infolist(Schema $schema): Schema
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public static function relatedContextEntries(FindingException $record): array
{
$entries = [];
if ($record->finding && $record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'finding',
label: 'Finding',
value: static::findingSummary($record),
secondaryValue: 'Return to the linked finding detail.',
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Open finding',
contextBadge: 'Governance',
)->toArray();
}
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
$entries[] = RelatedContextEntry::available(
key: 'approval_queue',
label: 'Approval queue',
value: 'Review pending exception requests',
secondaryValue: 'Return to the queue for the rest of this tenants governance workload.',
targetUrl: static::approvalQueueUrl($record->tenant),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open approval queue',
contextBadge: 'Queue',
)->toArray();
}
return $entries;
}
public static function table(Table $table): Table
{
return $table

View File

@ -5,7 +5,6 @@
namespace App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
@ -34,40 +33,10 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array
{
return [
Action::make('open_finding')
->label('Open finding')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(function (): ?string {
$record = $this->getRecord();
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}),
Action::make('open_approval_queue')
->label('Open approval queue')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(function (): bool {
$record = $this->getRecord();
return $record instanceof FindingException
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
})
->url(function (): ?string {
$record = $this->getRecord();
return $record instanceof FindingException
? FindingExceptionResource::approvalQueueUrl($record->tenant)
: null;
}),
Action::make('renew_exception')
->label('Renew exception')
->icon('heroicon-o-arrow-path')
->color('warning')
->color('primary')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
->fillForm(fn (): array => [
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,

View File

@ -1257,6 +1257,16 @@ private static function primaryRelatedEntry(Finding $record, bool $fresh = false
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
{
$incomingContext = CanonicalNavigationContext::fromRequest(request());
if (
$incomingContext instanceof CanonicalNavigationContext
&& str_starts_with($incomingContext->sourceSurface, 'baseline_compare_matrix')
&& $incomingContext->backLinkUrl !== null
) {
return $incomingContext;
}
$tenant = $record->tenant;
return new CanonicalNavigationContext(

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver;
use Filament\Actions;
@ -23,7 +24,17 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array
{
return [
$actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Actions\Action::make('back_to_origin')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
}
return array_merge($actions, [
Actions\Action::make('primary_related')
->label(fn (): string => app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
@ -53,11 +64,16 @@ protected function getHeaderActions(): array
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
];
]);
}
public function getSubheading(): string|Htmlable|null
{
return FindingResource::findingSubheading($this->getRecord());
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
}

View File

@ -46,6 +46,8 @@
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\UnavailableRelationState;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionSurface;
@ -79,6 +81,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use UnitEnum;
@ -175,7 +178,7 @@ 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::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
->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.');
}
private static function userCanManageAnyTenant(User $user): bool
@ -194,9 +197,12 @@ private static function userCanDeleteAnyTenant(User $user): bool
public static function form(Schema $schema): Schema
{
// ... [Schema Omitted - No Change] ...
return $schema
->schema([
Forms\Components\Placeholder::make('related_context')
->label('Related context')
->content(fn (?Tenant $record): HtmlString => static::tenantEditContextHtml($record))
->visible(fn (?Tenant $record): bool => $record instanceof Tenant && static::tenantEditContextEntries($record) !== []),
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
@ -1990,6 +1996,16 @@ public static function infolist(Schema $schema): Schema
])
->columns(2)
->columnSpanFull(),
Section::make('Related context')
->schema([
Infolists\Components\ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (Tenant $record): array => static::tenantViewContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull()
->visible(fn (Tenant $record): bool => static::tenantViewContextEntries($record) !== []),
Section::make('Provider')
->schema([
Infolists\Components\ViewEntry::make('provider_connection_state')
@ -2236,6 +2252,166 @@ public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
}
/**
* @return array<int, array<string, mixed>>
*/
public static function tenantViewContextEntries(Tenant $tenant): array
{
$entries = [];
if (static::canEdit($tenant)) {
$entries[] = RelatedContextEntry::available(
key: 'tenant_edit',
label: 'Tenant edit',
value: 'Edit tenant',
secondaryValue: 'Update tenant identity and lifecycle metadata.',
targetUrl: static::getUrl('edit', ['record' => $tenant]),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Edit',
contextBadge: 'Management',
);
} elseif (static::viewerCanInspectTenantContext($tenant)) {
$entries[] = RelatedContextEntry::unavailable(
key: 'tenant_edit',
label: 'Tenant edit',
state: new UnavailableRelationState(
relationKey: 'tenant_edit',
referenceValue: null,
reason: 'authorization_denied',
message: UiTooltips::insufficientPermission(),
),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Edit',
);
}
if (static::viewerHasTenantCapability($tenant, Capabilities::PROVIDER_VIEW)) {
$entries[] = RelatedContextEntry::available(
key: 'provider_connections',
label: 'Provider connections',
value: 'Open provider connections',
secondaryValue: 'Inspect consent, credentials, and health for this tenant.',
targetUrl: ProviderConnectionResource::getUrl('index', ['tenant_id' => $tenant->external_id], panel: 'admin'),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open',
contextBadge: 'Integrations',
);
} elseif (static::viewerCanInspectTenantContext($tenant)) {
$entries[] = RelatedContextEntry::unavailable(
key: 'provider_connections',
label: 'Provider connections',
state: new UnavailableRelationState(
relationKey: 'provider_connections',
referenceValue: null,
reason: 'authorization_denied',
message: UiTooltips::insufficientPermission(),
),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open',
);
}
$relatedOnboarding = static::relatedOnboardingDraftAction($tenant, TenantActionSurface::TenantViewHeader);
if ($relatedOnboarding instanceof TenantActionDescriptor && filled(static::relatedOnboardingDraftUrl($tenant))) {
$entries[] = RelatedContextEntry::available(
key: 'related_onboarding',
label: 'Onboarding draft',
value: $relatedOnboarding->label,
secondaryValue: 'Return to the linked onboarding workflow for this tenant.',
targetUrl: (string) static::relatedOnboardingDraftUrl($tenant),
targetKind: 'workflow',
priority: 30,
actionLabel: 'Open',
contextBadge: 'Workflow',
);
}
return collect($entries)
->sortBy('priority')
->values()
->map(static fn (RelatedContextEntry $entry): array => $entry->toArray())
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
public static function tenantEditContextEntries(Tenant $tenant): array
{
$entries = [
RelatedContextEntry::available(
key: 'tenant_view',
label: 'Tenant detail',
value: 'Open tenant detail',
secondaryValue: 'Review verification, RBAC, and lifecycle context without leaving the tenant resource.',
targetUrl: static::getUrl('view', ['record' => $tenant]),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Open',
contextBadge: 'Inspection',
),
];
$relatedOnboarding = static::relatedOnboardingDraftAction($tenant, TenantActionSurface::TenantEditHeader);
if ($relatedOnboarding instanceof TenantActionDescriptor && filled(static::relatedOnboardingDraftUrl($tenant))) {
$entries[] = RelatedContextEntry::available(
key: 'related_onboarding',
label: 'Onboarding draft',
value: $relatedOnboarding->label,
secondaryValue: 'Return to the linked onboarding workflow for this tenant.',
targetUrl: (string) static::relatedOnboardingDraftUrl($tenant),
targetKind: 'workflow',
priority: 20,
actionLabel: 'Open',
contextBadge: 'Workflow',
);
}
return collect($entries)
->sortBy('priority')
->values()
->map(static fn (RelatedContextEntry $entry): array => $entry->toArray())
->all();
}
public static function tenantEditContextHtml(?Tenant $tenant): HtmlString
{
if (! $tenant instanceof Tenant) {
return new HtmlString('');
}
$entries = static::tenantEditContextEntries($tenant);
if ($entries === []) {
return new HtmlString('');
}
return new HtmlString((string) view('filament.infolists.entries.related-context', [
'entries' => $entries,
])->render());
}
public static function tenantViewLifecycleGroupVisible(Tenant $tenant): bool
{
return in_array(static::lifecycleActionDescriptor($tenant, TenantActionSurface::TenantViewHeader)?->key, ['archive', 'restore'], true);
}
public static function tenantViewExternalGroupVisible(Tenant $tenant): bool
{
return static::adminConsentUrl($tenant) !== null || static::entraUrl($tenant) !== null;
}
public static function tenantViewSetupGroupVisible(Tenant $tenant): bool
{
return $tenant->isActive();
}
public static function verificationActionVisible(Tenant $tenant): bool
{
$outcome = static::verificationReadinessOutcome($tenant);
@ -2284,6 +2460,28 @@ private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantAction
]);
}
private static function viewerCanInspectTenantContext(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User && $user->canAccessTenant($tenant);
}
private static function viewerHasTenantCapability(Tenant $tenant, string $capability): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $user->canAccessTenant($tenant)) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, $capability);
}
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
{
$user = auth()->user();

View File

@ -18,49 +18,54 @@ class EditTenant extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
UiEnforcement::forAction(
Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
->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(),
];
return array_values(array_filter([
Actions\ActionGroup::make([
UiEnforcement::forAction(
Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
->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')
->icon('heroicon-o-archive-box')
->color('gray')
->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& in_array(
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
['archive', 'restore'],
true,
)),
]));
}
}

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
@ -56,29 +55,8 @@ protected function getHeaderWidgets(): array
protected function getHeaderActions(): array
{
return [
return array_values(array_filter([
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('provider_connections')
->label('Provider connections')
->icon('heroicon-o-link')
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
Actions\Action::make('admin_consent')
->label('Grant admin consent')
->icon('heroicon-o-clipboard-document')
@ -91,6 +69,13 @@ protected function getHeaderActions(): array
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
->openUrlInNewTab(),
])
->label('External links')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('verify')
->label(self::verificationHeaderActionLabel())
@ -156,10 +141,6 @@ protected function getHeaderActions(): array
}
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())
@ -283,6 +264,13 @@ protected function getHeaderActions(): array
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
])
->label('Setup')
->icon('heroicon-o-wrench-screwdriver')
->color('gray')
->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
@ -318,9 +306,11 @@ protected function getHeaderActions(): array
->destructive()
->apply(),
])
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
];
->label('Lifecycle')
->icon('heroicon-o-archive-box')
->color('gray')
->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
]));
}
}

View File

@ -122,7 +122,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes one dominant lifecycle action, groups the remaining lifecycle actions under "More", keeps archive in a danger bucket, and renders operation/export/evidence navigation in contextual summary content.');
}
public static function getEloquentQuery(): Builder
@ -570,6 +570,7 @@ private static function summaryPresentation(TenantReview $record): array
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record),
'metrics' => [
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
@ -579,6 +580,43 @@ private static function summaryPresentation(TenantReview $record): array
];
}
/**
* @return array<int, array{title:string,label:string,url:string,description:string}>
*/
private static function summaryContextLinks(TenantReview $record): array
{
$links = [];
if (is_numeric($record->operation_run_id)) {
$links[] = [
'title' => 'Operation',
'label' => 'Open operation',
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
'description' => 'Inspect the latest review composition or refresh run.',
];
}
if ($record->currentExportReviewPack && $record->tenant) {
$links[] = [
'title' => 'Executive pack',
'label' => 'View executive pack',
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
'description' => 'Open the current export that belongs to this review.',
];
}
if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [
'title' => 'Evidence snapshot',
'label' => 'View evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
'description' => 'Return to the evidence basis behind this review.',
];
}
return $links;
}
/**
* @return array<string, mixed>
*/

View File

@ -11,7 +11,6 @@
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus;
use Filament\Actions;
@ -53,153 +52,235 @@ protected function authorizeAccess(): void
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_run')
->label('Open operation')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
->url(fn (): ?string => $this->record->operation_run_id
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
: null),
Actions\Action::make('view_export')
->label('View executive pack')
->icon('heroicon-o-document-arrow-down')
->color('gray')
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
->url(fn (): ?string => $this->record->currentExportReviewPack
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
: null),
Actions\Action::make('view_evidence')
->label('View evidence snapshot')
->icon('heroicon-o-shield-check')
->color('gray')
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
->url(fn (): ?string => $this->record->evidenceSnapshot
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
: null),
UiEnforcement::forAction(
Actions\Action::make('refresh_review')
->label('Refresh review')
->icon('heroicon-o-arrow-path')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
$secondaryActions = $this->secondaryLifecycleActions();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewService::class)->refresh($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
return;
}
Notification::make()->success()->title('Refresh review queued')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('publish_review')
->label('Publish review')
->icon('heroicon-o-check-badge')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
return;
}
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
Notification::make()->success()->title('Review published')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->hidden(fn (): bool => ! in_array($this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('create_next_review')
->label('Create next review')
->icon('heroicon-o-document-duplicate')
->hidden(fn (): bool => ! $this->record->isPublished())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
return;
}
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive_review')
->label('Archive review')
->icon('heroicon-o-archive-box')
->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
$this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title('Review archived')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
])
return array_values(array_filter([
$this->primaryLifecycleAction(),
Actions\ActionGroup::make($secondaryActions)
->label('More')
->icon('heroicon-m-ellipsis-vertical')
->color('gray'),
];
->color('gray')
->visible(fn (): bool => $secondaryActions !== []),
Actions\ActionGroup::make([
$this->archiveReviewAction(),
])
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
]));
}
private function primaryLifecycleAction(): ?Actions\Action
{
return match ($this->primaryLifecycleActionName()) {
'refresh_review' => $this->refreshReviewAction(),
'publish_review' => $this->publishReviewAction(),
'export_executive_pack' => $this->exportExecutivePackAction(),
default => null,
};
}
private function primaryLifecycleActionName(): ?string
{
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack';
}
if ((string) $this->record->status === TenantReviewStatus::Ready->value) {
return 'publish_review';
}
if ($this->record->isMutable()) {
return 'refresh_review';
}
return null;
}
/**
* @return list<Actions\Action>
*/
private function secondaryLifecycleActions(): array
{
return array_values(array_filter(array_map(
fn (string $name): ?Actions\Action => match ($name) {
'refresh_review' => $this->refreshReviewAction(),
'publish_review' => $this->publishReviewAction(),
'export_executive_pack' => $this->exportExecutivePackAction(),
'create_next_review' => $this->createNextReviewAction(),
default => null,
},
$this->secondaryLifecycleActionNames(),
)));
}
/**
* @return array<int, string>
*/
private function secondaryLifecycleActionNames(): array
{
$names = [];
if ($this->record->isMutable()) {
$names[] = 'refresh_review';
$names[] = 'publish_review';
}
if (in_array((string) $this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true)) {
$names[] = 'export_executive_pack';
}
if ($this->record->isPublished()) {
$names[] = 'create_next_review';
}
return array_values(array_filter(
$names,
fn (string $name): bool => $name !== $this->primaryLifecycleActionName(),
));
}
private function refreshReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('refresh_review')
->label('Refresh review')
->icon('heroicon-o-arrow-path')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewService::class)->refresh($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
return;
}
Notification::make()->success()->title('Refresh review queued')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function publishReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('publish_review')
->label('Publish review')
->icon('heroicon-o-check-badge')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
return;
}
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
Notification::make()->success()->title('Review published')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function exportExecutivePackAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function createNextReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('create_next_review')
->label('Create next review')
->icon('heroicon-o-document-duplicate')
->hidden(fn (): bool => ! $this->record->isPublished())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
return;
}
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function archiveReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('archive_review')
->label('Archive review')
->icon('heroicon-o-archive-box')
->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
$this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title('Review archived')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
}

View File

@ -4,6 +4,7 @@
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -37,4 +38,30 @@ public function assignedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by_user_id');
}
public function scopeForBaselineProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query->where('baseline_profile_id', $profileId);
}
public function scopeInWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
public static function assignedTenantIdsForProfile(BaselineProfile|int $profile, ?int $workspaceId = null): array
{
return static::query()
->when($workspaceId !== null, fn (Builder $query): Builder => $query->inWorkspace($workspaceId))
->forBaselineProfile($profile)
->pluck('tenant_id')
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->filter(static fn (int $tenantId): bool => $tenantId > 0)
->values()
->all();
}
}

View File

@ -274,6 +274,18 @@ public function scopeOpenDrift(Builder $query): Builder
->openWorkflow();
}
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query
->drift()
->where('source', 'baseline.compare')
->where('scope_key', 'baseline_profile:'.$profileId);
}
public function scopeOverdueOpen(Builder $query): Builder
{
return $query

View File

@ -6,6 +6,7 @@
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder;
@ -89,6 +90,17 @@ public function scopeTerminalFailure(Builder $query): Builder
->where('outcome', OperationRunOutcome::Failed->value);
}
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query
->where('type', OperationRunType::BaselineCompare->value)
->where('context->baseline_profile_id', $profileId);
}
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
{
$policy ??= app(OperationLifecyclePolicy::class);
@ -284,6 +296,34 @@ public function isGovernanceArtifactOperation(): bool
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
}
/**
* @param array<int, int> $tenantIds
* @return \Illuminate\Support\Collection<int, self>
*/
public static function latestBaselineCompareRunsForProfile(
BaselineProfile|int $profile,
array $tenantIds,
?int $workspaceId = null,
bool $completedOnly = false,
): \Illuminate\Support\Collection {
if ($tenantIds === []) {
return collect();
}
$runs = static::query()
->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
->whereIn('tenant_id', $tenantIds)
->baselineCompareForProfile($profile)
->when($completedOnly, fn (Builder $query): Builder => $query->where('status', OperationRunStatus::Completed->value))
->orderByDesc('completed_at')
->orderByDesc('id')
->get();
return $runs
->unique(static fn (self $run): int => (int) $run->tenant_id)
->values();
}
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
{
if ($tenantId <= 0) {

View File

@ -32,6 +32,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
@ -62,6 +63,7 @@ public function panel(Panel $panel): Panel
->brandLogoHeight('2rem')
->homeUrl(fn (): string => route('admin.home'))
->favicon(asset('favicon.ico'))
->font(null, provider: LocalFontProvider::class, preload: [])
->authenticatedRoutes(function (Panel $panel): void {
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
@ -182,7 +184,7 @@ public function panel(Panel $panel): Panel
FilamentInfoWidget::class,
])
->databaseNotifications()
->databaseNotificationsPolling('30s')
->databaseNotificationsPolling(null)
->unsavedChangesAlerts()
->middleware([
EncryptCookies::class,

View File

@ -7,6 +7,7 @@
use App\Http\Middleware\UseSystemSessionCookie;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Filament\PanelThemeAsset;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -31,11 +32,12 @@ public function panel(Panel $panel): Panel
->path('system')
->authGuard('platform')
->login(Login::class)
->font(null, provider: LocalFontProvider::class, preload: [])
->colors([
'primary' => Color::Blue,
])
->databaseNotifications()
->databaseNotificationsPolling('30s')
->databaseNotificationsPolling(null)
->renderHook(
PanelsRenderHook::BODY_START,
fn () => view('filament.system.components.break-glass-banner')->render(),

View File

@ -10,6 +10,7 @@
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -40,6 +41,7 @@ public function panel(Panel $panel): Panel
->brandLogo(fn () => view('filament.admin.logo'))
->brandLogoHeight('2rem')
->favicon(asset('favicon.ico'))
->font(null, provider: LocalFontProvider::class, preload: [])
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix(null)
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
@ -93,7 +95,7 @@ public function panel(Panel $panel): Panel
FilamentInfoWidget::class,
])
->databaseNotifications()
->databaseNotificationsPolling('30s')
->databaseNotificationsPolling(null)
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,

View File

@ -11,6 +11,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
@ -28,6 +29,7 @@ public function __construct(
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
private readonly CapabilityResolver $capabilityResolver,
) {}
/**
@ -47,12 +49,34 @@ public function startCompare(
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
}
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
$profile = $assignment->baselineProfile;
if (! $profile instanceof BaselineProfile) {
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
}
return $this->startCompareForProfile($profile, $tenant, $initiator, $baselineSnapshotId);
}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
*/
public function startCompareForProfile(
BaselineProfile $profile,
Tenant $tenant,
User $initiator,
?int $baselineSnapshotId = null,
): array {
$assignment = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('baseline_profile_id', (int) $profile->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
}
$precondition = $this->validatePreconditions($profile);
if ($precondition !== null) {
@ -124,6 +148,103 @@ public function startCompare(
return ['ok' => true, 'run' => $run];
}
/**
* @return array{
* baselineProfileId: int,
* visibleAssignedTenantCount: int,
* queuedCount: int,
* alreadyQueuedCount: int,
* blockedCount: int,
* targets: list<array{tenantId: int, runId: ?int, launchState: string, reasonCode: ?string}>
* }
*/
public function startCompareForVisibleAssignments(BaselineProfile $profile, User $initiator): array
{
$assignments = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->with('tenant')
->get();
$queuedCount = 0;
$alreadyQueuedCount = 0;
$blockedCount = 0;
$targets = [];
foreach ($assignments as $assignment) {
$tenant = $assignment->tenant;
if (! $tenant instanceof Tenant) {
continue;
}
if (! $this->capabilityResolver->isMember($initiator, $tenant)) {
continue;
}
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_VIEW)) {
continue;
}
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_SYNC)) {
$blockedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => null,
'launchState' => 'blocked',
'reasonCode' => 'tenant_sync_required',
];
continue;
}
$result = $this->startCompareForProfile($profile, $tenant, $initiator);
$run = $result['run'] ?? null;
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : null;
if (! ($result['ok'] ?? false) || ! $run instanceof OperationRun) {
$blockedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => null,
'launchState' => 'blocked',
'reasonCode' => $reasonCode,
];
continue;
}
if ($run->wasRecentlyCreated) {
$queuedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => (int) $run->getKey(),
'launchState' => 'queued',
'reasonCode' => null,
];
continue;
}
$alreadyQueuedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => (int) $run->getKey(),
'launchState' => 'already_queued',
'reasonCode' => null,
];
}
return [
'baselineProfileId' => (int) $profile->getKey(),
'visibleAssignedTenantCount' => count($targets),
'queuedCount' => $queuedCount,
'alreadyQueuedCount' => $alreadyQueuedCount,
'blockedCount' => $blockedCount,
'targets' => $targets,
];
}
private function validatePreconditions(BaselineProfile $profile): ?string
{
if ($profile->status !== BaselineProfileStatus::Active) {

View File

@ -66,6 +66,9 @@ final class BadgeCatalog
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
BadgeDomain::BaselineCompareMatrixState->value => Domains\BaselineCompareMatrixStateBadge::class,
BadgeDomain::BaselineCompareMatrixFreshness->value => Domains\BaselineCompareMatrixFreshnessBadge::class,
BadgeDomain::BaselineCompareMatrixTrust->value => Domains\BaselineCompareMatrixTrustBadge::class,
];
/**

View File

@ -57,4 +57,7 @@ enum BadgeDomain: string
case SystemHealth = 'system_health';
case ReferenceResolutionState = 'reference_resolution_state';
case DiffRowStatus = 'diff_row_status';
case BaselineCompareMatrixState = 'baseline_compare_matrix_state';
case BaselineCompareMatrixFreshness = 'baseline_compare_matrix_freshness';
case BaselineCompareMatrixTrust = 'baseline_compare_matrix_trust';
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixFreshnessBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
'fresh' => new BadgeSpec('Current result', 'success', 'heroicon-m-check-badge'),
'stale' => new BadgeSpec('Refresh recommended', 'warning', 'heroicon-m-arrow-path'),
'never_compared' => new BadgeSpec('Not compared yet', 'gray', 'heroicon-m-minus-circle'),
'unknown' => new BadgeSpec('Freshness unknown', 'info', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
'match' => new BadgeSpec('Reference aligned', 'success', 'heroicon-m-check-circle'),
'differ' => new BadgeSpec('Drift detected', 'danger', 'heroicon-m-exclamation-triangle'),
'missing' => new BadgeSpec('Missing from tenant', 'warning', 'heroicon-m-minus-circle'),
'ambiguous' => new BadgeSpec('Identity ambiguous', 'info', 'heroicon-m-question-mark-circle'),
'not_compared' => new BadgeSpec('Not compared', 'gray', 'heroicon-m-clock'),
'stale_result' => new BadgeSpec('Result stale', 'warning', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixTrustBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $value);
}
}

View File

@ -245,6 +245,57 @@ public static function topReasons(array $byReason, int $limit = 5): array
);
}
/**
* @return array<string, list<string>>
*/
public static function subjectReasonsFromOperationRun(?OperationRun $run): array
{
$details = self::fromOperationRun($run);
$buckets = is_array($details['buckets'] ?? null) ? $details['buckets'] : [];
$reasonMap = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$reasonCode = self::stringOrNull($bucket['reason_code'] ?? null);
if ($reasonCode === null) {
continue;
}
$rows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
if ($policyType === null || $subjectKey === null) {
continue;
}
$compositeKey = self::subjectCompositeKey($policyType, $subjectKey);
$reasonMap[$compositeKey] ??= [];
$reasonMap[$compositeKey][] = $reasonCode;
}
}
return array_map(
static fn (array $reasons): array => array_values(array_unique(array_filter($reasons, 'is_string'))),
$reasonMap,
);
}
public static function subjectCompositeKey(string $policyType, string $subjectKey): string
{
return trim(mb_strtolower($policyType)).'|'.trim(mb_strtolower($subjectKey));
}
/**
* @param list<array<string, mixed>> $buckets
* @return list<array<string, mixed>>

View File

@ -4,6 +4,7 @@
namespace App\Support\Baselines;
use App\Models\OperationRun;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
@ -18,6 +19,36 @@ public function __construct(
private readonly ReasonPresenter $reasonPresenter,
) {}
public function trustLevelForRun(?OperationRun $run): string
{
if (! $run instanceof OperationRun) {
return TrustworthinessLevel::Unusable->value;
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [];
$coverage = is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : [];
$evidenceGaps = is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : [];
$reasonCode = is_string($baselineCompare['reason_code'] ?? null) ? trim((string) $baselineCompare['reason_code']) : null;
$proof = is_bool($coverage['proof'] ?? null) ? (bool) $coverage['proof'] : null;
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null) ? $coverage['uncovered_types'] : [];
$evidenceGapCount = is_numeric($evidenceGaps['count'] ?? null) ? (int) $evidenceGaps['count'] : 0;
if ($run->status !== 'completed' || $run->outcome === 'failed') {
return TrustworthinessLevel::Unusable->value;
}
if ($proof === false || $reasonCode !== null) {
return TrustworthinessLevel::DiagnosticOnly->value;
}
if ($uncoveredTypes !== [] || $evidenceGapCount > 0) {
return TrustworthinessLevel::LimitedConfidence->value;
}
return TrustworthinessLevel::Trustworthy->value;
}
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
{
$reason = $stats->reasonCode !== null

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,28 @@ final class BaselineCompareSummaryAssessor
{
private const int STALE_AFTER_DAYS = 7;
public static function staleAfterDays(): int
{
return self::STALE_AFTER_DAYS;
}
public static function isStaleComparedAt(\DateTimeInterface|string|null $value): bool
{
if ($value === null) {
return false;
}
try {
$comparedAt = $value instanceof \DateTimeInterface
? CarbonImmutable::instance(\DateTimeImmutable::createFromInterface($value))
: CarbonImmutable::parse($value);
} catch (\Throwable) {
return false;
}
return $comparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
}
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
{
$explanation = $stats->operatorExplanation();
@ -376,12 +398,6 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
return false;
}
try {
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
} catch (\Throwable) {
return false;
}
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
return self::isStaleComparedAt($stats->lastComparedIso);
}
}

View File

@ -4,6 +4,9 @@
namespace App\Support\Navigation;
use App\Filament\Pages\BaselineCompareMatrix;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use Illuminate\Http\Request;
final readonly class CanonicalNavigationContext
@ -63,4 +66,31 @@ public function toQuery(): array
return $query;
}
/**
* @param array<string, mixed> $filters
*/
public static function forBaselineCompareMatrix(
BaselineProfile $profile,
array $filters = [],
?Tenant $tenant = null,
?string $subjectKey = null,
): self {
$parameters = array_filter([
'record' => $profile,
...$filters,
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
return new self(
sourceSurface: 'baseline_compare_matrix',
canonicalRouteName: BaselineCompareMatrix::getRouteName(),
tenantId: $tenant?->getKey(),
backLinkLabel: 'Back to compare matrix',
backLinkUrl: BaselineCompareMatrix::getUrl($parameters, panel: 'admin'),
filterPayload: array_filter([
'baseline_profile_id' => (int) $profile->getKey(),
'subject_key' => $subjectKey,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
}

View File

@ -12,6 +12,7 @@
use Closure;
use Filament\Actions\Action;
use Illuminate\Database\Eloquent\Model;
use ReflectionObject;
use Throwable;
/**
@ -39,6 +40,10 @@ final class WorkspaceUiEnforcement
private Model|Closure|null $record = null;
private bool $preserveExistingVisibility = false;
private bool $preserveExistingDisabled = false;
private function __construct(Action $action)
{
$this->action = $action;
@ -58,6 +63,14 @@ public static function forTableAction(Action $action, Model|Closure $record): se
return $instance;
}
public static function forAction(Action $action, Model|Closure|null $record = null): self
{
$instance = new self($action);
$instance->record = $record;
return $instance;
}
public function requireMembership(bool $require = true): self
{
$this->requireMembership = $require;
@ -95,6 +108,20 @@ public function tooltip(string $message): self
return $this;
}
public function preserveVisibility(): self
{
$this->preserveExistingVisibility = true;
return $this;
}
public function preserveDisabled(): self
{
$this->preserveExistingDisabled = true;
return $this;
}
public function apply(): Action
{
$this->applyVisibility();
@ -111,10 +138,22 @@ private function applyVisibility(): void
return;
}
$this->action->visible(function (?Model $record = null): bool {
$existingVisibility = $this->preserveExistingVisibility
? $this->getExistingVisibilityCondition()
: null;
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
$context = $this->resolveContextWithRecord($record);
return $context->isMember;
if (! $context->isMember) {
return false;
}
if ($existingVisibility === null) {
return true;
}
return $this->evaluateVisibilityCondition($existingVisibility, $record);
});
}
@ -126,7 +165,15 @@ private function applyDisabledState(): void
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null): bool {
$existingDisabled = $this->preserveExistingDisabled
? $this->getExistingDisabledCondition()
: null;
$this->action->disabled(function (?Model $record = null) use ($existingDisabled): bool {
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
return true;
}
$context = $this->resolveContextWithRecord($record);
if (! $context->isMember) {
@ -173,6 +220,96 @@ private function applyServerSideGuard(): void
});
}
private function getExistingVisibilityCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isVisible')) {
return null;
}
$property = $ref->getProperty('isVisible');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return false;
}
return (bool) $condition($record);
} catch (Throwable) {
return false;
}
}
private function getExistingDisabledCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isDisabled')) {
return null;
}
$property = $ref->getProperty('isDisabled');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return true;
}
return (bool) $condition($record);
} catch (Throwable) {
return true;
}
}
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
{
$user = auth()->user();

View File

@ -6,10 +6,12 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use ReflectionClass;
use Filament\Tables\Contracts\HasTable;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Throwable;
final class ActionSurfaceDiscovery
{
@ -100,7 +102,10 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
{
$scopes = [ActionSurfacePanelScope::Tenant];
if (in_array($className, $adminScopedClasses, true)) {
if (
in_array($className, $adminScopedClasses, true)
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
) {
$scopes[] = ActionSurfacePanelScope::Admin;
}
@ -228,6 +233,37 @@ private function isDeclaredSystemTablePage(string $className): bool
&& method_exists($className, 'actionSurfaceDeclaration');
}
/**
* Resource-owned Filament pages can live under app/Filament/Pages and be routed
* from the resource instead of being panel-registered directly. When that happens,
* inherit admin scope from the owning resource so discovery stays truthful.
*
* @param array<int, string> $adminScopedClasses
*/
private function inheritsAdminScopeFromResource(string $className, array $adminScopedClasses): bool
{
if (! class_exists($className)) {
return false;
}
try {
$reflection = new ReflectionClass($className);
if (! $reflection->hasProperty('resource')) {
return false;
}
$defaults = $reflection->getDefaultProperties();
$resourceClass = $defaults['resource'] ?? null;
return is_string($resourceClass)
&& $resourceClass !== ''
&& in_array($resourceClass, $adminScopedClasses, true);
} catch (Throwable) {
return false;
}
}
/**
* @return array<int, string>
*/

View File

@ -4,6 +4,20 @@
namespace App\Support\Ui\ActionSurface;
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot;
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion;
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
use App\Filament\Resources\TenantResource\Pages\EditTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
final class ActionSurfaceExemptions
@ -49,4 +63,275 @@ public function hasClass(string $className): bool
{
return array_key_exists($className, $this->componentReasons);
}
/**
* @return array<string, array{
* surfaceKey: string,
* classification: string,
* canonicalNoun: string,
* panelScope: string,
* ownerScope: string,
* routeKind: string,
* requiresHeaderRemediation: bool,
* exceptionReason: ?string,
* maxVisiblePrimaryActions: int,
* allowsNoPrimaryAction: bool,
* requiresGroupedSecondaryActions: bool,
* requiresDangerSeparation: bool,
* allowsPrimaryNavigation: bool,
* browserSmokeRequired: bool
* }>
*/
public static function spec192RecordPageInventory(): array
{
return [
ViewBaselineProfile::class => [
'surfaceKey' => 'baseline_profile_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Baseline profile',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => false,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewEvidenceSnapshot::class => [
'surfaceKey' => 'evidence_snapshot_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Evidence snapshot',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => false,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewFindingException::class => [
'surfaceKey' => 'finding_exception_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Finding exception',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewTenantReview::class => [
'surfaceKey' => 'tenant_review_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Tenant review',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => false,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
EditTenant::class => [
'surfaceKey' => 'tenant_edit',
'classification' => 'remediation_required',
'canonicalNoun' => 'Tenant',
'panelScope' => 'admin',
'ownerScope' => 'tenant-owned',
'routeKind' => 'edit',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewTenant::class => [
'surfaceKey' => 'tenant_view',
'classification' => 'workflow_heavy_special_type',
'canonicalNoun' => 'Tenant',
'panelScope' => 'admin',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => 'Tenant detail remains a workflow-heavy hub for external links, verification/setup, and lifecycle operations. It may show one dominant next step, but it must never silently fall back to a flat multi-button strip.',
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewProviderConnection::class => [
'surfaceKey' => 'provider_connection_view',
'classification' => 'minor_alignment_only',
'canonicalNoun' => 'Provider connection',
'panelScope' => 'admin',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => false,
],
ViewFinding::class => [
'surfaceKey' => 'finding_view',
'classification' => 'minor_alignment_only',
'canonicalNoun' => 'Finding',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => false,
],
ViewReviewPack::class => [
'surfaceKey' => 'review_pack_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Review pack',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewAlertDestination::class => [
'surfaceKey' => 'alert_destination_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Alert destination',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewPolicyVersion::class => [
'surfaceKey' => 'policy_version_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Policy version',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewWorkspace::class => [
'surfaceKey' => 'workspace_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Workspace',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewBaselineSnapshot::class => [
'surfaceKey' => 'baseline_snapshot_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Baseline snapshot',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewBackupSet::class => [
'surfaceKey' => 'backup_set_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Backup set',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
];
}
/**
* @return array{
* surfaceKey: string,
* classification: string,
* canonicalNoun: string,
* panelScope: string,
* ownerScope: string,
* routeKind: string,
* requiresHeaderRemediation: bool,
* exceptionReason: ?string,
* maxVisiblePrimaryActions: int,
* allowsNoPrimaryAction: bool,
* requiresGroupedSecondaryActions: bool,
* requiresDangerSeparation: bool,
* allowsPrimaryNavigation: bool,
* browserSmokeRequired: bool
* }|null
*/
public static function spec192RecordPageSurface(string $className): ?array
{
return self::spec192RecordPageInventory()[$className] ?? null;
}
}

View File

@ -54,6 +54,8 @@ public function validateComponents(array $components): ActionSurfaceValidationRe
{
$issues = [];
$this->validateSpec192RecordPageInventory($issues);
foreach ($components as $component) {
if (! class_exists($component->className)) {
$issues[] = new ActionSurfaceValidationIssue(
@ -106,6 +108,128 @@ className: $component->className,
);
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateSpec192RecordPageInventory(array &$issues): void
{
$allowedClassifications = [
'remediation_required',
'minor_alignment_only',
'compliant_reference',
'workflow_heavy_special_type',
];
$allowedPanelScopes = ['admin', 'tenant'];
$allowedOwnerScopes = ['workspace-owned', 'tenant-owned'];
$allowedRouteKinds = ['view', 'edit'];
$surfaceKeys = [];
foreach (ActionSurfaceExemptions::spec192RecordPageInventory() as $className => $surface) {
if (! class_exists($className)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 inventory references a page class that does not exist.',
hint: 'Keep ActionSurfaceExemptions::spec192RecordPageInventory() aligned with the in-scope page classes.',
);
continue;
}
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
if ($surfaceKey === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 inventory entry is missing a non-empty surface key.',
hint: 'Provide the stable spec surface key for this page.',
);
} elseif (isset($surfaceKeys[$surfaceKey])) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf('Spec 192 surface key "%s" is declared more than once.', $surfaceKey),
hint: 'Each in-scope page must have a unique surface key.',
);
} else {
$surfaceKeys[$surfaceKey] = true;
}
if (! in_array($surface['classification'] ?? null, $allowedClassifications, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 classification is invalid or missing.',
hint: 'Use remediation_required, minor_alignment_only, compliant_reference, or workflow_heavy_special_type.',
);
}
if (! in_array($surface['panelScope'] ?? null, $allowedPanelScopes, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 panel scope is invalid or missing.',
hint: 'Use the concrete panel scope for the record page inventory entry.',
);
}
if (! in_array($surface['ownerScope'] ?? null, $allowedOwnerScopes, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 owner scope is invalid or missing.',
hint: 'Use workspace-owned or tenant-owned.',
);
}
if (! in_array($surface['routeKind'] ?? null, $allowedRouteKinds, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 route kind is invalid or missing.',
hint: 'Use view or edit.',
);
}
if (! is_string($surface['canonicalNoun'] ?? null) || trim((string) $surface['canonicalNoun']) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 canonical noun must be non-empty.',
hint: 'Use the stable operator-facing noun for the surface.',
);
}
$classification = (string) ($surface['classification'] ?? '');
$exceptionReason = $surface['exceptionReason'] ?? null;
if ($classification === 'workflow_heavy_special_type') {
if (! is_string($exceptionReason) || trim($exceptionReason) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Workflow-heavy Spec 192 pages require an explicit exception reason.',
hint: 'Document why this surface is intentionally exempt from the standard record-page rule.',
);
}
} elseif ($exceptionReason !== null && trim((string) $exceptionReason) !== '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Only workflow-heavy Spec 192 pages may carry an exception reason.',
hint: 'Clear the exception reason for standard, minor-alignment, and compliant-reference surfaces.',
);
}
if (($surface['maxVisiblePrimaryActions'] ?? null) !== 1) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 maxVisiblePrimaryActions must stay pinned to 1.',
hint: 'The bounded header contract allows at most one visible primary header action.',
);
}
if ($classification === 'remediation_required' && ($surface['allowsPrimaryNavigation'] ?? false) === true) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Remediation-required Spec 192 surfaces must not allow primary navigation.',
hint: 'Move pure navigation into contextual placement outside the header.',
);
}
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/

View File

@ -10,6 +10,7 @@
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
const recentKeys = new Map();
const recentTransportFailures = [];
const cleanupRecentKeys = (nowMs) => {
for (const [key, timestampMs] of recentKeys.entries()) {
@ -19,6 +20,215 @@
}
};
const cleanupRecentTransportFailures = (nowMs) => {
while (recentTransportFailures.length > 0 && nowMs - recentTransportFailures[0].timestampMs > 15_000) {
recentTransportFailures.shift();
}
};
const normalizeUrl = (value) => {
if (typeof value !== 'string' || value === '') {
return null;
}
try {
return new URL(value, window.location.href).href;
} catch {
return value;
}
};
const toBodySnippet = (body) => {
if (typeof body !== 'string' || body === '') {
return null;
}
return body.slice(0, 1_000);
};
const recordTransportFailure = ({ requestUrl, method, status, body, transportType }) => {
const nowMs = Date.now();
cleanupRecentTransportFailures(nowMs);
recentTransportFailures.push({
requestUrl: normalizeUrl(requestUrl),
method: typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET',
status: Number.isFinite(status) ? status : null,
bodySnippet: toBodySnippet(body),
transportType: typeof transportType === 'string' && transportType !== '' ? transportType : 'unknown',
timestampMs: nowMs,
});
if (recentTransportFailures.length > 30) {
recentTransportFailures.shift();
}
};
const resolveTransportMetadata = (reason) => {
if (reason === null || typeof reason !== 'object') {
return null;
}
const directRequestUrl = typeof reason.requestUrl === 'string'
? normalizeUrl(reason.requestUrl)
: (typeof reason.url === 'string' ? normalizeUrl(reason.url) : null);
if (directRequestUrl) {
return {
requestUrl: directRequestUrl,
method: typeof reason.method === 'string' ? reason.method.toUpperCase() : null,
transportType: 'reason',
};
}
if (!isTransportEnvelope(reason)) {
return null;
}
const nowMs = Date.now();
const reasonBodySnippet = toBodySnippet(reason.body);
cleanupRecentTransportFailures(nowMs);
for (let index = recentTransportFailures.length - 1; index >= 0; index -= 1) {
const candidate = recentTransportFailures[index];
if (nowMs - candidate.timestampMs > 5_000) {
break;
}
if (candidate.status !== reason.status) {
continue;
}
if (reasonBodySnippet !== null && candidate.bodySnippet !== null && candidate.bodySnippet !== reasonBodySnippet) {
continue;
}
return {
requestUrl: candidate.requestUrl,
method: candidate.method,
transportType: candidate.transportType,
};
}
return null;
};
const extractRequestMetadata = (input, init) => {
if (input instanceof Request) {
return {
requestUrl: normalizeUrl(input.url),
method: typeof input.method === 'string' && input.method !== ''
? input.method.toUpperCase()
: 'GET',
};
}
return {
requestUrl: normalizeUrl(typeof input === 'string' ? input : String(input ?? '')),
method: typeof init?.method === 'string' && init.method !== ''
? init.method.toUpperCase()
: 'GET',
};
};
if (typeof window.fetch === 'function' && !window.__tenantpilotUnhandledRejectionFetchInstrumented) {
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const [input, init] = args;
const transport = extractRequestMetadata(input, init);
try {
const response = await originalFetch(...args);
if (!response.ok) {
const clonedResponse = typeof response.clone === 'function' ? response.clone() : null;
if (clonedResponse && typeof clonedResponse.text === 'function') {
clonedResponse.text()
.then((body) => {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: response.status,
body,
transportType: 'fetch',
});
})
.catch(() => {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: response.status,
body: null,
transportType: 'fetch',
});
});
} else {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: response.status,
body: null,
transportType: 'fetch',
});
}
}
return response;
} catch (error) {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: null,
body: error instanceof Error ? error.message : String(error ?? ''),
transportType: 'fetch',
});
throw error;
}
};
window.__tenantpilotUnhandledRejectionFetchInstrumented = true;
}
if (typeof XMLHttpRequest !== 'undefined' && !window.__tenantpilotUnhandledRejectionXhrInstrumented) {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__tenantpilotRequestMethod = typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET';
this.__tenantpilotRequestUrl = normalizeUrl(typeof url === 'string' ? url : String(url ?? ''));
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
if (!this.__tenantpilotTransportFailureListenerApplied) {
this.addEventListener('loadend', () => {
if (typeof this.status === 'number' && this.status >= 400) {
recordTransportFailure({
requestUrl: this.__tenantpilotRequestUrl,
method: this.__tenantpilotRequestMethod,
status: this.status,
body: typeof this.responseText === 'string' ? this.responseText : null,
transportType: 'xhr',
});
}
});
this.__tenantpilotTransportFailureListenerApplied = true;
}
return originalSend.apply(this, args);
};
window.__tenantpilotUnhandledRejectionXhrInstrumented = true;
}
const isTransportEnvelope = (value) => {
return value !== null
&& typeof value === 'object'
@ -101,6 +311,9 @@
'errors',
'reason',
'code',
'url',
'requestUrl',
'method',
];
for (const key of allowedKeys) {
@ -139,10 +352,14 @@
window.addEventListener('unhandledrejection', (event) => {
const normalizedReason = normalizeReason(event.reason);
const transport = resolveTransportMetadata(normalizedReason);
const payload = {
source: 'window.unhandledrejection',
href: window.location.href,
timestamp: new Date().toISOString(),
requestUrl: transport?.requestUrl ?? null,
requestMethod: transport?.method ?? null,
transportType: transport?.transportType ?? null,
reason: normalizedReason,
};
@ -155,6 +372,7 @@
const dedupeKey = toStableJson({
source: payload.source,
href: payload.href,
requestUrl: payload.requestUrl,
reason: payload.reason,
});

View File

@ -5,6 +5,7 @@
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
@endphp
@ -72,6 +73,37 @@
</div>
@endif
@if ($contextLinks !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
<div class="grid gap-3 md:grid-cols-3">
@foreach ($contextLinks as $link)
@php
$title = is_string($link['title'] ?? null) ? $link['title'] : null;
$label = is_string($link['label'] ?? null) ? $link['label'] : null;
$url = is_string($link['url'] ?? null) ? $link['url'] : null;
$description = is_string($link['description'] ?? null) ? $link['description'] : null;
@endphp
@continue($title === null || $label === null || $url === null)
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $title }}</div>
<div class="mt-2">
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
{{ $label }}
</x-filament::link>
</div>
@if ($description !== null && trim($description) !== '')
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $description }}</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>

View File

@ -9,6 +9,8 @@
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
@ -26,6 +28,28 @@
};
@endphp
@if ($arrivedFromCompareMatrix)
<x-filament::section>
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" icon="heroicon-m-squares-2x2" size="sm">
Arrived from compare matrix
</x-filament::badge>
@if ($matrixBaselineProfileId)
<x-filament::badge color="gray" size="sm">
Baseline profile #{{ (int) $matrixBaselineProfileId }}
</x-filament::badge>
@endif
@if (filled($matrixSubjectKey))
<x-filament::badge color="gray" size="sm">
Subject {{ $matrixSubjectKey }}
</x-filament::badge>
@endif
</div>
</x-filament::section>
@endif
@if ($duplicateNamePoliciesCountValue > 0)
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex items-start gap-3">

View File

@ -0,0 +1,868 @@
<x-filament::page>
@php
$reference = is_array($reference ?? null) ? $reference : [];
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
$denseRows = is_array($denseRows ?? null) ? $denseRows : [];
$compactResults = is_array($compactResults ?? null) ? $compactResults : [];
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
$tenantSortOptions = is_array($tenantSortOptions ?? null) ? $tenantSortOptions : [];
$subjectSortOptions = is_array($subjectSortOptions ?? null) ? $subjectSortOptions : [];
$stateLegend = is_array($stateLegend ?? null) ? $stateLegend : [];
$freshnessLegend = is_array($freshnessLegend ?? null) ? $freshnessLegend : [];
$trustLegend = is_array($trustLegend ?? null) ? $trustLegend : [];
$emptyState = is_array($emptyState ?? null) ? $emptyState : null;
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
$draftFilters = is_array($draftFilters ?? null) ? $draftFilters : [];
$presentationState = is_array($presentationState ?? null) ? $presentationState : [];
$supportSurfaceState = is_array($supportSurfaceState ?? null) ? $supportSurfaceState : [];
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
$activeFilterCount = $this->activeFilterCount();
$activeFilterSummary = $this->activeFilterSummary();
$stagedFilterSummary = $this->stagedFilterSummary();
$hasStagedFilterChanges = (bool) ($presentationState['hasStagedFilterChanges'] ?? false);
$requestedMode = (string) ($presentationState['requestedMode'] ?? 'auto');
$resolvedMode = (string) ($presentationState['resolvedMode'] ?? 'compact');
$visibleTenantCount = (int) ($presentationState['visibleTenantCount'] ?? 0);
$autoRefreshActive = (bool) ($presentationState['autoRefreshActive'] ?? false);
$lastUpdatedAt = $presentationState['lastUpdatedAt'] ?? null;
$compactModeAvailable = (bool) ($presentationState['compactModeAvailable'] ?? false);
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - $visibleTenantCount);
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
$trustBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixTrust, $value);
$severityBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::FindingSeverity, $value);
$profileStatusBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineProfileStatus, $value);
$profileStatusSpec = $profileStatusBadge($reference['baselineStatus'] ?? null);
$modeBadgeColor = match ($resolvedMode) {
'dense' => 'info',
'compact' => 'success',
default => 'gray',
};
$modeLabel = $this->presentationModeLabel($resolvedMode);
@endphp
@if ($autoRefreshActive)
<div aria-hidden="true" wire:poll.5s="pollMatrix"></div>
@endif
<x-filament::section heading="Reference overview">
<x-slot name="description">
Compare assigned tenants remains simulation only. This operator view changes presentation density, not compare truth, visible-set scope, or the existing drilldown path.
</x-slot>
<div class="space-y-4">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$profileStatusSpec->color" :icon="$profileStatusSpec->icon" size="sm">
{{ $profileStatusSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$referenceReady ? 'success' : 'warning'" :icon="$referenceReady ? 'heroicon-m-check-badge' : 'heroicon-m-exclamation-triangle'" size="sm">
{{ $referenceReady ? 'Reference snapshot ready' : 'Reference snapshot blocked' }}
</x-filament::badge>
<x-filament::badge :color="$modeBadgeColor" size="sm">
{{ $modeLabel }}
</x-filament::badge>
@if (filled($reference['referenceSnapshotId'] ?? null))
<x-filament::badge color="gray" size="sm">
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
</x-filament::badge>
@endif
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="info" icon="heroicon-m-eye-slash" size="sm">
{{ $hiddenAssignedTenantCount }} hidden by access scope
</x-filament::badge>
@endif
</div>
<div class="space-y-1">
<h2 class="text-xl font-semibold text-gray-950 dark:text-white" data-testid="baseline-compare-matrix-profile">
{{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300">
Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}.
Visible tenants: {{ $visibleTenantCount }}.
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
@endif
</p>
<p class="text-sm text-gray-600 dark:text-gray-300">
Auto mode resolves from the visible tenant set. Manual mode stays local to this route and never becomes stored preference truth.
</p>
@if (filled($reference['referenceReasonCode'] ?? null))
<p class="text-sm text-warning-700 dark:text-warning-300">
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
</p>
@endif
</div>
</div>
<dl class="grid gap-3 sm:grid-cols-2 xl:w-[28rem]">
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tenants</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $visibleTenantCount }}
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Rendered subjects</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $resolvedMode === 'compact' ? count($compactResults) : count($denseRows) }}
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Active filters</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
@if ($activeFilterCount === 0)
All visible results
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
@endif
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Resolved mode</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $modeLabel }}
</dd>
</div>
</dl>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(19rem,23rem)]">
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="baseline-compare-matrix-mode-switcher">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Presentation mode</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
Requested: {{ $this->presentationModeLabel($requestedMode) }}. Resolved: {{ $modeLabel }}.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button tag="a" :href="$this->modeUrl('auto')" :color="$requestedMode === 'auto' ? 'primary' : 'gray'" size="sm">
Auto
</x-filament::button>
<x-filament::button tag="a" :href="$this->modeUrl('dense')" :color="$requestedMode === 'dense' ? 'primary' : 'gray'" size="sm">
Dense
</x-filament::button>
@if ($compactModeAvailable)
<x-filament::button tag="a" :href="$this->modeUrl('compact')" :color="$requestedMode === 'compact' ? 'primary' : 'gray'" size="sm">
Compact
</x-filament::button>
@else
<x-filament::badge color="gray" size="sm">
Compact unlocks at one visible tenant
</x-filament::badge>
@endif
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2" data-testid="baseline-compare-matrix-last-updated">
@if (($supportSurfaceState['showLastUpdated'] ?? true) && filled($lastUpdatedAt))
<x-filament::badge color="gray" icon="heroicon-m-clock" size="sm">
Last updated {{ \Illuminate\Support\Carbon::parse($lastUpdatedAt)->diffForHumans() }}
</x-filament::badge>
@endif
@if (($supportSurfaceState['showAutoRefreshHint'] ?? false) && $autoRefreshActive)
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
Passive auto-refresh every 5 seconds
</x-filament::badge>
@endif
<div wire:loading.flex wire:target="refreshMatrix,applyFilters,resetFilters" class="items-center">
<x-filament::badge color="warning" icon="heroicon-m-arrow-path" size="sm">
Refreshing now
</x-filament::badge>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<x-filament::button type="button" wire:click="refreshMatrix" wire:loading.attr="disabled" wire:target="refreshMatrix,applyFilters,resetFilters" color="gray" size="sm">
Refresh matrix
</x-filament::button>
@if ($hiddenAssignedTenantCount > 0)
<span class="text-xs text-gray-500 dark:text-gray-400">
Visible-set only. Hidden tenants never contribute to summaries or drilldowns.
</span>
@endif
</div>
</div>
</div>
</div>
</div>
</x-filament::section>
<x-filament::section heading="Filters">
<x-slot name="description">
Heavy filters stage locally first. The matrix keeps rendering the applied scope until you explicitly apply or reset the draft.
</x-slot>
<div class="space-y-4" data-testid="baseline-compare-matrix-filters">
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="matrix-active-filters">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-2 min-w-0">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Applied matrix scope</div>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
@if ($activeFilterCount === 0)
No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are already shaping the rendered matrix.
@endif
</p>
@if ($activeFilterSummary !== [])
<div class="flex flex-wrap gap-2">
@foreach ($activeFilterSummary as $label => $value)
<x-filament::badge color="info" size="sm">
{{ $label }}: {{ $value }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<x-filament::badge :color="$activeFilterCount === 0 ? 'gray' : 'info'" icon="heroicon-m-funnel" size="sm">
@if ($activeFilterCount === 0)
All visible results
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
@endif
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Tenant sort: {{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Subject sort: {{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
</x-filament::badge>
</div>
</div>
@if ($hasStagedFilterChanges)
<div class="mt-3 rounded-xl border border-primary-200 bg-primary-50/70 px-3 py-3 dark:border-primary-900/60 dark:bg-primary-950/20" data-testid="baseline-compare-matrix-staged-filters">
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
<p class="text-sm text-primary-800/90 dark:text-primary-200/90">
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
</p>
</div>
@if ($stagedFilterSummary !== [])
<div class="flex flex-wrap gap-2">
@foreach ($stagedFilterSummary as $label => $value)
<x-filament::badge color="primary" size="sm">
{{ $label }}: {{ is_string($value) ? \Illuminate\Support\Str::headline(str_replace('_', ' ', $value)) : $value }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
</div>
@endif
</div>
<form wire:submit.prevent="applyFilters" class="space-y-4">
{{ $this->form }}
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold text-gray-950 dark:text-white">Focused subject</span>
@if (filled($currentFilters['subject_key'] ?? null))
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
{{ $currentFilters['subject_key'] }}
</x-filament::badge>
<x-filament::button tag="a" :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
Clear subject focus
</x-filament::button>
@else
<span class="text-gray-500 dark:text-gray-400">
None set yet. Use Focus subject from a row when you want a subject-first drilldown.
</span>
@endif
</div>
</div>
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Apply filters
</x-filament::button>
<x-filament::button type="button" wire:click="resetFilters" color="gray" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Reset filters
</x-filament::button>
</div>
</div>
</form>
<x-filament-actions::modals />
</div>
</x-filament::section>
<x-filament::section heading="Support context">
<x-slot name="description">
Status, legends, and refresh cues stay compact so the matrix body remains the primary working surface.
</x-slot>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current scope</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ $visibleTenantCount }} visible {{ \Illuminate\Support\Str::plural('tenant', $visibleTenantCount) }}.
{{ $resolvedMode === 'dense' ? 'State-first dense scan stays active.' : 'Compact single-tenant review stays active.' }}
</p>
@if ($policyTypeOptions !== [])
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
{{ count($policyTypeOptions) }} searchable policy types
</x-filament::badge>
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="gray" size="sm">
Visible-set only
</x-filament::badge>
@endif
</div>
@endif
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Refresh honesty</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Manual refresh shows a blocking state only while you explicitly redraw. Background polling remains a passive hint.
</p>
@if ($autoRefreshActive)
<div class="mt-3">
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
Compare work is still queued or running
</x-filament::badge>
</div>
@endif
</div>
</div>
<details class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm open:bg-white dark:border-gray-800 dark:bg-gray-900/50 dark:open:bg-gray-900/70">
<summary class="cursor-pointer list-none">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Grouped legend</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
State, freshness, and trust stay available on demand without pushing the matrix down the page.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">{{ count($stateLegend) }} states</x-filament::badge>
<x-filament::badge color="gray" size="sm">{{ count($freshnessLegend) }} freshness cues</x-filament::badge>
<x-filament::badge color="gray" size="sm">{{ count($trustLegend) }} trust cues</x-filament::badge>
</div>
</div>
</summary>
<div class="mt-4 grid gap-3">
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($stateLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($freshnessLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($trustLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
</div>
</details>
</div>
</x-filament::section>
<div class="relative" data-testid="baseline-compare-matrix-results">
@if ($emptyState !== null)
<x-filament::section heading="Results">
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-8 dark:border-gray-700 dark:bg-gray-900/40">
<div class="space-y-3" data-testid="baseline-compare-matrix-empty-state">
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] ?? 'Nothing to show' }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}</p>
@if ($activeFilterCount > 0)
<div class="pt-1">
<x-filament::button type="button" wire:click="resetFilters" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Reset filters
</x-filament::button>
</div>
@endif
</div>
</div>
</x-filament::section>
@elseif ($resolvedMode === 'compact')
@php
$compactTenant = $tenantSummaries[0] ?? null;
$compactTenantFreshnessSpec = $freshnessBadge($compactTenant['freshnessState'] ?? null);
$compactTenantTrustSpec = $trustBadge($compactTenant['trustLevel'] ?? null);
@endphp
<x-filament::section heading="Compact compare results">
<x-slot name="description">
One visible tenant remains in scope, so the matrix collapses into a shorter subject-result list instead of a pseudo-grid.
</x-slot>
@if ($compactTenant)
<div class="mb-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70" data-testid="baseline-compare-matrix-compact-shell">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $compactTenant['tenantName'] }}</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
Compact mode stays visible-set only. Subject drilldowns and run links still preserve the matrix context.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$compactTenantFreshnessSpec->color" :icon="$compactTenantFreshnessSpec->icon" size="sm">
{{ $compactTenantFreshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$compactTenantTrustSpec->color" :icon="$compactTenantTrustSpec->icon" size="sm">
{{ $compactTenantTrustSpec->label }}
</x-filament::badge>
</div>
</div>
</div>
@endif
<div class="space-y-3">
@foreach ($compactResults as $result)
@php
$stateSpec = $stateBadge($result['state'] ?? null);
$freshnessSpec = $freshnessBadge($result['freshnessState'] ?? null);
$trustSpec = $trustBadge($result['trustLevel'] ?? null);
$severitySpec = filled($result['severity'] ?? null) ? $severityBadge($result['severity']) : null;
$tenantId = (int) ($result['tenantId'] ?? 0);
$subjectKey = $result['subjectKey'] ?? null;
$primaryUrl = filled($result['findingId'] ?? null)
? $this->findingUrl($tenantId, (int) $result['findingId'], $subjectKey)
: $this->tenantCompareUrl($tenantId, $subjectKey);
$runUrl = filled($result['compareRunId'] ?? null)
? $this->runUrl((int) $result['compareRunId'], $tenantId, $subjectKey)
: null;
$attentionClasses = match ((string) ($result['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
$attentionLabel = \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($result['attentionLevel'] ?? 'review')));
@endphp
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-base font-semibold text-gray-950 dark:text-white">
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $result['policyType'] ?? 'Unknown policy type' }}
</div>
@if (filled($result['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reference ID: {{ $result['baselineExternalId'] }}
</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon" size="sm">
{{ $stateSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
@if ($severitySpec)
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
@endif
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
{{ $attentionLabel }}
</span>
</div>
@if (filled($result['reasonSummary'] ?? null) || filled($result['lastComparedAt'] ?? null))
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
@if (filled($result['reasonSummary'] ?? null))
<div>{{ $result['reasonSummary'] }}</div>
@endif
@if (filled($result['lastComparedAt'] ?? null))
<div>Compared {{ \Illuminate\Support\Carbon::parse($result['lastComparedAt'])->diffForHumans() }}</div>
@endif
</div>
@endif
</div>
<div class="flex flex-col gap-3 xl:items-end">
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
Drift breadth {{ (int) ($result['deviationBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Missing {{ (int) ($result['missingBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Ambiguous {{ (int) ($result['ambiguousBreadth'] ?? 0) }}
</x-filament::badge>
</div>
<div class="flex flex-wrap gap-3 text-sm">
@if ($primaryUrl)
<x-filament::link :href="$primaryUrl" size="sm">
{{ filled($result['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
</x-filament::link>
@endif
@if ($runUrl)
<x-filament::link :href="$runUrl" color="gray" size="sm">
Open run
</x-filament::link>
@endif
@if (filled($result['subjectKey'] ?? null))
<x-filament::link :href="$this->filterUrl(['subject_key' => $result['subjectKey']])" color="gray" size="sm">
Focus subject
</x-filament::link>
@endif
</div>
</div>
</div>
</div>
@endforeach
</div>
</x-filament::section>
@else
<x-filament::section heading="Dense multi-tenant scan">
<x-slot name="description">
The matrix body is state-first. Row click stays forbidden, the subject column stays pinned, and repeated follow-up actions move behind compact secondary reveals.
</x-slot>
<div class="mb-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
@foreach ($tenantSummaries as $tenantSummary)
@php
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
$trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null);
$tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null;
$tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']);
$tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null)
? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId'])
: null;
@endphp
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
@if ($tenantSeveritySpec)
<x-filament::badge :color="$tenantSeveritySpec->color" :icon="$tenantSeveritySpec->icon" size="sm">
{{ $tenantSeveritySpec->label }}
</x-filament::badge>
@endif
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Aligned</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Drift</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Missing</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Ambiguous</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) }}</div>
</div>
</div>
<div class="flex flex-wrap gap-3 text-sm">
@if ($tenantCompareUrl)
<x-filament::link :href="$tenantCompareUrl" size="sm">
Open tenant compare
</x-filament::link>
@endif
@if ($tenantRunUrl)
<x-filament::link :href="$tenantRunUrl" color="gray" size="sm">
Open latest run
</x-filament::link>
@endif
</div>
</div>
</div>
@endforeach
</div>
<div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
<div class="min-w-[82rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800" data-testid="baseline-compare-matrix-dense-shell">
<table class="min-w-full border-separate border-spacing-0">
<thead class="bg-gray-50 dark:bg-gray-950/70">
<tr>
<th class="sticky left-0 z-20 w-[22rem] border-r border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:bg-gray-950/70 dark:text-gray-400">
Baseline subject
</th>
@foreach ($tenantSummaries as $tenantSummary)
@php
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
@endphp
<th class="min-w-[16rem] border-b border-gray-200 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
<div class="space-y-2">
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
</div>
</div>
</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($denseRows as $row)
@php
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
$rowKey = (string) ($subject['subjectKey'] ?? 'subject-'.$loop->index);
$rowSurfaceClasses = $loop->even
? 'bg-gray-50/70 dark:bg-gray-950/20'
: 'bg-white dark:bg-gray-900/60';
$subjectAttentionClasses = match ((string) ($subject['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
@endphp
<tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}" data-testid="baseline-compare-matrix-row">
<td class="sticky left-0 z-10 border-r border-gray-200 px-4 py-4 align-top dark:border-gray-800 {{ $rowSurfaceClasses }}">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $subject['policyType'] ?? 'Unknown policy type' }}
</div>
@if (filled($subject['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reference ID: {{ $subject['baselineExternalId'] }}
</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Missing {{ (int) ($subject['missingBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge :color="$subjectTrustSpec->color" :icon="$subjectTrustSpec->icon" size="sm">
{{ $subjectTrustSpec->label }}
</x-filament::badge>
@if ($subjectSeveritySpec)
<x-filament::badge :color="$subjectSeveritySpec->color" :icon="$subjectSeveritySpec->icon" size="sm">
{{ $subjectSeveritySpec->label }}
</x-filament::badge>
@endif
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $subjectAttentionClasses }}">
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($subject['attentionLevel'] ?? 'review'))) }}
</span>
</div>
@if (filled($subject['subjectKey'] ?? null))
<div class="flex flex-wrap gap-3 text-sm">
<x-filament::link :href="$this->filterUrl(['subject_key' => $subject['subjectKey']])" color="gray" size="sm" data-testid="matrix-focus-subject">
Focus subject
</x-filament::link>
</div>
@endif
</div>
</td>
@foreach ($cells as $cell)
@php
$cellStateSpec = $stateBadge($cell['state'] ?? null);
$cellFreshnessSpec = $freshnessBadge($cell['freshnessState'] ?? null);
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
$tenantId = (int) ($cell['tenantId'] ?? 0);
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
$primaryUrl = filled($cell['findingId'] ?? null)
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
: $this->tenantCompareUrl($tenantId, $subjectKey);
$runUrl = filled($cell['compareRunId'] ?? null)
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
: null;
$attentionClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
$cellSurfaceClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
'needs_attention' => 'border-danger-300 bg-danger-50/80 ring-1 ring-danger-200/70 dark:border-danger-900/70 dark:bg-danger-950/15 dark:ring-danger-900/40',
'refresh_recommended' => 'border-warning-300 bg-warning-50/80 ring-1 ring-warning-200/70 dark:border-warning-900/70 dark:bg-warning-950/15 dark:ring-warning-900/40',
'aligned' => 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10',
default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40',
};
@endphp
<td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
<div class="flex h-full flex-col gap-3 rounded-xl border p-3 text-xs transition-colors group-hover:border-primary-200 dark:group-hover:border-primary-900 {{ $cellSurfaceClasses }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
{{ $cellStateSpec->label }}
</x-filament::badge>
@if ($cellSeveritySpec)
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
{{ $cellSeveritySpec->label }}
</x-filament::badge>
@endif
</div>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($cell['attentionLevel'] ?? 'review'))) }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellFreshnessSpec->color" :icon="$cellFreshnessSpec->icon" size="sm">
{{ $cellFreshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
{{ $cellTrustSpec->label }}
</x-filament::badge>
</div>
@if (filled($cell['reasonSummary'] ?? null) || filled($cell['lastComparedAt'] ?? null))
<div class="space-y-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
@if (filled($cell['reasonSummary'] ?? null))
<div>{{ $cell['reasonSummary'] }}</div>
@endif
@if (filled($cell['lastComparedAt'] ?? null))
<div>Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}</div>
@endif
</div>
@endif
<div class="mt-auto space-y-2">
@if ($primaryUrl)
<div class="text-sm">
<x-filament::link :href="$primaryUrl" size="sm">
{{ filled($cell['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
</x-filament::link>
</div>
@endif
@if ($runUrl || filled($subjectKey))
<details class="rounded-lg border border-gray-200 bg-white/70 px-2 py-1.5 dark:border-gray-800 dark:bg-gray-950/50">
<summary class="cursor-pointer list-none text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
More follow-up
</summary>
<div class="mt-2 flex flex-wrap gap-3 text-sm">
@if ($runUrl)
<x-filament::link :href="$runUrl" color="gray" size="sm">
Open run
</x-filament::link>
@endif
@if (filled($subjectKey))
<x-filament::link :href="$this->filterUrl(['subject_key' => $subjectKey])" color="gray" size="sm">
Focus subject
</x-filament::link>
@endif
</div>
</details>
@endif
</div>
</div>
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</x-filament::section>
@endif
</div>
</x-filament::page>

View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Models\Finding;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(BuildsBaselineCompareMatrixFixtures::class);
pest()->browser()->timeout(15_000);
it('smokes dense multi-tenant scanning and finding drilldown continuity', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => Finding::SEVERITY_CRITICAL],
);
$this->actingAs($fixture['user'])->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
$page = visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']));
$page
->assertNoJavaScriptErrors()
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
->assertSee('Dense multi-tenant scan')
->assertSee('Grouped legend')
->assertSee('Open finding')
->assertSee('More follow-up')
->click('Open finding')
->waitForText('Back to compare matrix')
->assertNoJavaScriptErrors()
->assertSee('Back to compare matrix');
});
it('smokes the compact single-tenant path when only one visible tenant remains', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => Finding::SEVERITY_HIGH],
);
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$viewer->tenants()->syncWithoutDetaching([
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
]);
$this->actingAs($viewer)->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertNoJavaScriptErrors()
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
->assertSee('Compact compare results')
->assertSee('Open finding');
});
it('smokes filtered zero-results reset flow and passive refresh cues without losing the matrix route', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
attributes: [
'status' => \App\Support\OperationRunStatus::Queued->value,
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
'completed_at' => null,
'started_at' => now(),
],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->actingAs($fixture['user'])->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
->assertNoJavaScriptErrors()
->waitForText('No rows match the current filters')
->assertSee('Passive auto-refresh every 5 seconds')
->click('Reset filters')
->waitForText('Dense multi-tenant scan')
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertNoJavaScriptErrors();
});

View File

@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\AlertDestination;
use App\Models\BackupSet;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
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\Workspaces\WorkspaceContext;
pest()->browser()->timeout(20_000);
function spec192ApprovedFindingException(Tenant $tenant, User $requester)
{
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$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' => 'Browser smoke test exception 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' => 'Browser smoke approval.',
]);
}
it('smokes remediated standard record pages with contextual navigation and one clear next step', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Spec192 Browser Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$onboardingTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec192 Browser Onboarding Tenant',
]);
createUserWithTenant(
tenant: $onboardingTenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
createOnboardingDraft([
'workspace' => $onboardingTenant->workspace,
'tenant' => $onboardingTenant,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => (string) $onboardingTenant->tenant_id,
'tenant_name' => (string) $onboardingTenant->name,
],
]);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec192 Browser Baseline',
]);
$baselineSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
\App\Models\BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$run = 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) $run->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(),
]);
$exception = spec192ApprovedFindingException($tenant, $user);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
visit(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Review compare matrix')
->assertSee('Compare now');
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Review pack')
->assertSee('Refresh evidence');
visit(FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Open finding')
->assertSee('Renew exception');
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Artifact truth')
->assertSee('Evidence snapshot');
visit(TenantResource::getUrl('edit', ['record' => $onboardingTenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Resume onboarding')
->assertSee('Open tenant detail');
});
it('smokes the explicit workflow-heavy tenant detail exception without javascript errors', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Spec192 Workflow Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Edit tenant')
->assertSee('Open provider connections');
});
it('smokes the compliant reference baseline without header regressions or javascript errors', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Spec192 Reference Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$workspace = $tenant->workspace;
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec192 Reference Baseline',
]);
$baselineSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Spec192 Browser Policy',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'version_number' => 4,
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $baselineSnapshot->getKey(),
'meta_jsonb' => [
'display_name' => 'Spec192 Browser Policy',
'version_reference' => [
'policy_version_id' => (int) $version->getKey(),
],
],
]);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Spec192 Browser Backup',
]);
OperationRun::factory()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies',
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$reviewSnapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $reviewSnapshot);
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $reviewSnapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
]);
$destination = AlertDestination::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec192 Browser Destination',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
visit(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
->waitForText('Download')
->assertNoJavaScriptErrors()
->assertSee('Regenerate');
visit(AlertDestinationResource::getUrl('view', ['record' => $destination], panel: 'admin'))
->waitForText('Send test message')
->assertNoJavaScriptErrors()
->assertSee('Details');
visit(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertSee('View snapshot');
visit(WorkspaceResource::getUrl('view', ['record' => $workspace], panel: 'admin'))
->waitForText('Memberships')
->assertNoJavaScriptErrors()
->assertSee('Edit');
visit(BaselineSnapshotResource::getUrl('view', ['record' => $baselineSnapshot], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
->assertSee('Spec192 Browser Policy');
visit(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
->assertSee('Operations');
});

View File

@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('builds visible-set-only dense rows plus support metadata from assigned baseline truth', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$visibleRun = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$visibleRunTwo = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$hiddenRun = $this->makeBaselineCompareMatrixRun(
$fixture['hiddenTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$visibleRunTwo,
'wifi-corp-profile',
);
$this->makeBaselineCompareMatrixFinding(
$fixture['hiddenTenant'],
$fixture['profile'],
$hiddenRun,
'wifi-corp-profile',
['severity' => 'critical'],
);
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
$wifiRow = collect($matrix['denseRows'])->first(
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
);
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
->and($matrix['reference']['visibleTenantCount'])->toBe(2)
->and(collect($matrix['tenantSummaries'])->pluck('tenantName')->all())->toEqualCanonicalizing([
(string) $fixture['visibleTenant']->name,
(string) $fixture['visibleTenantTwo']->name,
])
->and($wifiRow)->not->toBeNull()
->and($wifiRow['subject']['deviationBreadth'])->toBe(1)
->and($wifiRow['subject']['attentionLevel'])->toBe('needs_attention')
->and(count($wifiRow['cells']))->toBe(2)
->and($matrix['denseRows'])->toHaveCount(2)
->and($matrix['compactResults'])->toBeEmpty()
->and($matrix['supportSurfaceState']['legendMode'])->toBe('grouped')
->and($matrix['supportSurfaceState']['showAutoRefreshHint'])->toBeFalse()
->and($matrix['lastUpdatedAt'])->not->toBeNull();
});
it('derives matrix cell precedence, freshness, attention, and reason summaries from compare truth', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$matchTenant = $fixture['visibleTenant'];
$differTenant = $fixture['visibleTenantTwo'];
$missingTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Missing',
]);
$ambiguousTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Ambiguous',
]);
$notComparedTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Uncovered',
]);
$staleTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Stale',
]);
$fixture['user']->tenants()->syncWithoutDetaching([
(int) $missingTenant->getKey() => ['role' => 'owner'],
(int) $ambiguousTenant->getKey() => ['role' => 'owner'],
(int) $notComparedTenant->getKey() => ['role' => 'owner'],
(int) $staleTenant->getKey() => ['role' => 'owner'],
]);
$this->assignTenantToBaselineProfile($fixture['profile'], $missingTenant);
$this->assignTenantToBaselineProfile($fixture['profile'], $ambiguousTenant);
$this->assignTenantToBaselineProfile($fixture['profile'], $notComparedTenant);
$this->assignTenantToBaselineProfile($fixture['profile'], $staleTenant);
$this->makeBaselineCompareMatrixRun($matchTenant, $fixture['profile'], $fixture['snapshot']);
$differRun = $this->makeBaselineCompareMatrixRun($differTenant, $fixture['profile'], $fixture['snapshot']);
$this->makeBaselineCompareMatrixFinding($differTenant, $fixture['profile'], $differRun, 'wifi-corp-profile', [
'evidence_jsonb' => [
'subject_key' => 'wifi-corp-profile',
'change_type' => 'different_version',
],
]);
$missingRun = $this->makeBaselineCompareMatrixRun($missingTenant, $fixture['profile'], $fixture['snapshot']);
$this->makeBaselineCompareMatrixFinding($missingTenant, $fixture['profile'], $missingRun, 'wifi-corp-profile', [
'evidence_jsonb' => [
'subject_key' => 'wifi-corp-profile',
'change_type' => 'missing_policy',
],
]);
$ambiguousRun = $this->makeBaselineCompareMatrixRun(
$ambiguousTenant,
$fixture['profile'],
$fixture['snapshot'],
[
'baseline_compare' => [
'evidence_gaps' => [
'count' => 1,
'by_reason' => ['ambiguous_match' => 1],
'subjects' => [
$this->baselineCompareMatrixGap('deviceConfiguration', 'wifi-corp-profile', [
'reason_code' => 'ambiguous_match',
'resolution_outcome' => 'ambiguous_match',
]),
],
],
],
],
);
$this->makeBaselineCompareMatrixFinding($ambiguousTenant, $fixture['profile'], $ambiguousRun, 'wifi-corp-profile', [
'evidence_jsonb' => [
'subject_key' => 'wifi-corp-profile',
'change_type' => 'missing_policy',
],
]);
$this->makeBaselineCompareMatrixRun(
$notComparedTenant,
$fixture['profile'],
$fixture['snapshot'],
[
'baseline_compare' => [
'coverage' => [
'proof' => true,
'effective_types' => ['deviceConfiguration'],
'covered_types' => [],
'uncovered_types' => ['deviceConfiguration'],
],
],
],
);
$this->makeBaselineCompareMatrixRun(
$staleTenant,
$fixture['profile'],
$fixture['snapshot'],
[],
[
'completed_at' => $fixture['snapshot']->captured_at->copy()->subDay(),
'context' => [
'baseline_profile_id' => (int) $fixture['profile']->getKey(),
'baseline_snapshot_id' => (int) $fixture['snapshot']->getKey() - 1,
'baseline_compare' => [
'coverage' => [
'proof' => true,
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
],
'evidence_gaps' => [
'count' => 0,
'by_reason' => [],
'subjects' => [],
],
],
],
],
);
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
$wifiRow = collect($matrix['denseRows'])->first(
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
);
$cellsByTenant = collect($wifiRow['cells'] ?? [])
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => $cell])
->all();
expect($cellsByTenant[(int) $matchTenant->getKey()]['state'] ?? null)->toBe('match')
->and($cellsByTenant[(int) $matchTenant->getKey()]['attentionLevel'] ?? null)->toBe('aligned')
->and($cellsByTenant[(int) $differTenant->getKey()]['state'] ?? null)->toBe('differ')
->and($cellsByTenant[(int) $differTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
->and($cellsByTenant[(int) $differTenant->getKey()]['reasonSummary'] ?? null)->toBe('A baseline compare finding exists for this subject.')
->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing')
->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous')
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven')
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')
->and($cellsByTenant[(int) $staleTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended');
});
it('applies policy, state, severity, and subject-focus filters honestly without changing compare truth', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$visibleRun = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
[],
[
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$visibleRun,
'wifi-corp-profile',
['severity' => 'critical'],
);
$deviceOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
'policyTypes' => ['deviceConfiguration'],
]);
$driftOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
'states' => ['differ'],
'severities' => ['critical'],
]);
$subjectFocus = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
'focusedSubjectKey' => 'wifi-corp-profile',
]);
expect(count($deviceOnly['denseRows']))->toBe(1)
->and($deviceOnly['denseRows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
->and(count($driftOnly['denseRows']))->toBe(1)
->and($driftOnly['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
->and(count($subjectFocus['denseRows']))->toBe(1)
->and($subjectFocus['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
});
it('emits compact single-tenant results from the visible set only when one tenant remains in scope', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => 'critical'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$viewer->tenants()->syncWithoutDetaching([
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
]);
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $viewer);
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
->and($matrix['reference']['visibleTenantCount'])->toBe(1)
->and($matrix['compactResults'])->toHaveCount(2)
->and(collect($matrix['compactResults'])->pluck('tenantId')->unique()->all())->toBe([(int) $fixture['visibleTenant']->getKey()])
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['state'] ?? null)->toBe('differ')
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['attentionLevel'] ?? null)->toBe('needs_attention');
});

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareMatrix;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Baselines\BaselineCompareService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('fans out compare starts across the visible assigned set without creating a workspace umbrella run', function (): void {
Queue::fake();
$fixture = $this->makeBaselineCompareMatrixFixture();
$readonlyTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Readonly Contoso',
]);
$fixture['user']->tenants()->syncWithoutDetaching([
(int) $readonlyTenant->getKey() => ['role' => 'readonly'],
]);
$this->assignTenantToBaselineProfile($fixture['profile'], $readonlyTenant);
$service = app(BaselineCompareService::class);
$existingRunResult = $service->startCompareForProfile(
$fixture['profile'],
$fixture['visibleTenantTwo'],
$fixture['user'],
);
expect($existingRunResult['ok'] ?? false)->toBeTrue();
$result = $service->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
expect($result['visibleAssignedTenantCount'])->toBe(3)
->and($result['queuedCount'])->toBe(1)
->and($result['alreadyQueuedCount'])->toBe(1)
->and($result['blockedCount'])->toBe(1);
$launchStates = collect($result['targets'])
->mapWithKeys(static fn (array $target): array => [(int) $target['tenantId'] => (string) $target['launchState']])
->all();
expect($launchStates[(int) $fixture['visibleTenant']->getKey()] ?? null)->toBe('queued')
->and($launchStates[(int) $fixture['visibleTenantTwo']->getKey()] ?? null)->toBe('already_queued')
->and($launchStates[(int) $readonlyTenant->getKey()] ?? null)->toBe('blocked');
Queue::assertPushed(CompareBaselineToTenantJob::class);
$activeRuns = OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare')
->get();
expect($activeRuns)->toHaveCount(2)
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0);
});
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
Queue::fake();
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
Livewire::actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
->assertActionVisible('compareAssignedTenants')
->assertActionEnabled('compareAssignedTenants')
->callAction('compareAssignedTenants')
->assertStatus(200);
Queue::assertPushed(CompareBaselineToTenantJob::class, 2);
expect(OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare')
->whereNull('tenant_id')
->count())->toBe(0);
});

View File

@ -12,8 +12,13 @@
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\DB;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(BuildsBaselineCompareMatrixFixtures::class);
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
bindFailHardGraphClient();
@ -100,3 +105,28 @@
expect($code)->toBeString();
expect($code)->toContain('->chunk(');
});
it('keeps matrix aggregation query-bounded over the visible assigned set', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
foreach (range(1, 6) as $index) {
$tenant = \App\Models\Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Matrix Tenant '.$index,
]);
$fixture['user']->tenants()->syncWithoutDetaching([
(int) $tenant->getKey() => ['role' => 'owner'],
]);
$this->assignTenantToBaselineProfile($fixture['profile'], $tenant);
$this->makeBaselineCompareMatrixRun($tenant, $fixture['profile'], $fixture['snapshot']);
}
DB::enableQueryLog();
DB::flushQueryLog();
app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(20);
});

View File

@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Concerns;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
trait BuildsBaselineCompareMatrixFixtures
{
/**
* @return array{
* user: User,
* workspace: Workspace,
* profile: BaselineProfile,
* snapshot: BaselineSnapshot,
* visibleTenant: Tenant,
* visibleTenantTwo: Tenant,
* hiddenTenant: Tenant,
* subjects: array<string, BaselineSnapshotItem>
* }
*/
protected function makeBaselineCompareMatrixFixture(
string $viewerRole = 'owner',
?string $workspaceRole = null,
): array {
[$user, $visibleTenant] = createUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole);
$workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id);
$profile = BaselineProfile::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => BaselineProfileStatus::Active->value,
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'name' => 'Visible-set baseline',
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration', 'compliancePolicy'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $workspace->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subHours(2),
'completed_at' => now()->subHours(2),
]);
$profile->forceFill([
'active_snapshot_id' => (int) $snapshot->getKey(),
])->save();
$visibleTenantTwo = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Northwind',
]);
$hiddenTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Hidden Fabrikam',
]);
$user->tenants()->syncWithoutDetaching([
(int) $visibleTenantTwo->getKey() => ['role' => 'owner'],
]);
WorkspaceMembership::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
], [
'role' => $workspaceRole ?? $viewerRole,
]);
$this->assignTenantToBaselineProfile($profile, $visibleTenant);
$this->assignTenantToBaselineProfile($profile, $visibleTenantTwo);
$this->assignTenantToBaselineProfile($profile, $hiddenTenant);
$subjects = [
'wifi-corp-profile' => $this->makeBaselineCompareMatrixSubject(
$snapshot,
'deviceConfiguration',
'wifi-corp-profile',
'WiFi Corp Profile',
'dc:wifi-corp-profile',
),
'windows-compliance' => $this->makeBaselineCompareMatrixSubject(
$snapshot,
'compliancePolicy',
'windows-compliance',
'Windows Compliance',
'cp:windows-compliance',
),
];
return [
'user' => $user,
'workspace' => $workspace,
'profile' => $profile,
'snapshot' => $snapshot,
'visibleTenant' => $visibleTenant,
'visibleTenantTwo' => $visibleTenantTwo,
'hiddenTenant' => $hiddenTenant,
'subjects' => $subjects,
];
}
protected function makeBaselineCompareMatrixSubject(
BaselineSnapshot $snapshot,
string $policyType,
string $subjectKey,
string $displayName,
?string $subjectExternalId = null,
): BaselineSnapshotItem {
return BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'subject_external_id' => $subjectExternalId ?? $policyType.':'.$subjectKey,
'meta_jsonb' => ['display_name' => $displayName],
]);
}
protected function assignTenantToBaselineProfile(BaselineProfile $profile, Tenant $tenant): BaselineTenantAssignment
{
return BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $profile->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'tenant_id' => (int) $tenant->getKey(),
]);
}
/**
* @param array<string, mixed> $contextOverrides
* @param array<string, mixed> $attributes
*/
protected function makeBaselineCompareMatrixRun(
Tenant $tenant,
BaselineProfile $profile,
BaselineSnapshot $snapshot,
array $contextOverrides = [],
array $attributes = [],
): OperationRun {
$defaults = [
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'initiator_name' => 'Spec190 Matrix',
'summary_counts' => [
'matched_items' => 1,
'different_items' => 0,
'missing_items' => 0,
'unexpected_items' => 0,
],
'context' => array_replace_recursive([
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => null,
'subjects_total' => 2,
'fidelity' => 'content',
'coverage' => [
'proof' => true,
'effective_types' => ['deviceConfiguration', 'compliancePolicy'],
'covered_types' => ['deviceConfiguration', 'compliancePolicy'],
'uncovered_types' => [],
],
'evidence_gaps' => [
'count' => 0,
'by_reason' => [],
'subjects' => [],
],
],
], $contextOverrides),
'started_at' => now()->subMinutes(5),
'completed_at' => now()->subMinute(),
];
return OperationRun::factory()->create(array_replace_recursive($defaults, $attributes));
}
/**
* @param array<string, mixed> $overrides
*/
protected function makeBaselineCompareMatrixFinding(
Tenant $tenant,
BaselineProfile $profile,
OperationRun $run,
string $subjectKey,
array $overrides = [],
): Finding {
$defaults = [
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => 'baseline_profile:'.(int) $profile->getKey(),
'baseline_operation_run_id' => (int) $run->getKey(),
'current_operation_run_id' => (int) $run->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'subject:'.$subjectKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
'evidence_jsonb' => [
'subject_key' => $subjectKey,
'change_type' => 'different_version',
],
];
return Finding::factory()->create(array_replace_recursive($defaults, $overrides));
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
protected function baselineCompareMatrixGap(string $policyType, string $subjectKey, array $overrides = []): array
{
return BaselineSubjectResolutionFixtures::structuredGap(array_replace([
'policy_type' => $policyType,
'subject_key' => $subjectKey,
], $overrides));
}
protected function setAdminWorkspaceContext(User $user, Workspace|int $workspace, ?Tenant $rememberedTenant = null): array
{
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
$session = [
WorkspaceContext::SESSION_KEY => $workspaceId,
];
if ($rememberedTenant instanceof Tenant) {
$session[WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY] = [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
];
}
$this->actingAs($user)->withSession($session);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
if ($rememberedTenant instanceof Tenant) {
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
]);
}
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
return $session;
}
}

View File

@ -10,6 +10,7 @@
use App\Models\EvidenceSnapshotItem;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Support\Auth\Capabilities;
@ -20,6 +21,7 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
@ -53,6 +55,17 @@ function seedEvidenceDomain(Tenant $tenant): void
OperationRun::factory()->forTenant($tenant)->create();
}
function evidenceSnapshotHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
it('renders the evidence list page for an authorized user', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
@ -125,27 +138,47 @@ function seedEvidenceDomain(Tenant $tenant): void
it('renders the view page for an active snapshot', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create();
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'operation_run_id' => (int) $run->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(),
]);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->assertOk();
->assertOk()
->assertSee('Related context')
->assertSee('Review pack');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_snapshot')
->assertActionVisible('expire_snapshot');
expect(collect(evidenceSnapshotHeaderActions($component))
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
->filter()
->values()
->all())
->toEqualCanonicalizing(['refresh_snapshot', 'expire_snapshot'])
->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all())
->toContain('operation_run', 'review_pack');
});
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {

View File

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\User;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('renders dense auto mode with sticky subject behavior and compact support surfaces', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$run,
'wifi-corp-profile',
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('Visible-set baseline')
->assertSee('Requested: Auto mode. Resolved: Dense mode.')
->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false)
->assertDontSee('Passive auto-refresh every 5 seconds')
->assertSee('Grouped legend')
->assertSee('Apply filters')
->assertSee('Compact unlocks at one visible tenant')
->assertSee('Dense multi-tenant scan')
->assertSee('Open finding')
->assertSee('More follow-up')
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false)
->assertSee('sticky left-0', false);
});
it('stages heavy filter changes until apply and preserves mode and subject continuity in drilldown urls', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$finding = $this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
$component = Livewire::withQueryParams([
'mode' => 'dense',
'policy_type' => ['deviceConfiguration'],
'state' => ['differ'],
'severity' => ['high'],
'subject_key' => 'wifi-corp-profile',
])
->actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
->assertSet('requestedMode', 'dense')
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertSee('Focused subject')
->assertSee('wifi-corp-profile');
expect($component->instance()->hasStagedFilterChanges())->toBeFalse();
$component
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
->set('draftSelectedStates', ['match'])
->set('draftSelectedSeverities', [])
->set('draftTenantSort', 'freshness_urgency')
->set('draftSubjectSort', 'display_name')
->assertSee('Draft filters are staged');
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
$component->call('applyFilters')->assertRedirect(
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&policy_type%5B0%5D=compliancePolicy&state%5B0%5D=match&tenant_sort=freshness_urgency&subject_sort=display_name&subject_key=wifi-corp-profile'
);
$applied = Livewire::withQueryParams([
'mode' => 'dense',
'policy_type' => ['compliancePolicy'],
'state' => ['match'],
'tenant_sort' => 'freshness_urgency',
'subject_sort' => 'display_name',
'subject_key' => 'wifi-corp-profile',
])
->actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]);
$tenantCompareUrl = $applied->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
$findingUrl = $applied->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
expect(urldecode((string) $tenantCompareUrl))->toContain('mode=dense')
->and(urldecode((string) $tenantCompareUrl))->toContain('subject_key=wifi-corp-profile')
->and(urldecode((string) $findingUrl))->toContain('mode=dense')
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
$applied->call('resetFilters')->assertRedirect(
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense'
);
});
it('resolves auto to compact for the visible-set-only single-tenant edge case and still allows dense override', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => 'critical'],
);
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$viewer->tenants()->syncWithoutDetaching([
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
]);
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace'], $fixture['visibleTenant']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('Requested: Auto mode. Resolved: Compact mode.')
->assertSee('Compact compare results')
->assertSee('data-testid="baseline-compare-matrix-compact-shell"', false)
->assertDontSee('data-testid="baseline-compare-matrix-dense-shell"', false);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense')
->assertOk()
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false);
});
it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$fixture['snapshot']->markIncomplete();
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('No usable reference snapshot')
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
});
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$fixture['profile']->tenantAssignments()->delete();
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('No assigned tenants');
});
it('renders an empty state when the assigned set is not visible to the current actor', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('No visible assigned tenants');
});
it('renders a passive auto-refresh cue instead of a perpetual blocking state while compare runs remain active', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
attributes: [
'status' => \App\Support\OperationRunStatus::Queued->value,
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
'completed_at' => null,
'started_at' => now(),
],
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('Passive auto-refresh every 5 seconds')
->assertSee('wire:poll.5s="pollMatrix"', false)
->assertSee('Refresh matrix');
});
it('renders a filtered zero-result state that preserves mode and offers reset filters as the primary cta', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
->assertOk()
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertSee('No rows match the current filters')
->assertSee('Reset filters');
});

View File

@ -9,12 +9,26 @@
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function baselineProfileCaptureHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -78,7 +92,7 @@
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->assertActionHasLabel('capture', 'Capture baseline (full content)')
@ -86,6 +100,16 @@
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertStatus(200);
$topLevelActionNames = collect(baselineProfileCaptureHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->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();
expect($topLevelActionNames)->toBe(['capture']);
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
$run = OperationRun::query()

View File

@ -1,5 +1,6 @@
<?php
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
@ -8,9 +9,23 @@
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
function baselineProfileHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
Queue::fake();
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
@ -132,3 +147,85 @@
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$component = Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
&& $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
$topLevelActionNames = collect(baselineProfileHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->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();
$moreGroup = collect(baselineProfileHeaderActions($component))
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible());
$moreActionNames = collect($moreGroup?->getActions() ?? [])
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
expect($topLevelActionNames)->toBe(['compareNow'])
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
->and($moreActionNames)->toEqualCanonicalizing(['compareAssignedTenants', 'edit'])
->and(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
->toContain('compare_matrix', 'baseline_snapshot');
});
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members after the navigation move', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('compareAssignedTenants')
->assertActionDisabled('compareAssignedTenants');
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
->toContain('compare_matrix');
});

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('keeps database notifications enabled without background polling on every panel', function (): void {
foreach (['admin', 'tenant', 'system'] as $panelId) {
$panel = Filament::getPanel($panelId);
expect($panel->hasDatabaseNotifications())->toBeTrue();
expect($panel->getDatabaseNotificationsPollingInterval())->toBeNull();
}
});
it('renders the admin notifications modal without a polling attribute', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin');
$response->assertSuccessful();
$html = $response->getContent();
expect($html)->toContain('wire:name="Filament\\Livewire\\DatabaseNotifications"');
preg_match('/<[^>]+wire:name="Filament\\\\Livewire\\\\DatabaseNotifications"[^>]*>/', $html, $matches);
expect($matches)->not->toBeEmpty('Expected the admin page to render the database notifications Livewire root element.');
expect($matches[0])->not->toContain('wire:poll');
expect($matches[0])->not->toContain('wire:poll.30s');
});

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\EditTenant;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function editTenantHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
function editTenantHeaderGroupLabels(Testable $component): array
{
return collect(editTenantHeaderActions($component))
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
->values()
->all();
}
function editTenantHeaderPrimaryNames(Testable $component): array
{
return collect(editTenantHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->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();
}
it('keeps related links in contextual placement and reserves the header for lifecycle actions', function (): void {
$tenant = Tenant::factory()->onboarding()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
],
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Related context')
->assertSee('Open tenant detail')
->assertSee('Resume onboarding');
expect(editTenantHeaderPrimaryNames($component))->toBe([])
->and(editTenantHeaderGroupLabels($component))->toBe([]);
});
it('keeps tenant lifecycle mutations available under the lifecycle header group with confirmation intact', function (): void {
$tenant = Tenant::factory()->active()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionEnabled('archive')
->assertActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired());
expect(editTenantHeaderPrimaryNames($component))->toBe([])
->and(editTenantHeaderGroupLabels($component))->toBe(['Lifecycle']);
});

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
use App\Models\Finding;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function findingExceptionHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
function findingExceptionHeaderNames(Testable $component): array
{
return collect(findingExceptionHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
}
it('keeps finding navigation out of the header while preserving renewal and revocation actions', function (): void {
[$requester, $tenant] = createUserWithTenant(role: 'owner');
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'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' => 'Existing compensating controls remain in place.',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
$exception = $service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
'approval_reason' => 'Accepted while remediation is scheduled.',
]);
$this->actingAs($requester);
Filament::setTenant($tenant, true);
$component = Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
->assertActionVisible('renew_exception')
->assertActionVisible('revoke_exception')
->assertActionExists('revoke_exception', fn (Action $action): bool => $action->isConfirmationRequired())
->assertSee('Related context')
->assertSee('Approval queue')
->assertSee('Open finding');
expect(findingExceptionHeaderNames($component))
->toEqualCanonicalizing(['renew_exception', 'revoke_exception'])
->not->toContain('open_finding', 'open_approval_queue');
});

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
use App\Models\TenantReview;
use App\Support\TenantReviewStatus;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function tenantReviewHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
function tenantReviewHeaderPrimaryNames(Testable $component): array
{
return collect(tenantReviewHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
}
function tenantReviewHeaderGroupLabels(Testable $component): array
{
return collect(tenantReviewHeaderActions($component))
->filter(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
->values()
->all();
}
it('keeps ready reviews to one primary action and renders related navigation in the summary context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $user);
setTenantPanelContext($tenant);
$component = Livewire::actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
->assertSee('Related context')
->assertSee('Evidence snapshot');
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['publish_review'])
->and(tenantReviewHeaderGroupLabels($component))->toBe(['More', 'Danger']);
});
it('promotes executive-pack export as the only visible primary action after publication', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $user);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
setTenantPanelContext($tenant);
$component = Livewire::actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
->assertActionVisible('export_executive_pack')
->assertActionEnabled('export_executive_pack');
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['export_executive_pack'])
->and(tenantReviewHeaderGroupLabels($component))->toContain('More')
->and(tenantReviewHeaderPrimaryNames($component))->not->toContain('refresh_review', 'publish_review');
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\AuditLog;
use App\Models\Tenant;
@ -14,7 +15,7 @@
uses(RefreshDatabase::class);
describe('Tenant View header action UI enforcement', function () {
it('shows edit and archive actions as visible but disabled for readonly members', function () {
it('keeps archive visible in the workflow header and moves edit/provider navigation into contextual unavailable entries for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
@ -23,19 +24,19 @@
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('edit')
->assertActionDisabled('edit')
->assertActionExists('edit', function (Action $action): bool {
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
})
->assertActionVisible('archive')
->assertActionDisabled('archive')
->assertActionExists('archive', function (Action $action): bool {
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
});
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('authorization_denied')
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
});
it('shows edit and archive actions as enabled for owner members', function () {
it('keeps archive enabled for owner members and exposes edit/provider navigation in contextual related content', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
@ -44,10 +45,13 @@
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('edit')
->assertActionEnabled('edit')
->assertActionVisible('archive')
->assertActionEnabled('archive');
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('available')
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
});
it('does not execute the archive action for readonly members (silently blocked by Filament)', function () {
@ -85,11 +89,9 @@
$this->actingAs($user);
Filament::setTenant(null, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionExists('related_onboarding', function (Action $action): bool {
return $action->getLabel() === 'Resume onboarding';
});
expect(collect(TenantResource::tenantViewContextEntries($tenant))
->firstWhere('key', 'related_onboarding')['value'] ?? null)
->toBe('Resume onboarding');
});
it('shows a cancelled-onboarding label and repairs stale onboarding tenant status when the linked draft was cancelled', function () {
@ -126,10 +128,8 @@
->where('action', \App\Support\Audit\AuditActionId::TenantReturnedToDraft->value)
->exists())->toBeTrue();
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionExists('related_onboarding', function (Action $action): bool {
return $action->getLabel() === 'View cancelled onboarding draft';
});
expect(collect(TenantResource::tenantViewContextEntries($tenant))
->firstWhere('key', 'related_onboarding')['value'] ?? null)
->toBe('View cancelled onboarding draft');
});
});

View File

@ -15,6 +15,13 @@
expect($js)
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
->toContain("window.addEventListener('unhandledrejection'")
->toContain('window.fetch = async (...args) =>')
->toContain('XMLHttpRequest.prototype.open = function (method, url, ...rest)')
->toContain('const transport = resolveTransportMetadata(normalizedReason)')
->toContain('requestUrl: transport?.requestUrl ?? null')
->toContain('requestMethod: transport?.method ?? null')
->toContain('transportType: transport?.transportType ?? null')
->toContain('requestUrl: payload.requestUrl')
->toContain('isExpectedBackgroundTransportFailure')
->toContain("document.visibilityState !== 'visible'")
->toContain('document.hasFocus')

View File

@ -78,6 +78,8 @@
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\FindingException;
@ -207,6 +209,54 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}
});
it('discovers the baseline compare matrix page as an admin-scoped drilldown-only surface', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$matrixPage = $components->get(\App\Filament\Pages\BaselineCompareMatrix::class);
expect($matrixPage)->not->toBeNull('BaselineCompareMatrix should be discovered by action surface discovery')
->and($matrixPage?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
$declaration = \App\Filament\Pages\BaselineCompareMatrix::actionSurfaceDeclaration();
expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? ''))
->toContain('compare fan-out')
->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
->toContain('forbids row click');
});
it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
Livewire::actingAs($user)
->test(\App\Filament\Pages\BaselineCompareMatrix::class, ['record' => $profile->getKey()])
->assertActionVisible('backToBaselineProfile')
->assertActionVisible('compareAssignedTenants')
->assertActionDisabled('compareAssignedTenants')
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to compare the visible assigned set.');
});
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -1946,3 +1996,45 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
expect((string) $run?->initiator_name)->toBe((string) $user->name);
});
it('documents the spec 192 workflow-heavy exception and reference inventory', function (): void {
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
$workflowHeavy = collect($inventory)
->filter(fn (array $surface): bool => $surface['classification'] === 'workflow_heavy_special_type')
->keys()
->values()
->all();
$referencePages = collect($inventory)
->filter(fn (array $surface): bool => $surface['classification'] === 'compliant_reference')
->keys()
->values()
->all();
expect($workflowHeavy)->toBe([\App\Filament\Resources\TenantResource\Pages\ViewTenant::class])
->and($referencePages)->toEqualCanonicalizing([
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
]);
});
it('keeps spec 192 remediated pages out of the enterprise-detail layout rollout', function (): void {
foreach ([
\App\Filament\Resources\BaselineProfileResource::class,
\App\Filament\Resources\EvidenceSnapshotResource::class,
\App\Filament\Resources\FindingExceptionResource::class,
\App\Filament\Resources\TenantReviewResource::class,
\App\Filament\Resources\TenantResource::class,
\App\Filament\Resources\TenantResource\Pages\EditTenant::class,
\App\Filament\Resources\TenantResource\Pages\ViewTenant::class,
] as $className) {
$source = file_get_contents((string) (new ReflectionClass($className))->getFileName()) ?: '';
expect($source)
->not->toContain('EnterpriseDetail')
->not->toContain('enterprise-detail/header');
}
});

View File

@ -229,3 +229,11 @@ className: $className,
expect($result->hasIssues())->toBeTrue();
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
});
it('accepts the repository spec 192 inventory even when only inventory validation runs', function (): void {
$validator = ActionSurfaceValidator::withBaselineExemptions();
$result = $validator->validateComponents([]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});

View File

@ -1,5 +1,7 @@
<?php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use Illuminate\Support\Collection;
it('does not contain ad-hoc status-like badge semantics', function () {
@ -113,3 +115,9 @@
expect($hits)->toBeEmpty("Ad-hoc status-like badge semantics found:\n".implode("\n", $hits));
});
it('keeps baseline compare matrix state and freshness surfaces on centralized badge domains', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended');
});

View File

@ -2,6 +2,8 @@
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\Badges\OperatorStateClassification;
@ -41,3 +43,15 @@
expect($violations)->toBeEmpty("Overloaded bare operator labels remain in the first-slice taxonomy:\n".implode("\n", $violations));
});
it('keeps baseline compare matrix trust aligned with the adopted trust taxonomy and qualified labels', function (): void {
$matrixTrust = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, 'diagnostic_only');
$operatorTrust = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, 'diagnostic_only');
$matrixMissing = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing');
$matrixStale = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result');
expect($matrixTrust->label)->toBe($operatorTrust->label)
->and($matrixTrust->color)->toBe($operatorTrust->color)
->and($matrixMissing->label)->toBe('Missing from tenant')
->and($matrixStale->label)->toBe('Result stale');
});

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Resources\TenantResource\Pages\EditTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
function spec192RecordPageSource(string $className): string
{
$reflection = new ReflectionClass($className);
$path = $reflection->getFileName();
expect($path)->toBeString();
return file_get_contents((string) $path) ?: '';
}
it('keeps the spec 192 record-page inventory complete and explicitly classified', function (): void {
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
expect(array_keys($inventory))->toEqualCanonicalizing([
\App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile::class,
\App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot::class,
\App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException::class,
\App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview::class,
EditTenant::class,
ViewTenant::class,
\App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection::class,
\App\Filament\Resources\FindingResource\Pages\ViewFinding::class,
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
]);
expect(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class))
->not->toBeNull()
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['classification'] ?? null)->toBe('workflow_heavy_special_type')
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['exceptionReason'] ?? null)->toContain('workflow-heavy hub')
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['classification'] ?? null)->toBe('remediation_required')
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['allowsNoPrimaryAction'] ?? null)->toBeTrue();
});
it('keeps the spec 192 inventory valid inside the action-surface validator', function (): void {
$result = ActionSurfaceValidator::withBaselineExemptions()->validateComponents([]);
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('keeps remediated spec 192 pages off the enterprise-detail body-layout builder', function (): void {
foreach ([
BaselineProfileResource::class,
EvidenceSnapshotResource::class,
FindingExceptionResource::class,
TenantReviewResource::class,
TenantResource::class,
EditTenant::class,
ViewTenant::class,
] as $className) {
expect(spec192RecordPageSource($className))
->not->toContain('EnterpriseDetail')
->not->toContain('enterprise-detail/header');
}
});

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\FindingResource;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('returns 404 for non-members on the workspace baseline compare matrix', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$nonMember = User::factory()->create();
$this->actingAs($nonMember)->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
]);
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertNotFound();
});
it('returns 403 for workspace members missing baseline view capability on the matrix route', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$viewer = User::factory()->create();
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'readonly',
]);
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
$this->actingAs($viewer)->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
]);
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertForbidden();
});
it('returns 404 for matrix tenant drilldowns when the actor is not a tenant member', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$nonMember = User::factory()->create();
$this->actingAs($nonMember)->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
]);
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
$fixture['profile'],
subjectKey: 'wifi-corp-profile',
)->toQuery();
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
->assertNotFound();
});
it('returns 403 for matrix tenant drilldowns when tenant view capability is missing', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$resolver = \Mockery::mock(CapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(CapabilityResolver::class, $resolver);
$this->actingAs($fixture['user']);
$fixture['visibleTenant']->makeCurrent();
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
$fixture['profile'],
subjectKey: 'wifi-corp-profile',
tenant: $fixture['visibleTenant'],
)->toQuery();
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
->assertForbidden();
});
it('returns 403 for matrix finding drilldowns when findings view capability is missing', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$finding = $this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
);
$this->actingAs($fixture['user']);
$fixture['visibleTenant']->makeCurrent();
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
$fixture['profile'],
subjectKey: 'wifi-corp-profile',
tenant: $fixture['visibleTenant'],
)->toQuery();
$this->get(FindingResource::getUrl('view', [
'record' => $finding,
...$query,
], tenant: $fixture['visibleTenant']))
->assertForbidden();
});

View File

@ -5,9 +5,22 @@
use App\Filament\Resources\TenantResource\Pages\EditTenant;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
function editTenantUiHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
describe('Edit tenant archive action UI enforcement', function () {
it('shows archive action as visible but disabled for manager members', function () {
$tenant = Tenant::factory()->create();
@ -18,7 +31,7 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionDisabled('archive')
->assertActionExists('archive', function (Action $action): bool {
@ -30,6 +43,12 @@
->callMountedAction()
->assertSuccessful();
expect(collect(editTenantUiHeaderActions($component))
->filter(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
->values()
->all())->toBe(['Lifecycle']);
$tenant->refresh();
expect($tenant->trashed())->toBeFalse();
});
@ -43,7 +62,7 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionEnabled('archive')
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
@ -51,6 +70,12 @@
->callMountedAction()
->assertHasNoActionErrors();
expect(collect(editTenantUiHeaderActions($component))
->filter(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
->values()
->all())->toBe(['Lifecycle']);
$tenant->refresh();
expect($tenant->trashed())->toBeTrue();
});
@ -75,10 +100,16 @@
Filament::setTenant($tenant, true);
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('restore')
->assertActionEnabled('restore')
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired())
->assertActionHidden('archive');
expect(collect(editTenantUiHeaderActions($component))
->filter(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
->values()
->all())->toBe(['Lifecycle']);
});
});

View File

@ -48,15 +48,19 @@ function tenantActionSurfaceSearchTitles($results): array
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionHidden('archive')
->assertActionHidden('restore');
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
->toContain('related_onboarding');
Livewire::actingAs($user)
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionHidden('archive')
->assertActionHidden('restore');
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
->toContain('related_onboarding');
});
it('keeps active lifecycle actions consistent across list, view, and edit surfaces', function (): void {
@ -76,14 +80,18 @@ function tenantActionSurfaceSearchTitles($results): array
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionHidden('restore')
->assertActionHidden('related_onboarding');
->assertActionHidden('restore');
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
->not->toContain('related_onboarding');
Livewire::actingAs($user)
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionHidden('restore')
->assertActionHidden('related_onboarding');
->assertActionHidden('restore');
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
->not->toContain('related_onboarding');
});
it('keeps draft lifecycle actions consistent across list, view, and edit surfaces', function (): void {
@ -113,15 +121,19 @@ function tenantActionSurfaceSearchTitles($results): array
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionHidden('archive')
->assertActionHidden('restore');
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
->toContain('related_onboarding');
Livewire::actingAs($user)
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionHidden('archive')
->assertActionHidden('restore');
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
->toContain('related_onboarding');
});
it('keeps archived lifecycle actions consistent across list, view, and edit surfaces', function (): void {

View File

@ -45,10 +45,12 @@
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding')
->assertActionHidden('archive')
->assertActionHidden('restore');
expect(collect(TenantResource::tenantViewContextEntries($tenant))
->firstWhere('key', 'related_onboarding')['value'] ?? null)
->toBe('Resume onboarding');
})->with([
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
@ -91,8 +93,10 @@
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('restore')
->assertActionHidden('archive')
->assertActionHidden('related_onboarding');
->assertActionHidden('archive');
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
->not->toContain('related_onboarding');
});
it('shows verification only for active tenants on administrative list and detail surfaces', function (
@ -190,7 +194,10 @@
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $onboardingTenant->getRouteKey()])
->assertActionHidden('archive')
->assertActionVisible('related_onboarding');
->assertActionHidden('restore');
expect(collect(TenantResource::tenantViewContextEntries($onboardingTenant))->pluck('key')->all())
->toContain('related_onboarding');
});
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
@ -228,8 +235,10 @@
Livewire::actingAs($user)
->test(ViewTenant::class, ['record' => $archivedTenant->getRouteKey()])
->assertActionVisible('restore')
->assertActionHidden('archive')
->assertActionHidden('related_onboarding');
->assertActionHidden('archive');
expect(collect(TenantResource::tenantViewContextEntries($archivedTenant))->pluck('key')->all())
->not->toContain('related_onboarding');
});
it('refuses lifecycle-invalid archive and restore mutations without changing tenant state', function (): void {

View File

@ -11,8 +11,22 @@
use App\Models\User;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
function tenantReviewContractHeaderActions(Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
it('disables tenant-review global search while keeping the view page available for resource inspection', function (): void {
$reflection = new ReflectionClass(TenantReviewResource::class);
@ -103,6 +117,37 @@
->assertActionMounted('archive_review');
});
it('keeps tenant review header hierarchy to one primary action and moves related links into summary context', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $owner);
setTenantPanelContext($tenant);
$this->actingAs($owner)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee('Related context')
->assertSee('Evidence snapshot');
$component = Livewire::actingAs($owner)
->test(ViewTenantReview::class, ['record' => $review->getKey()]);
$topLevelActionNames = collect(tenantReviewContractHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
$groupLabels = collect(tenantReviewContractHeaderActions($component))
->filter(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
->values()
->all();
expect($topLevelActionNames)->toBe(['publish_review'])
->and($groupLabels)->toBe(['More', 'Danger']);
});
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
$tenant = Tenant::factory()->create();
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps baseline compare matrix state badges through centralized semantics', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'match')->label)->toBe('Reference aligned')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing')->label)->toBe('Missing from tenant')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'ambiguous')->label)->toBe('Identity ambiguous')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'not_compared')->label)->toBe('Not compared')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale');
});
it('maps baseline compare matrix freshness badges through centralized semantics', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'fresh')->label)->toBe('Current result')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'never_compared')->label)->toBe('Not compared yet')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'unknown')->label)->toBe('Freshness unknown');
});
it('reuses operator trustworthiness semantics for matrix trust badges', function (): void {
foreach (['trustworthy', 'limited_confidence', 'diagnostic_only', 'unusable'] as $state) {
$matrixSpec = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, $state);
$operatorSpec = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state);
expect($matrixSpec->label)->toBe($operatorSpec->label)
->and($matrixSpec->color)->toBe($operatorSpec->color)
->and($matrixSpec->icon)->toBe($operatorSpec->icon);
}
});

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Workspace Baseline Compare Matrix V1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-11
**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
- Validation pass completed on 2026-04-11.
- Route names, capability names, and action-surface identifiers remain in the spec because the repository template and constitution require explicit operator-surface, RBAC, and action-surface contracts.
- The spec intentionally keeps the matrix derived from existing baseline, compare, finding, and run truth and rejects new persisted cross-tenant compare artifacts in V1.

View File

@ -0,0 +1,468 @@
openapi: 3.1.0
info:
title: Workspace Baseline Compare Matrix Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for workspace baseline compare matrix reads, drilldown context, and compare-all launch behavior
description: |
This contract is an internal planning artifact for Spec 190. The affected routes still
render HTML through Filament and Livewire. The schemas below define the bounded read
models and action payloads that must be derivable from existing baseline, compare,
finding, and run truth before the matrix surface can render or trigger compare-all.
servers:
- url: /internal
x-baseline-compare-matrix-consumers:
- surface: baseline.profile.detail
sourceFiles:
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
mustRender:
- baseline_profile_id
- reference_snapshot_id
- visible_tenant_count
- open_compare_matrix_action
- compare_assigned_tenants_action
- surface: baseline.compare.matrix
sourceFiles:
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
mustRender:
- reference
- filters
- tenant_summaries
- subject_summaries
- matrix_rows
- freshness_legend
- trust_legend
mustAccept:
- policy_type
- state
- severity
- tenant_sort
- subject_sort
- subject_key
- surface: tenant.compare.drilldown
sourceFiles:
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
- apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
mustRender:
- matrix_source_context
- baseline_profile_id
- subject_key
- back_link
paths:
/admin/baseline-profiles/{profile}/compare-matrix:
get:
summary: Render the workspace baseline compare matrix for one selected baseline profile
operationId: viewBaselineCompareMatrix
parameters:
- name: profile
in: path
required: true
schema:
type: integer
- name: policy_type
in: query
required: false
schema:
type: array
items:
type: string
- name: state
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
- name: severity
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
- name: tenant_sort
in: query
required: false
schema:
$ref: '#/components/schemas/TenantSort'
- name: subject_sort
in: query
required: false
schema:
$ref: '#/components/schemas/SubjectSort'
- name: subject_key
in: query
required: false
schema:
type: string
description: Optional focused subject row selector for subject-first drilldown within the same matrix route.
responses:
'200':
description: Rendered workspace matrix plus a fully derived matrix bundle
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.baseline-compare-matrix+json:
schema:
$ref: '#/components/schemas/BaselineCompareMatrixBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-assigned-tenants:
post:
summary: Launch compare for all visible assigned tenants under the selected baseline profile
operationId: compareAssignedTenants
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
responses:
'200':
description: Compare-all launch summary derived from underlying tenant compare starts
content:
application/vnd.tenantpilot.compare-assigned-tenants+json:
schema:
$ref: '#/components/schemas/CompareAssignedTenantsLaunchResult'
'403':
description: Actor is in scope but lacks workspace baseline manage capability
'404':
description: Workspace or baseline profile is outside actor scope
components:
schemas:
MatrixCellState:
type: string
enum:
- match
- differ
- missing
- ambiguous
- not_compared
- stale_result
FindingSeverity:
type: string
enum:
- low
- medium
- high
- critical
FreshnessState:
type: string
enum:
- fresh
- stale
- never_compared
- unknown
TrustLevel:
type: string
enum:
- trustworthy
- limited_confidence
- diagnostic_only
- unusable
TenantSort:
type: string
enum:
- tenant_name
- deviation_count
- freshness_urgency
SubjectSort:
type: string
enum:
- deviation_breadth
- policy_type
- display_name
MatrixReference:
type: object
additionalProperties: false
required:
- workspaceId
- baselineProfileId
- baselineProfileName
- baselineStatus
- referenceState
- assignedTenantCount
- visibleTenantCount
properties:
workspaceId:
type: integer
baselineProfileId:
type: integer
baselineProfileName:
type: string
baselineStatus:
type: string
referenceSnapshotId:
type:
- integer
- 'null'
referenceSnapshotCapturedAt:
type:
- string
- 'null'
format: date-time
referenceState:
type: string
referenceReasonCode:
type:
- string
- 'null'
assignedTenantCount:
type: integer
visibleTenantCount:
type: integer
MatrixTenantSummary:
type: object
additionalProperties: false
required:
- tenantId
- tenantName
- freshnessState
- matchedCount
- differingCount
- missingCount
- ambiguousCount
- notComparedCount
- trustLevel
properties:
tenantId:
type: integer
tenantName:
type: string
compareRunId:
type:
- integer
- 'null'
compareRunStatus:
type:
- string
- 'null'
compareRunOutcome:
type:
- string
- 'null'
freshnessState:
$ref: '#/components/schemas/FreshnessState'
lastComparedAt:
type:
- string
- 'null'
format: date-time
matchedCount:
type: integer
differingCount:
type: integer
missingCount:
type: integer
ambiguousCount:
type: integer
notComparedCount:
type: integer
maxSeverity:
anyOf:
- $ref: '#/components/schemas/FindingSeverity'
- type: 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
MatrixSubjectSummary:
type: object
additionalProperties: false
required:
- subjectKey
- policyType
- deviationBreadth
- missingBreadth
- ambiguousBreadth
- notComparedBreadth
- trustLevel
properties:
subjectKey:
type: string
policyType:
type: string
displayName:
type:
- string
- 'null'
baselineExternalId:
type:
- string
- 'null'
deviationBreadth:
type: integer
missingBreadth:
type: integer
ambiguousBreadth:
type: integer
notComparedBreadth:
type: integer
maxSeverity:
anyOf:
- $ref: '#/components/schemas/FindingSeverity'
- type: 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
MatrixCell:
type: object
additionalProperties: false
required:
- tenantId
- subjectKey
- state
- trustLevel
- policyTypeCovered
properties:
tenantId:
type: integer
subjectKey:
type: string
state:
$ref: '#/components/schemas/MatrixCellState'
severity:
anyOf:
- $ref: '#/components/schemas/FindingSeverity'
- type: 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
reasonCode:
type:
- string
- 'null'
compareRunId:
type:
- integer
- 'null'
findingId:
type:
- integer
- 'null'
findingWorkflowState:
type:
- string
- 'null'
lastComparedAt:
type:
- string
- 'null'
format: date-time
policyTypeCovered:
type: boolean
MatrixRow:
type: object
additionalProperties: false
required:
- subject
- cells
properties:
subject:
$ref: '#/components/schemas/MatrixSubjectSummary'
cells:
type: array
items:
$ref: '#/components/schemas/MatrixCell'
MatrixFilterState:
type: object
additionalProperties: false
properties:
policyTypes:
type: array
items:
type: string
states:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
severities:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
tenantSort:
$ref: '#/components/schemas/TenantSort'
subjectSort:
$ref: '#/components/schemas/SubjectSort'
focusedSubjectKey:
type:
- string
- 'null'
BaselineCompareMatrixBundle:
type: object
additionalProperties: false
required:
- reference
- filters
- tenantSummaries
- subjectSummaries
- rows
properties:
reference:
$ref: '#/components/schemas/MatrixReference'
filters:
$ref: '#/components/schemas/MatrixFilterState'
tenantSummaries:
type: array
items:
$ref: '#/components/schemas/MatrixTenantSummary'
subjectSummaries:
type: array
items:
$ref: '#/components/schemas/MatrixSubjectSummary'
rows:
type: array
items:
$ref: '#/components/schemas/MatrixRow'
CompareAssignedTenantTarget:
type: object
additionalProperties: false
required:
- tenantId
- launchState
properties:
tenantId:
type: integer
runId:
type:
- integer
- 'null'
launchState:
type: string
enum:
- queued
- already_queued
- blocked
reasonCode:
type:
- string
- 'null'
CompareAssignedTenantsLaunchResult:
type: object
additionalProperties: false
required:
- baselineProfileId
- visibleAssignedTenantCount
- queuedCount
- alreadyQueuedCount
- blockedCount
- targets
properties:
baselineProfileId:
type: integer
visibleAssignedTenantCount:
type: integer
queuedCount:
type: integer
alreadyQueuedCount:
type: integer
blockedCount:
type: integer
targets:
type: array
items:
$ref: '#/components/schemas/CompareAssignedTenantTarget'

View File

@ -0,0 +1,192 @@
# Data Model: Workspace Baseline Compare Matrix V1
## Overview
This feature introduces no new persisted entity. The matrix is a derived workspace-scoped read model over existing baseline reference truth, tenant compare execution truth, and compare-created finding truth.
## Existing Source Truths
### Baseline reference truth
**Types**: Existing workspace-owned source of truth
**Sources**: `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotTruthResolver`, `BaselineSnapshotItem`
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `BaselineProfile` | Selects the reference standard and active snapshot pointer | `id`, `workspace_id`, `name`, `status`, `active_snapshot_id`, `scope_jsonb`, `capture_mode` |
| `BaselineSnapshot` | Represents the effective baseline reference state | `id`, `baseline_profile_id`, `workspace_id`, `state`, `completed_at`, `operation_run_id` |
| `BaselineSnapshotItem` | Defines the subject row axis for the matrix | `baseline_snapshot_id`, `policy_type`, `subject_key`, `external_id`, `display_name` |
### Assignment truth
**Type**: Existing workspace-to-tenant mapping truth
**Source**: `BaselineTenantAssignment`
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `BaselineTenantAssignment` | Defines which tenants belong in the matrix target set | `workspace_id`, `tenant_id`, `baseline_profile_id`, `override_scope_jsonb` |
### Tenant compare truth
**Type**: Existing tenant-owned execution and diagnostic truth
**Sources**: `OperationRun` with `type = baseline_compare`, `BaselineCompareStats`, `baseline_compare` run context
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `OperationRun` | Stores compare lifecycle, timestamps, summary counts, and context | `tenant_id`, `workspace_id`, `type`, `status`, `outcome`, `completed_at`, `summary_counts`, `context` |
| `OperationRun.context.baseline_compare` | Stores compare diagnostics needed for trust and no-result interpretation | `coverage`, `reason_code`, `reason_translation`, `evidence_gaps`, `subjects_total`, `fidelity`, `inventory_sync_run_id` |
| `BaselineCompareStats` | Existing single-tenant projection for freshness, coverage, findings count, and operator explanation | `state`, `reasonCode`, `reasonMessage`, `lastComparedIso`, `coverageStatus`, `evidenceGapDetails`, `operatorExplanation` |
### Drift finding truth
**Type**: Existing tenant-owned technical drift and workflow truth
**Source**: `Finding` with `finding_type = drift` and `source = baseline.compare`
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `Finding` | Stores subject-level technical differences plus workflow metadata | `tenant_id`, `workspace_id`, `scope_key`, `subject_external_id`, `subject_type`, `severity`, `status`, `baseline_operation_run_id`, `current_operation_run_id`, `evidence_jsonb` |
## New Derived Read Models
### BaselineCompareMatrixReference
**Type**: Request-scoped reference bundle
**Source**: `BaselineProfile` + resolved effective `BaselineSnapshot`
| Field | Type | Notes |
|------|------|-------|
| `workspaceId` | integer | Active workspace scope |
| `baselineProfileId` | integer | Selected baseline profile |
| `baselineProfileName` | string | Operator-facing reference label |
| `baselineStatus` | string | Existing profile lifecycle/status |
| `referenceSnapshotId` | integer or null | Null when compare is blocked |
| `referenceSnapshotCapturedAt` | datetime or null | For freshness context |
| `referenceState` | string | `ready` or a blocked state such as `no_snapshot` |
| `referenceReasonCode` | string or null | Existing compare-snapshot truth reason |
| `assignedTenantCount` | integer | Total assigned tenant count in workspace |
| `visibleTenantCount` | integer | Count after visibility filtering |
### MatrixTenantSummary
**Type**: Request-scoped visible tenant summary
**Source**: visible tenant set + latest relevant compare run + derived cell states
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | Visible tenant identifier |
| `tenantName` | string | Operator-facing column label |
| `compareRunId` | integer or null | Latest relevant compare run for this baseline |
| `compareRunStatus` | string or null | `queued`, `running`, `completed`, or null |
| `compareRunOutcome` | string or null | Existing `OperationRun` outcome |
| `freshnessState` | string | `fresh`, `stale`, `never_compared`, or `unknown` |
| `lastComparedAt` | datetime or null | Latest completed compare time |
| `matchedCount` | integer | Derived from cell states |
| `differingCount` | integer | Derived from cell states |
| `missingCount` | integer | Derived from cell states |
| `ambiguousCount` | integer | Derived from cell states |
| `notComparedCount` | integer | Derived from cell states |
| `maxSeverity` | string or null | Highest visible severity among differing cells |
| `trustLevel` | string | Existing trustworthiness semantics reused at tenant level |
### MatrixSubjectSummary
**Type**: Request-scoped baseline subject summary
**Source**: `BaselineSnapshotItem` + visible tenant cell states
| Field | Type | Notes |
|------|------|-------|
| `subjectKey` | string | Reused existing subject identity |
| `policyType` | string | For filter and grouping |
| `displayName` | string or null | Operator-facing row label |
| `baselineExternalId` | string or null | Secondary drilldown metadata |
| `deviationBreadth` | integer | Count of visible tenants in `differ` or `missing` |
| `missingBreadth` | integer | Count of visible tenants in `missing` |
| `ambiguousBreadth` | integer | Count of visible tenants in `ambiguous` |
| `notComparedBreadth` | integer | Count of visible tenants in `not_compared` |
| `maxSeverity` | string or null | Highest visible severity across differing cells |
| `trustLevel` | string | Highest-risk trust signal across visible cells |
### MatrixCell
**Type**: Request-scoped cell read model
**Source**: `BaselineSnapshotItem` + latest relevant compare run context + compare-created findings
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | Visible tenant column key |
| `subjectKey` | string | Subject row key |
| `state` | string | `match`, `differ`, `missing`, `ambiguous`, `not_compared`, or `stale_result` |
| `severity` | string or null | From current technical drift finding when present |
| `trustLevel` | string | Reused trustworthiness semantics or cell-level downgraded trust |
| `reasonCode` | string or null | Existing reason/evidence-gap code when the state is not a plain match |
| `compareRunId` | integer or null | Latest relevant compare run |
| `findingId` | integer or null | Existing finding drilldown target when a related finding exists |
| `findingWorkflowState` | string or null | Secondary workflow context; never overrides technical state |
| `lastComparedAt` | datetime or null | Latest compare timestamp for this tenant |
| `policyTypeCovered` | boolean | False when the run never covered this subject's policy type |
### CompareAssignedTenantsLaunchResult
**Type**: Request-scoped action result bundle
**Source**: repeated calls to existing `BaselineCompareService::startCompare()`
| Field | Type | Notes |
|------|------|-------|
| `baselineProfileId` | integer | Selected baseline profile |
| `visibleAssignedTenantCount` | integer | Size of eligible visible set considered by the action |
| `queuedCount` | integer | New compare runs started |
| `alreadyQueuedCount` | integer | Existing active runs reused |
| `blockedCount` | integer | Compare starts refused by normal service preconditions |
| `targets` | array | Per-tenant action outcome bundle with tenant id, run id or reason code |
## Validation Rules
### Visibility rules
- Only tenants visible to the current actor may produce columns, counts, or drilldown targets.
- Summary counts are always computed from the visible tenant set only.
- Hidden tenants are never represented as anonymous remainder counts.
### Reference rules
- The matrix may render technical compare truth only when the selected baseline profile resolves to a usable effective snapshot.
- If no usable snapshot exists, the page remains in a blocked state and no cell state may imply compare health.
### Cell derivation precedence
1. If no usable reference snapshot exists, the page is blocked and no cells are materialized.
2. If the tenant has no completed compare against this baseline reference or the subject's policy type was not covered, the cell state is `not_compared`.
3. If the latest completed compare result predates the current effective snapshot or is stale under existing stale-result policy, the cell state is `stale_result` unless a stronger blocked or absent condition applies.
4. If the latest relevant compare run records an evidence-gap or reason code indicating ambiguous or low-confidence identity matching for the subject, the cell state is `ambiguous`.
5. If the latest relevant compare run records the subject as missing from tenant truth, the cell state is `missing`.
6. If the latest relevant compare output created or updated a drift finding for the subject under the current compare run, the cell state is `differ` regardless of finding workflow status.
7. Otherwise the cell state is `match`.
### Freshness rules
- `fresh` means the tenant has a completed compare result against the current effective snapshot and it does not exceed the existing stale-result threshold.
- `stale` means the compare result predates the current effective snapshot or breaches the existing stale-result threshold.
- `never_compared` means no relevant compare run exists for the tenant and selected baseline.
- `unknown` is reserved for unexpected cases where timestamps or run truth are missing but a cell still exists.
### Trust rules
- Trust levels reuse existing compare explanation semantics rather than a new matrix-only taxonomy.
- `match` may only be shown when the subject was covered and no ambiguity or missing-basis signal exists.
- `not_compared` and uncovered policy types are treated as low-trust or unusable for operator interpretation.
## Relationships
- One `BaselineProfile` resolves to zero or one effective `BaselineSnapshot` for compare.
- One `BaselineSnapshot` has many `BaselineSnapshotItem` rows.
- One `BaselineProfile` has many `BaselineTenantAssignment` rows.
- One visible tenant may have many `baseline_compare` `OperationRun` rows over time, but the matrix resolves one latest relevant run per tenant for the selected baseline.
- One latest relevant compare run may have many related drift `Finding` rows.
- One matrix cell may link to zero or one preferred finding drilldown and always belongs to exactly one visible tenant and one subject row.
## Rendering Rules
- Technical deviation state is primary; finding workflow state is secondary.
- Tenant running or queued compare state is shown at tenant-summary level and does not replace the last known completed technical truth unless the latest completed truth is absent.
- Subject-focused views reuse the same row and cell models and simply reduce the visible subject set to one selected subject.
- Empty and degraded states remain page-level states rather than synthetic match rows.

View File

@ -0,0 +1,271 @@
# Implementation Plan: Workspace Baseline Compare Matrix V1
**Branch**: `190-baseline-compare-matrix` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Add one workspace-scoped Filament page anchored on `BaselineProfile` that renders a tenant-by-subject compare matrix from the current effective baseline snapshot, visible assigned tenants, the latest relevant tenant `baseline_compare` runs, and compare-created findings. Reuse the existing tenant compare start path for `Compare assigned tenants`, reuse `CanonicalNavigationContext` for drilldown continuity, and keep V1 derived and read-only so no new persisted cross-tenant compare artifact or workspace umbrella run is introduced.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
**Storage**: PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned
**Testing**: Pest 4 feature and browser tests, Filament or Livewire page coverage, focused RBAC regressions, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: web application
**Performance Goals**: Keep matrix reads query-bounded over the visible tenant set and reference snapshot, avoid per-cell N+1 queries, keep render-time surfaces DB-only, and keep `Compare assigned tenants` enqueue-only by reusing tenant compare run starts
**Constraints**: No new persisted cross-tenant compare artifact; no new workspace umbrella `OperationRun`; hidden tenants must not leak by name or count; `Compare assigned tenants` must remain confirmation-gated and `simulation only`; matrix uses a narrow custom grid exception because a one-axis table cannot represent subject-by-tenant truth cleanly; no remote calls at render time
**Scale/Scope**: One workspace, one baseline profile at a time, all visible assigned tenants for that profile, all in-scope baseline subjects for the effective snapshot, one new page and view, one narrow derived matrix builder, additive baseline-detail header actions, and focused feature plus smoke-test 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 matrix reads current tenant evidence through existing compare runs and baseline snapshots; it does not redefine last-observed truth. |
| Read/write separation | PASS | PASS | The feature is read-only apart from compare start. `Compare assigned tenants` remains confirmation-gated and simulation-only, with existing run and audit semantics. |
| Graph contract path | N/A | N/A | No new Graph calls or contract-registry changes are required. |
| Deterministic capabilities | PASS | PASS | Existing capability constants `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, and `TENANT_FINDINGS_VIEW` remain canonical. |
| Workspace + tenant isolation | PASS | PASS | The matrix is workspace-scoped, its columns are limited to visible assigned tenants, and all drilldowns re-check tenant entitlement. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; in-scope members missing required capability remain `403`; server-side authorization remains authoritative. |
| Run observability / Ops-UX | PASS | PASS | `Compare assigned tenants` fans out to existing tenant `baseline_compare` runs only. No new shadow run model or inline remote work is introduced. |
| Data minimization | PASS | PASS | The matrix reads existing compare and finding metadata only; no new payload copies or exported artifacts are stored. |
| Proportionality / no premature abstraction | PASS | PASS | The design adds one narrow matrix builder and one new page instead of a generalized compare framework or persisted portfolio layer. |
| Persisted truth / behavioral state | PASS | PASS | No new persistence or state family is added. Matrix states remain derived from existing truth. |
| UI semantics / few layers | PASS | PASS | Trust, stale, and ambiguity reuse existing compare semantics and do not create a new UI taxonomy. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature stays inside current Filament v5 and Livewire v4 patterns. |
| Provider registration location | PASS | PASS | No panel or provider changes are required. Provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is added. Existing baseline resources already satisfy current search constraints. |
| Destructive action safety | PASS | PASS | No destructive action is added. Existing destructive baseline actions remain unchanged and confirmation-gated. |
| Asset strategy | PASS | PASS | No new assets are planned. Existing deployment behavior for `filament:assets` remains unchanged. |
| Filament-native UI / Action Surface Contract | PASS WITH NARROW EXCEPTION | PASS WITH NARROW EXCEPTION | The matrix uses native Filament page structure and actions, but the core body is a custom two-dimensional grid because a one-axis table is insufficient. |
| Filament UX-001 | PASS WITH NARROW EXCEPTION | PASS WITH NARROW EXCEPTION | Sections, filters, legends, and empty states remain standard. The matrix body itself is the documented UX-001 exception. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The test plan focuses on aggregation correctness, hidden-tenant safety, stale or ambiguous truth, compare-all fan-out, and drilldown continuity. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/research.md`.
Key decisions:
- Use `/admin/baseline-profiles/{record}/compare-matrix` as the canonical route and anchor the workflow under the existing baseline profile resource.
- Derive matrix cells and summaries from `BaselineSnapshotItem` plus the latest relevant `baseline_compare` run plus compare-created findings; findings alone are insufficient.
- Treat technical deviation state as primary and finding workflow state as secondary metadata so closed or risk-accepted findings do not hide real current drift.
- Reuse the existing stale-result threshold and current-reference comparison rather than inventing matrix-specific freshness rules.
- Reuse existing evidence-gap and trustworthiness semantics for ambiguity and low-confidence signals.
- Reuse `CanonicalNavigationContext` for matrix drilldown continuity instead of extending `PortfolioArrivalContext`.
- Implement `Compare assigned tenants` as visible-tenant fan-out over existing tenant compare starts without a workspace umbrella run.
- Validate primarily with focused feature tests plus one browser smoke test.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/`:
- `data-model.md`: existing source truths, derived matrix read models, and cell-state precedence
- `contracts/baseline-compare-matrix.logical.openapi.yaml`: internal logical contract for matrix reads and compare-all launch behavior
- `quickstart.md`: implementation sequence and focused verification workflow
Design decisions:
- The matrix stays fully derived and introduces no new table, artifact, or workspace-level run type.
- The new page is baseline-profile-scoped and reuses existing baseline detail, compare landing, finding detail, and operation-run viewer destinations.
- The core builder lives alongside existing baseline support code and returns plain derived arrays or value-oriented payloads rather than a new presenter framework.
- `Compare assigned tenants` iterates the visible assigned tenant set and reuses `BaselineCompareService::startCompare()` per tenant, reporting queued, already queued, and blocked results honestly.
- Cell state precedence is fixed so `not_compared`, `stale_result`, `ambiguous`, `missing`, and `differ` remain visibly distinct from `match`.
- Matrix drilldowns preserve a canonical return path by reusing `CanonicalNavigationContext` query propagation rather than inventing a new navigation token system.
## Project Structure
### Documentation (this feature)
```text
specs/190-baseline-compare-matrix/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── baseline-compare-matrix.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── BaselineCompareLanding.php
│ │ │ └── BaselineCompareMatrix.php
│ │ └── Resources/
│ │ ├── BaselineProfileResource.php
│ │ ├── BaselineProfileResource/
│ │ │ └── Pages/ViewBaselineProfile.php
│ │ ├── FindingResource.php
│ │ └── OperationRunResource.php
│ ├── Models/
│ │ ├── BaselineProfile.php
│ │ ├── BaselineSnapshot.php
│ │ ├── BaselineSnapshotItem.php
│ │ ├── BaselineTenantAssignment.php
│ │ ├── Finding.php
│ │ └── OperationRun.php
│ ├── Services/
│ │ └── Baselines/BaselineCompareService.php
│ ├── Support/
│ │ ├── Baselines/
│ │ │ ├── BaselineCompareEvidenceGapDetails.php
│ │ │ ├── BaselineCompareMatrixBuilder.php
│ │ │ ├── BaselineCompareStats.php
│ │ │ ├── BaselineCompareSummaryAssessor.php
│ │ │ └── BaselineSnapshotTruthResolver.php
│ │ ├── Navigation/
│ │ │ ├── CanonicalNavigationContext.php
│ │ │ └── RelatedNavigationResolver.php
│ │ └── Rbac/
│ │ └── UiEnforcement.php
│ └── resources/views/filament/pages/
│ └── baseline-compare-matrix.blade.php
└── tests/
├── Browser/
│ └── Spec190BaselineCompareMatrixSmokeTest.php
├── Feature/
│ ├── Baselines/
│ │ ├── BaselineCompareMatrixBuilderTest.php
│ │ └── BaselineCompareMatrixCompareAllActionTest.php
│ ├── Filament/
│ │ └── BaselineCompareMatrixPageTest.php
│ └── Rbac/
│ └── BaselineCompareMatrixAuthorizationTest.php
└── Feature/Guards/
└── ActionSurfaceContractTest.php
```
**Structure Decision**: Keep the work inside the existing Laravel monolith under `apps/platform`. Add one new workspace page, one new view, one narrow baseline-support matrix builder, and additive changes to existing baseline detail and drilldown surfaces instead of creating a new domain package, new persistence layer, or new top-level workspace app.
## Implementation Strategy
### Phase A — Add The Workspace Entry Surface
**Goal**: Introduce the canonical matrix route and entry points without changing baseline ownership or navigation semantics.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Add the new workspace page class, scope it to one `BaselineProfile`, expose filter state, and declare the page contract plus action-surface intent |
| A.2 | `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | Add the page view using Filament sections, summaries, legends, and one narrow custom grid body |
| A.3 | `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Add `Open compare matrix` and `Compare assigned tenants` header actions with confirmation and capability gating |
### Phase B — Build The Live Matrix Aggregation
**Goal**: Derive visible rows, columns, cells, summaries, freshness, and trust from existing truth only.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` | Add the narrow read-model builder that resolves reference snapshot, visible assigned tenants, subject rows, matrix cells, and summaries |
| B.2 | Existing baseline support classes such as `BaselineCompareStats.php`, `BaselineCompareEvidenceGapDetails.php`, and `BaselineCompareSummaryAssessor.php` | Reuse or extract helper logic for freshness, trust, evidence-gap interpretation, and no-result handling instead of duplicating semantics |
| B.3 | Existing models `BaselineTenantAssignment`, `Finding`, and `OperationRun` | Add or reuse scoped queries for visible tenant assignments, latest relevant compare runs per tenant, and compare-created findings keyed to the selected baseline profile |
### Phase C — Add Compare-All Fan-Out Over Existing Tenant Runs
**Goal**: Start compare for the visible assigned set without creating a workspace umbrella run.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Services/Baselines/BaselineCompareService.php` | Add or extend a batch-start path that iterates visible assigned tenants and reuses `startCompare()` for each target |
| C.2 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Surface `Compare assigned tenants`, call the batch-start path, render queued, already queued, and blocked outcomes honestly, and preserve visible-disabled helper text for in-scope users missing manage capability |
| C.3 | Existing operation feedback helpers such as `OperationUxPresenter` and `OpsUxBrowserEvents` | Reuse current queued feedback and run-link behavior; do not invent matrix-specific run notifications |
### Phase D — Bind Filters, Drilldowns, And Degraded States
**Goal**: Make the matrix operator-scanable, trustworthy, and continuous with existing follow-up surfaces.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Add policy-type, state, severity, tenant-sort, subject-sort, and focused-subject state |
| D.2 | `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and related call sites | Reuse canonical navigation context so cell, tenant, finding, and run drilldowns preserve a return path to the matrix |
| D.3 | Existing drilldown destinations such as `BaselineCompareLanding.php`, `FindingResource.php`, and `OperationRunResource.php` | Accept bounded matrix source context and render a meaningful back link or source hint without adding a new navigation system |
| D.4 | Matrix view and builder | Render explicit empty, blocked, partial, stale, ambiguous, and no-result states without collapsing them into matches |
### Phase E — Regression Protection And Hardening
**Goal**: Prove business truth, hidden-tenant safety, and action-surface compliance.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` | Cover row and cell derivation, stale or no-result logic, ambiguity, and visible-set-only summaries |
| E.2 | `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` | Cover compare-all fan-out, per-tenant queued or blocked outcomes, and no workspace umbrella run creation |
| E.3 | `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` | Cover page mount, filters, subject focus, degraded states, and drilldown link presence |
| E.4 | `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` | Cover non-member `404`, member-without-capability `403`, visible-set filtering, and allowed access |
| E.5 | `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` | Add one smoke test for the rendered matrix, one core filter interaction, and one drilldown or compare-all affordance |
| E.6 | `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` | Keep the new page and baseline detail actions aligned with action-surface rules |
## Key Design Decisions
### D-001 — Keep the matrix fully derived
The page must read from existing baseline, compare, finding, and run truth only. No `CrossTenantCompare`, no stored matrix snapshot, and no new reporting artifact are introduced in V1.
### D-002 — Keep baseline profile as the visible standard
The matrix route lives under the selected baseline profile because the operator question is not generic cross-tenant compare; it is reference-to-many compare against one workspace-owned standard.
### D-003 — Reuse tenant compare runs only
`Compare assigned tenants` fans out across the visible assigned tenant set through existing tenant compare starts. Aggregate progress on the matrix page derives from those tenant runs instead of a workspace batch run.
### D-004 — Technical drift comes before workflow posture
Cells and counts reflect technical compare truth first. Finding workflow state, risk acceptance, or acknowledgement remain secondary metadata and do not suppress a current `differ` or `missing` state.
### D-005 — Reuse canonical navigation context
Drilldown continuity uses existing canonical navigation patterns rather than extending portfolio-triage tokens or adding a new navigation framework.
### D-006 — Accept one narrow matrix-grid UI exception
The matrix body is the only major UI exception because a standard table cannot represent subject-by-tenant truth. Everything around the grid remains Filament-native: actions, sections, legends, empty states, and badges.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Hidden-tenant leakage through summaries or totals | High | Medium | Build every column and summary from the visible tenant set only; add explicit negative RBAC tests. |
| Findings-only aggregation understates current drift | High | Medium | Derive technical state from latest compare run plus findings; treat finding workflow as secondary metadata. |
| Stale or no-result truth looks calm | High | Medium | Reuse stale-result semantics, explicit `not_compared`, and blocked-state rendering; test all degraded cases. |
| Matrix render becomes query-heavy | High | Medium | Build one narrow aggregation layer that batches tenant runs, findings, and snapshot items instead of resolving per cell. |
| Compare-all introduces a shadow batch model | Medium | Medium | Reuse `BaselineCompareService::startCompare()` per tenant and return a simple launch summary only. |
| The page grows into a generalized standardization platform | Medium | Medium | Keep route, copy, and design anchored on one baseline profile, one reference snapshot, and read-only workflow scope. |
## Test Strategy
- Add focused feature coverage for matrix aggregation, cell precedence, tenant and subject summaries, stale or no-result truth, and visible-set-only counts.
- Add action coverage for compare-all fan-out, per-tenant queued or blocked outcomes, reuse of the existing tenant compare run path, service-owned run transitions, canonical summary-count invariants, and notification-surface limits.
- Add RBAC coverage for workspace membership, capability denial, partial tenant visibility, visible-disabled helper-text states for in-scope users missing manage capability, and drilldown authorization semantics.
- Add query-shape regression coverage so wide tenant/subject matrices stay batched and avoid per-cell N+1 resolution.
- Add one browser smoke test for matrix rendering plus a core filter or drilldown interaction.
- Keep action-surface and existing baseline compare suites green so the new page does not regress current baseline workflows.
- Run the minimum focused Sail pack plus `pint --dirty --format agent` before implementation sign-off.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Two-dimensional matrix grid on a Filament page | The operator must scan subjects across multiple visible tenant columns in one surface | A standard Filament table or tenant-by-tenant list cannot represent both axes without destroying the core workflow question |
## Proportionality Review
- **Current operator problem**: Operators can already define baselines and compare one tenant at a time, but they still cannot answer cross-tenant baseline deviation questions without manual aggregation.
- **Existing structure is insufficient because**: Existing tenant compare and finding surfaces are tenant-first. They do not present one visible-set baseline view across all assigned tenants.
- **Narrowest correct implementation**: Add one baseline-scoped matrix page, one narrow derived matrix builder, one centralized badge adapter that reuses canonical compare truth, and one compare-all fan-out path over existing tenant compare starts.
- **Ownership cost created**: One new page, one view, one derived aggregation helper, one centralized badge adapter, additive baseline-detail actions, and focused feature plus browser regression coverage.
- **Alternative intentionally rejected**: A persisted cross-tenant compare artifact, a generalized compare engine, or a workspace umbrella run were rejected because they add durable complexity beyond V1's read-only portfolio visibility goal.
- **Release truth**: Current-release truth. The feature closes a present operator workflow gap using already-shipped baseline, finding, and run foundations.

View File

@ -0,0 +1,83 @@
# Quickstart: Workspace Baseline Compare Matrix V1
## Goal
Implement one workspace-scoped baseline compare matrix that lets an operator inspect visible assigned tenants against one baseline reference, trigger compare execution across the visible assigned set, and drill into existing tenant compare or finding surfaces without introducing a new persisted cross-tenant compare artifact.
## Implementation Sequence
1. Add the new workspace matrix page and baseline entry action.
- Add a new workspace Filament page for `/admin/baseline-profiles/{record}/compare-matrix`.
- Add `Open compare matrix` to the existing baseline profile detail header.
- Keep the page scoped to one selected baseline profile and one explicit reference snapshot.
2. Build the live aggregation layer over existing truth.
- Create a narrow matrix builder under the existing baseline-support namespace.
- Use `BaselineSnapshotTruthResolver` and `BaselineSnapshotItem` for the reference axis.
- Use latest relevant `baseline_compare` runs plus their `context['baseline_compare']` payload for freshness, coverage, and trust.
- Use compare-created findings for technical difference severity and drilldown targets.
- Keep the matrix derived only; do not add persistence.
3. Add compare-all fan-out without a workspace umbrella run.
- Extend the baseline compare start path so the matrix and baseline detail can iterate visible assigned tenants and call the existing tenant compare start logic.
- Keep confirmation, queued toast behavior, and run observability aligned with existing `OperationRun` semantics.
- Report partial success, already queued, and blocked starts honestly from the underlying per-tenant results.
4. Bind filtering, subject focus, and drilldown continuity.
- Add policy-type, state, and severity filters.
- Add tenant and subject sorting.
- Reuse `CanonicalNavigationContext`, `RelatedNavigationResolver`, and existing destination routes for tenant, finding, and run drilldowns.
- Preserve a clear return path to the matrix.
5. Add regression coverage.
- Cover live aggregation, compare-all, stale/no-result/ambiguous truth, visible-set RBAC filtering, and drilldown continuity.
- Add one browser smoke test to prove the interactive matrix surface renders and performs the core operator flow.
## Suggested Test Files
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
## Existing Suites To Extend Or Keep Green
- `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
- `apps/platform/tests/Feature/Filament/WorkspaceOverview*` suites that currently consume baseline attention summaries
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareStatsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open a baseline profile with a usable reference snapshot and verify `Open compare matrix` lands on the new workspace matrix page.
2. Confirm the page shows the selected baseline profile, reference snapshot, visible assigned tenant count, and truthful per-tenant and per-subject summaries.
3. Filter the matrix by policy type and by state and confirm the visible rows, columns, and counts update without implying hidden tenants.
4. Open a differing, missing, or ambiguous cell and confirm the drilldown reaches an existing tenant compare or finding surface with a clear return path.
5. Trigger `Compare assigned tenants` and confirm the modal describes `simulation only`, the launch fans out to visible assigned tenants only, and partial success is visible.
6. Verify a tenant with no prior compare reads as `Not compared`, not healthy.
7. Verify a tenant with stale compare truth reads as stale and does not look current.
8. Verify a user with partial tenant visibility sees only allowed tenants and no hidden-tenant aggregate counts.
## Deployment Notes
- No new database migration is expected.
- No new assets are planned; normal Filament asset publishing behavior remains unchanged.
- `Compare assigned tenants` reuses existing tenant compare runs only, so no new queue worker topology or deployment artifact is required.

View File

@ -0,0 +1,90 @@
# Research: Workspace Baseline Compare Matrix V1
## Decision: Build the matrix as a baseline-scoped workspace page under the existing baseline profile resource
### Rationale
The existing baseline workflow is already anchored on `BaselineProfileResource` and `ViewBaselineProfile`. A matrix is meaningful only in the context of one explicit baseline reference, so the narrowest route is a child workspace page such as `/admin/baseline-profiles/{record}/compare-matrix`. This keeps the baseline profile as the visible standard, preserves existing navigation expectations, and avoids introducing a new top-level workspace workbench.
### Alternatives considered
- Add a separate top-level workspace navigation item: rejected because it weakens the explicit baseline reference and creates a second entry pattern for the same workflow.
- Extend the existing tenant `BaselineCompareLanding` page to become cross-tenant: rejected because that page is tenant-scoped by design and would blur tenant and workspace concerns.
## Decision: Derive matrix truth from `BaselineSnapshotItem` plus the latest relevant tenant compare run plus compare-created findings
### Rationale
No single existing artifact is sufficient by itself. `BaselineSnapshotItem` defines the immutable subject row axis and reference truth. The latest `baseline_compare` `OperationRun` provides freshness, coverage, evidence-gap, and trust metadata. Findings created with `source = baseline.compare` provide durable per-subject technical drift detail and severity. Combining those existing sources yields matrix rows, columns, summaries, and drilldowns without adding a second persisted compare model.
### Alternatives considered
- Use findings alone: rejected because findings do not represent matches, do not fully encode no-result or stale truth, and can be filtered by workflow status in ways that would understate current technical deviation.
- Use run context alone: rejected because findings remain the durable subject-level drilldown truth and severity carrier across compare executions.
- Persist a new cross-tenant compare report: rejected because V1 can be derived live from existing truth.
## Decision: Treat technical deviation state as primary and finding workflow state as secondary metadata
### Rationale
The matrix must answer whether a tenant currently differs from the baseline, not whether someone already triaged the resulting finding. A current compare may still indicate drift even if a related finding is acknowledged, risk accepted, or closed. Therefore cell and summary counts must derive from the latest compare truth for the selected baseline and visible tenant, while any finding workflow state remains optional secondary metadata for drilldown or badges.
### Alternatives considered
- Count only open findings: rejected because terminal workflow states can hide still-present technical drift.
- Ignore findings entirely: rejected because the operator still needs severity and existing finding drilldown continuity.
## Decision: Reuse existing stale-result semantics and reference-snapshot freshness rules
### Rationale
The repo already defines baseline compare staleness in `BaselineCompareSummaryAssessor`, including a seven-day age threshold and `stale_result` evidence semantics. The matrix should reuse that rule and additionally mark results stale when the latest completed compare predates the current effective baseline snapshot. This keeps tenant and workspace compare freshness language aligned.
### Alternatives considered
- Invent a matrix-specific freshness threshold: rejected because it would create a parallel freshness language.
- Use only elapsed time: rejected because a result can become stale immediately when the baseline reference snapshot changes.
## Decision: Reuse evidence-gap and operator-explanation metadata for trust and ambiguity signals
### Rationale
`baseline_compare` run context already records evidence gaps, uncovered policy types, and related diagnostics. `BaselineCompareExplanationRegistry` and the existing trustworthiness semantics already distinguish trustworthy, limited-confidence, diagnostic-only, and unusable result conditions. The matrix should reuse those signals at tenant and subject level, while mapping per-cell ambiguity from evidence-gap reason codes such as ambiguous or low-confidence match situations.
### Alternatives considered
- Add a new trust or certainty taxonomy for the matrix: rejected because the compare domain already has operator-trust semantics.
- Hide uncertainty behind summary prose only: rejected because the spec explicitly forbids hidden ambiguity.
## Decision: Use existing `CanonicalNavigationContext` patterns for matrix drilldown continuity
### Rationale
The codebase already uses `CanonicalNavigationContext`, `RelatedNavigationResolver`, and `OperationRunLinks` to preserve source context and return paths across related surfaces. The matrix only needs source baseline, source subject, and return path continuity. Reusing canonical navigation context is narrower than extending `PortfolioArrivalContext`, which is currently specialized for backup-health and recovery-evidence triage families.
### Alternatives considered
- Reuse `PortfolioArrivalContext`: rejected because the matrix is not a backup or recovery concern-family workflow and would pollute that token's allowlist and semantics.
- Introduce a brand-new context token type: rejected because `CanonicalNavigationContext` already solves the generic return-path problem.
## Decision: Implement `Compare assigned tenants` as visible-tenant fan-out over existing tenant compare starts, without a workspace umbrella run
### Rationale
`BaselineCompareService::startCompare()` and tenant-scoped `OperationRun` creation already implement canonical baseline compare start semantics, deduplication, queued feedback, and run observability. V1 compare-all should iterate the visible assigned tenant set and reuse those existing starts. Aggregate progress on the matrix page can then be derived from the underlying tenant runs. This avoids inventing a second run identity or a workspace-level shadow batch truth.
### Alternatives considered
- Create one workspace umbrella `OperationRun`: rejected because the spec explicitly avoids a new parallel run truth and the repo already treats tenant compare as tenant-owned execution.
- Start compare directly in the page without reusing the service: rejected because it would duplicate preconditions, snapshot truth resolution, and dedupe behavior.
## Decision: Cover the feature primarily with focused feature tests plus one browser smoke test
### Rationale
The matrix combines query-heavy aggregation, RBAC filtering, and one new interactive page surface. Focused feature tests should verify aggregation correctness, compare-all launch behavior, and authorization semantics. One browser smoke test is sufficient to prove the two-dimensional page and its core drilldown or action affordances render correctly under Livewire and Filament, without turning the spec into a browser-heavy suite.
### Alternatives considered
- Browser-test the full feature exhaustively: rejected because most business truth can be validated faster and more deterministically through feature tests.
- Limit tests to one page smoke test: rejected because the feature's core risks are incorrect aggregation, stale or ambiguous truth, and hidden-tenant leakage.

View File

@ -0,0 +1,288 @@
# Feature Specification: Workspace Baseline Compare Matrix V1
**Feature Branch**: `190-baseline-compare-matrix`
**Created**: 2026-04-11
**Status**: Draft
**Input**: User description: "Spec 190 - Workspace Baseline Compare Matrix V1"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Workspace operators can define baselines, assign tenants, and review tenant-level compare truth, but they still cannot see one workspace-scoped picture of how all assigned visible tenants deviate from the same baseline standard.
- **Today's failure**: Portfolio governance remains tenant-by-tenant. Operators must open tenants individually, cannot rank the worst deviators quickly, cannot see which subjects drift across many tenants, and can misread stale, absent, or ambiguous compare truth as calm.
- **User-visible improvement**: One workspace matrix shows the reference baseline, the visible assigned tenants, the breadth of deviation by tenant and subject, freshness, and trust, with direct drilldown into existing compare and finding surfaces.
- **Smallest enterprise-capable version**: A read-only workspace-scoped matrix for one `BaselineProfile` at a time, powered by existing snapshot, compare, finding, and `OperationRun` truth, plus one `Compare assigned tenants` action.
- **Explicit non-goals**: Promotion, rollout orchestration, approval, cross-workspace compare, persisted portfolio compare reports, manual match overrides, remediation actions, compliance mapping, exports, and a generalized standardization framework.
- **Permanent complexity imported**: One new workspace page, one derived aggregation shape for rows, columns, cells, and summaries, additional filter and sort semantics, compare-all orchestration over existing runs, drilldown continuity, and focused feature/browser regression coverage. No new persisted domain artifact is imported.
- **Why now**: The baseline, finding, `OperationRun`, and workspace portfolio foundations already exist. Without a workspace-level compare projection, a high-value MSP/operator workflow stays fragmented even though the underlying truth is already in the product.
- **Why not local**: Tenant-local compare and finding pages cannot answer cross-tenant questions such as "which visible tenant deviates most from this baseline right now?" or "which subject is recurring across the portfolio?" without repeated context switching and manual aggregation by the operator.
- **Approval class**: Core Enterprise
- **Red flags triggered**: `New Achsen` because a matrix can accidentally become a second truth layer, and `New Meta-Infrastructure` because cross-tenant aggregation can tempt a generalized compare platform. Defense: V1 is baseline-referenced, read-only, live-aggregated from existing truth, and explicitly forbids new persisted cross-tenant compare artifacts.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/baseline-profiles` as the existing workspace list and selection entry for baseline standards
- `/admin/baseline-profiles/{record}` as the existing baseline profile detail that gains the canonical matrix entry action
- `/admin/baseline-profiles/{record}/compare-matrix` as the new canonical workspace route for one baseline compare matrix
- `/admin/t/{tenant}/baseline-compare` as the existing tenant drilldown landing
- `/admin/findings` plus finding detail as existing finding drilldown surfaces
- Monitoring -> Operation Run Detail for compare-all follow-up and tenant compare run truth
- **Data Ownership**:
- Workspace-owned reference truth remains `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotItem`, and baseline-to-tenant assignment records.
- Tenant-owned compare truth remains the existing tenant compare outputs, drift findings, and `OperationRun` rows created for compare execution.
- The matrix itself is a read-only workspace projection over visible assigned tenants and does not persist a new cross-tenant compare result, report, or standardization artifact.
- **RBAC**:
- Workspace membership plus `WORKSPACE_BASELINES_VIEW` is required to open the matrix and inspect workspace baseline reference truth.
- `WORKSPACE_BASELINES_MANAGE` is required to start `Compare assigned tenants`.
- In-scope members who can view the baseline but lack `WORKSPACE_BASELINES_MANAGE` still see `Compare assigned tenants` on the baseline detail and matrix surfaces in a disabled state with helper text; forced execution remains `403`.
- Matrix columns are limited to tenants the actor may already see under existing workspace and tenant visibility rules; drilldowns continue to enforce their destination capabilities such as `TENANT_VIEW` and `TENANT_FINDINGS_VIEW`.
- Non-members of the workspace or a tenant scope remain `404`; in-scope members missing the required capability remain `403`.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Surface Type | 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Baseline profile detail | Detail / action-launch surface | Explicit `Open compare matrix` header action | forbidden | Detail header actions | Existing archive remains in detail header | `/admin/baseline-profiles` | `/admin/baseline-profiles/{record}` | Active workspace, baseline status, assignment count, reference snapshot truth | Baseline profile | Whether this baseline is compare-ready and how many tenants are assigned | none |
| Workspace baseline compare matrix | Workspace matrix / report surface | Explicit tenant, subject, and cell drilldowns | forbidden | Header toolbar, summary strips, and focused in-matrix controls | none | `/admin/baseline-profiles/{record}/compare-matrix` | Same route with focused tenant or subject state, plus existing drilldowns to tenant compare or finding surfaces | Active workspace, selected baseline profile, reference snapshot, visible tenant count, filter scope, freshness legend | Baseline compare matrix | Tenant deviation breadth, subject breadth, freshness, ambiguity, and not-compared truth | matrix-grid surface |
| Tenant compare and finding drilldowns from the matrix | Existing detail/list drilldown surfaces | Explicit matrix drilldown links only | forbidden | Existing local actions remain in their current placements | Existing destructive or lifecycle actions remain where already defined | `/admin/t/{tenant}/baseline-compare` and `/admin/findings` | Existing tenant compare and finding detail routes | Tenant context, source baseline profile, source subject focus, arrival source | Baseline compare / Finding | Why the operator drilled from the matrix and what subject or deviation is being followed up | canonical-navigation extension |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Baseline profile detail | Workspace manager | Detail / launch surface | Is this the right reference baseline, and should I open or refresh its matrix now? | Profile name and status, effective snapshot, assignment count, matrix entry, last compare coverage summary | Snapshot capture history, run diagnostics, low-level compare details | baseline lifecycle, snapshot completeness, visible assignment coverage | `TenantPilot only` for profile edits, `simulation only` for compare starts | Open compare matrix, Compare assigned tenants, Capture baseline | Archive baseline profile |
| Workspace baseline compare matrix | Workspace operator | Workspace matrix / drilldown hub | Which visible assigned tenants diverge from this baseline, how fresh and trustworthy is that truth, and where should I drill next? | Reference baseline, reference snapshot, visible-vs-assigned counts, per-tenant summaries, per-subject summaries, cell states, freshness legend, trust legend | Matching method detail, evidence-gap reasons, run identifiers, raw subject keys | compare result state, freshness, trust or ambiguity, visible severity breadth | `simulation only` for compare starts; otherwise read-only | Compare assigned tenants, filter matrix, focus subject, open tenant compare, open finding follow-up | none |
| Tenant compare and finding drilldowns from the matrix | Workspace operator continuing investigation | Existing follow-up surface | What exactly is wrong for this tenant and subject, and what existing workflow should I use next? | Tenant context, baseline context, subject context, current compare or finding truth, return path | Raw evidence payloads, underlying run payload, low-level diagnostic detail | tenant compare readiness, finding workflow state, drift severity, evidence completeness | Existing destination-specific mutation scope only | View compare context, view finding context, return to matrix | Existing destination-specific dangerous actions only |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes, one narrow request-scoped matrix builder plus one centralized badge adapter for matrix state semantics
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: The product has strong tenant-level baseline compare truth but still lacks a portfolio answer to "who deviates most from this baseline right now?"
- **Existing structure is insufficient because**: Existing tenant compare and finding surfaces are tenant-first or item-first. They cannot show visible-set deviation breadth, freshness, and trust across all assigned tenants without forcing the operator to aggregate manually.
- **Narrowest correct implementation**: One baseline-scoped matrix page, one narrow request-scoped matrix builder, one centralized badge adapter that reuses canonical compare truth, plus one batch compare trigger that reuses current snapshots, compare outputs, findings, run truth, and drilldown surfaces.
- **Ownership cost**: One new page, one narrow derived builder, one centralized badge adapter, derived aggregation queries, one narrow matrix-grid presentation exception, additional filter/sort coverage, and focused feature/browser regression tests.
- **Alternative intentionally rejected**: A new `CrossTenantCompare`, stored report model, generalized tenant-vs-tenant compare engine, promotion workflow, or broader standardization platform.
- **Release truth**: current-release portfolio governance visibility
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Scan visible drift across assigned tenants (Priority: P1)
As a workspace operator, I want one matrix for one baseline profile so I can see which visible assigned tenants and subjects deviate most without opening tenants one by one.
**Why this priority**: This is the core product outcome. If the matrix cannot answer the portfolio question directly, the feature misses its reason to exist.
**Independent Test**: Open the matrix for a baseline profile with mixed compare outcomes and verify that the reference baseline, visible tenant set, per-tenant summaries, per-subject summaries, and cell states are all understandable from one page.
**Acceptance Scenarios**:
1. **Given** a usable baseline reference and multiple visible assigned tenants with existing compare outcomes, **When** the operator opens the compare matrix, **Then** the page shows one column per visible assigned tenant, one row per baseline subject, and truthful cell states for match, differ, missing, ambiguous, stale, or not compared.
2. **Given** the actor can see only a subset of assigned tenants, **When** the matrix opens, **Then** only visible tenants appear and every count and summary stays scoped to that visible set.
---
### User Story 2 - Refresh compare truth for the visible assigned set (Priority: P1)
As a workspace operator, I want to compare all visible assigned tenants in one action so I can refresh portfolio truth without visiting each tenant.
**Why this priority**: The matrix becomes operationally useful only if an authorized operator can refresh stale portfolio truth from the same workspace surface.
**Independent Test**: Start `Compare assigned tenants` from the baseline detail or matrix page and verify that the product reuses normal compare execution semantics, shows honest run progress, and reflects partial completion accurately.
**Acceptance Scenarios**:
1. **Given** an authorized operator and visible assigned tenants eligible for compare, **When** the operator confirms `Compare assigned tenants`, **Then** the system starts or reuses normal tenant compare execution for each eligible visible tenant and returns the operator to honest queued or running matrix truth.
2. **Given** some visible assigned tenants succeed and others fail or remain running, **When** the matrix refreshes, **Then** the page distinguishes completed, running, failed, stale, and never-compared tenants without collapsing them into a single calm state.
---
### User Story 3 - Drill from the matrix into existing follow-up surfaces (Priority: P2)
As a workspace operator, I want cell, tenant, and subject drilldowns so I can move from the portfolio view into existing compare or finding workflows without reconstructing context.
**Why this priority**: The matrix must be a decision surface, not a dead-end report.
**Independent Test**: Open a differing, missing, or ambiguous cell and confirm the drilldown lands in an existing tenant compare or finding surface with the tenant, subject, and baseline context still understandable.
**Acceptance Scenarios**:
1. **Given** a differing or missing matrix cell, **When** the operator opens its drilldown, **Then** the product lands in an existing tenant compare or finding context for that tenant and subject with the baseline reference still clear.
2. **Given** a subject row that deviates across several visible tenants, **When** the operator focuses that subject, **Then** the product shows the subject-first picture across the visible tenant set without inventing a new persisted subject report.
---
### User Story 4 - Stay honest in degraded or low-trust conditions (Priority: P2)
As a workspace operator, I want empty, partial, stale, and ambiguous states to stay explicit so the matrix never reads as healthier than the underlying truth.
**Why this priority**: False calmness would be worse than having no matrix at all.
**Independent Test**: Open the matrix for a baseline profile with no usable snapshot, no prior compare runs, stale results, and ambiguous matches, and verify that each degraded condition is visibly distinct from a normal match state.
**Acceptance Scenarios**:
1. **Given** the baseline profile has no usable reference snapshot, **When** the operator opens the matrix, **Then** the page does not imply compare truth exists and instead shows one clear next-step call to action.
2. **Given** compare truth is ambiguous, stale, or absent, **When** the matrix renders, **Then** those cells and summaries remain visibly distinct from matches and point the operator toward the right next compare or drilldown action.
### Edge Cases
- A baseline profile has assigned tenants but no usable reference snapshot; the matrix must block compare interpretation instead of rendering empty matches.
- A baseline profile has assigned tenants but the current actor can see none of them; the page must explain visible-set scoping without leaking hidden tenant counts or names.
- A visible assigned tenant has never been compared against this baseline; the cell and tenant summary must read as `Not compared` rather than healthy.
- A visible tenant has compare results against an older baseline reference than the current one; the result must read as stale rather than current.
- Subject identity is ambiguous for one or more tenants; the matrix must show ambiguity instead of forcing a match or differ conclusion.
- `Compare assigned tenants` starts successfully for some visible tenants but not others; the surface must stay honest about partial start and partial completion.
- One policy type has high trust while another has low trust; filtering by policy type must preserve the correct trust explanation for the visible slice.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds one workspace-scoped operator surface and reuses existing compare execution. It introduces no new Microsoft Graph contract path, no new write workflow beyond compare start, and no new source of baseline or finding truth. `Compare assigned tenants` remains a simulation-only governance action that must respect confirmation, tenant isolation, auditability through existing run truth, and focused regression coverage.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature stays within the default bias of deriving before persisting. The matrix is a thin derived projection over existing baseline snapshots, compare outputs, findings, and run context. V1 must not create new persistence, a generalized compare framework, or a new persisted state family just to support the matrix.
**Constitution alignment (OPS-UX):** `Compare assigned tenants` reuses existing `baseline_compare` run semantics only. Toast feedback remains intent-only, progress remains on active-ops and run-detail surfaces, and terminal notification behavior remains initiator-aware. `OperationRun.status` and `OperationRun.outcome` remain service-owned via `OperationRunService`. Any summary counts written to runs remain numeric-only and must use canonical summary keys. Scheduled or system-run semantics stay unchanged: no initiator means no terminal DB notification, and Monitoring remains the audit surface. Regression coverage must prove that compare-all does not invent shadow statuses or a second batch truth.
**Constitution alignment (RBAC-UX):** This feature affects the workspace-admin plane on `/admin/baseline-profiles` and the tenant follow-up plane on `/admin/t/{tenant}/baseline-compare`, plus existing finding drilldowns. Cross-plane access remains deny-as-not-found. Non-members of the workspace or tenant scope receive `404`. In-scope members missing `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, or `TENANT_FINDINGS_VIEW` as required by the destination surface receive `403`. Server-side enforcement remains mandatory for matrix view, compare start, and every drilldown. Global search behavior stays unchanged; the matrix itself is not introduced as a separate global-search result.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that this feature does not introduce auth-handshake HTTP behavior.
**Constitution alignment (BADGE-001):** Any new matrix legends, badges, or label mappings for match, differ, missing, ambiguous, not compared, stale result, freshness, or trust must stay centralized and derived from canonical compare truth. No page-local color language may redefine those meanings. Tests must cover any new or changed centralized mappings.
**Constitution alignment (UI-FIL-001):** The feature should use a Filament page, Filament header actions, sections, stats, filters, and shared badge or alert primitives wherever possible. The tenant-by-subject grid itself may require custom Blade markup because standard one-axis tables do not represent two-dimensional matrix inspection well enough; this is the only approved exception. Even with that exception, buttons, alerts, legends, empty states, and state badges must still use shared primitives and central semantics rather than page-local styling rules.
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must stay consistent across the matrix, baseline detail, run titles, and notifications: `Baseline compare matrix`, `Compare assigned tenants`, `Reference snapshot`, `Visible tenants`, `Match`, `Differs`, `Missing`, `Ambiguous match`, `Not compared`, and `Stale result`. Internal implementation terms such as `cross-tenant compare engine`, `matrix resolver`, or `portfolio deviation artifact` must stay out of primary labels.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The new surface is classified as a workspace matrix/report because the operator task is cross-tenant inspection, not CRUD. It has one primary inspect model: explicit tenant, subject, and cell drilldowns. Row click is forbidden because matrix rows and columns each represent different drilldown intents. Secondary actions live in the page header and matrix summaries. The matrix has no destructive actions. Its canonical collection route and canonical detail route are the same matrix route with focused state, while deeper investigation moves into existing tenant compare or finding routes. Scope signals must show active workspace, selected baseline profile, reference snapshot, visible-vs-assigned counts, and filter state. The critical truth visible by default is deviation breadth plus freshness and trust for the visible tenant set.
**Constitution alignment (OPSURF-001):** Default-visible matrix content must remain operator-first: baseline reference, visible tenant set, per-tenant deviation summary, per-subject breadth, freshness, and low-trust signals. Diagnostics such as raw subject keys, matching mechanics, and run identifiers must stay secondary. Status dimensions must remain separate: compare result state, freshness, trust or ambiguity, and visible severity breadth. `Compare assigned tenants` must communicate `simulation only` before execution. Workspace and tenant context must remain explicit in the matrix, the drilldowns, and the return path.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct exposure of tenant compare truth alone is insufficient because the operator needs a visible-set portfolio decision surface. The solution must stay a thin derived aggregation layer rather than a second truth system. Tests must focus on business consequences such as wrong visible-set counts, stale or absent truth being mistaken for calm, ambiguous matches being hidden, and drilldowns losing context.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. `BaselineProfileResource` keeps its existing primary inspect model and adds `Open compare matrix` plus `Compare assigned tenants` in the detail header. The new matrix page has no row-click primary open, no redundant `View` action, no empty action groups, and no destructive actions. UI-FIL-001 remains satisfied with the narrow matrix-grid exception described above.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The new matrix page must use sections or cards for reference truth, summaries, filters, and the matrix body. Because the core surface is two-dimensional, the matrix body is a narrow UX-001 exemption from standard Filament table layout. That exemption does not remove the need for search/filter/sort over core dimensions, a specific empty-state title plus explanation plus exactly one call to action, or BADGE-001-compliant state markers. No new create or edit form is introduced.
### Functional Requirements
- **FR-190-001 Workspace entry surface**: The system MUST provide a new workspace-scoped compare matrix page for one baseline profile at a time, reachable from existing workspace baseline context and directly from the selected baseline profile detail.
- **FR-190-002 Reference truth visibility**: The matrix MUST identify the selected baseline profile, the profile status, the reference snapshot or effective baseline state in use, the total assigned tenants, and the currently visible tenant count.
- **FR-190-003 Explicit compare source truth**: The matrix MUST derive from one explicit baseline reference state. If no usable reference state exists, the page MUST not imply compare truth and MUST explain the blocked state.
- **FR-190-004 Visible target set**: Matrix columns MUST be limited to tenants assigned to the selected baseline profile in the same workspace that the current actor is allowed to see. Hidden tenants MUST NOT leak by name, count, or indirect summary.
- **FR-190-005 Subject identity reuse**: Matrix rows MUST reuse the existing baseline subject identity and matching strategy. V1 MUST NOT introduce a competing subject identity system.
- **FR-190-006 Cell truth states**: Each subject-by-tenant cell MUST resolve to a truthful visible outcome representing at least `Match`, `Differs`, `Missing`, `Ambiguous match`, `Not compared`, or `Stale result`, even if the final UI wording is slightly adjusted.
- **FR-190-007 No false calmness**: `Ambiguous match`, `Not compared`, `Stale result`, and insufficient-basis conditions MUST remain visibly distinct from `Match` and MUST NOT contribute to healthy counts as if they were matches.
- **FR-190-008 Per-tenant summary**: The page MUST show a summary for each visible tenant including compared subject count, differing count, missing count, ambiguous count, highest visible severity or attention level, and compare freshness.
- **FR-190-009 Per-subject summary**: The page MUST show a summary for each subject including deviation breadth across visible tenants, missing breadth, ambiguous breadth, and highest visible severity or attention level.
- **FR-190-010 Filtering**: The matrix MUST support filtering by policy type, state group at minimum (`All`, `Deviations only`, `Missing only`, `Ambiguous only`, and a stale/no-result view), and visible severity band.
- **FR-190-011 Sorting**: The matrix MUST support sorting by tenant name, tenant deviation count, tenant freshness urgency, and subject deviation breadth.
- **FR-190-012 Compare-all availability**: Authorized users MUST be able to start `Compare assigned tenants` from the matrix page and from the selected baseline profile detail when the baseline has a usable reference state.
- **FR-190-013 Compare-all execution model**: `Compare assigned tenants` MUST start or reuse normal tenant compare execution only for eligible visible assigned tenants and MUST NOT invent a second persisted batch truth or shadow status model.
- **FR-190-014 Compare-all honesty**: The matrix MUST surface queued, running, completed, failed, partial, stale, and never-compared truth from the underlying compare runs and findings without collapsing mixed outcomes into one success state.
- **FR-190-015 Freshness visibility**: The matrix MUST show when each visible tenant was last compared against the selected baseline reference, whether that result predates the current reference state, and whether the tenant has never been compared.
- **FR-190-016 Trust visibility**: The matrix MUST show the applicable matching or identity trust signal for low-confidence cells or subjects, including ambiguous matches and missing compare basis, without forcing raw technical diagnostics into the primary scan path.
- **FR-190-017 Cell drilldown**: A matrix cell with meaningful follow-up MUST open an existing tenant compare or finding surface that keeps the tenant, subject, and baseline context understandable on arrival.
- **FR-190-018 Tenant drilldown**: A tenant summary or header interaction MUST open the existing tenant baseline compare landing or equivalent tenant follow-up surface for that tenant under the selected baseline context.
- **FR-190-019 Subject focus**: A subject interaction MUST open or switch to a subject-focused view across the current visible tenant set without creating a new persisted subject report artifact.
- **FR-190-020 Existing-truth-first aggregation**: V1 MUST derive matrix cells, summaries, freshness, and trust from existing baseline snapshots, compare outputs, findings, inventory subject identity, and `OperationRun` context before considering any new stored projection.
- **FR-190-021 No new portfolio persistence**: V1 MUST NOT introduce a first-class `CrossTenantCompare`, `CrossTenantCompareResult`, `PortfolioDeviationReport`, or equivalent persisted artifact unless a later spec proves live aggregation insufficient.
- **FR-190-022 RBAC-safe degradation**: Whenever the actor can see only part of the assigned tenant set, every column, summary, filter total, and subject breadth count MUST be computed from the visible set only or explicitly labeled as visible-set-only.
- **FR-190-023 Empty and degraded states**: The page MUST provide explicit operator-readable states for no usable snapshot, no assigned tenants, no visible tenants, no compare results, partially complete compare coverage, and all-low-trust results.
- **FR-190-024 Read-only boundary**: V1 MUST remain read-only apart from starting compare execution. It MUST NOT add remediation, restore, promotion, exception, approval, or manual match-override actions to matrix cells or summaries.
- **FR-190-025 Centralized semantics**: Any new matrix-specific legends, labels, or badges MUST stay centrally mapped from existing canonical truth and MUST NOT create a new persisted status family.
- **FR-190-026 Automated coverage**: Automated coverage MUST verify the core matrix flow, compare-all, visible-set RBAC filtering, ambiguous matching, stale/no-result honesty, policy-type filtering, subject focus, drilldown continuity, degraded-state behavior, and query-bounded read shapes for wide visible tenant and subject sets.
## Non-Goals
- No policy promotion or push between tenants
- No rollout waves, rings, or fleet orchestration
- No approval workflow or accepted-deviation flow
- No cross-workspace compare
- No ad hoc tenant-A-vs-tenant-B compare
- No manual match override workflow
- No deep field-by-field compare editor
- No compare export or stored report artifact in V1
- No portfolio-level finding persistence beyond existing truth
- No automatic remediation
## Assumptions
- Existing `BaselineProfile`, `BaselineSnapshot`, tenant compare outputs, findings, and `OperationRun` truth remain the canonical building blocks for V1.
- One baseline profile is the reference frame for one matrix at a time.
- Existing baseline subject identity and matching strategy are already authoritative enough to support portfolio aggregation, as long as ambiguity stays explicit.
- `Compare assigned tenants` can fan out to existing tenant compare execution without requiring a second persisted batch artifact.
- Existing tenant compare and finding surfaces can accept bounded canonical navigation context so the operator can understand why they drilled from the matrix.
## Dependencies
- Existing workspace baseline resources and snapshot truth
- Existing tenant baseline compare landing and compare execution
- Existing finding generation, finding detail, and finding triage surfaces
- Existing `baseline_compare` `OperationRun` lifecycle and Monitoring surfaces
- Existing `CanonicalNavigationContext`, `RelatedNavigationResolver`, and return-path helpers for bounded matrix drilldown continuity
## Risks
- The matrix could expand into a generalized standardization platform unless V1 stays baseline-referenced and read-only.
- Low-trust subject matching could be over-read as certainty unless ambiguity stays highly visible.
- Workspace aggregation could leak hidden tenants unless every summary is computed from the visible set only.
- Very wide tenant or subject sets could reduce scanability unless filtering and subject focus stay first-class.
- Old compare results could be misread as current truth unless freshness stays explicit and prominent.
## Review Questions
- Does the matrix clearly show which baseline reference state it is using?
- Does V1 aggregate existing compare and finding truth instead of inventing a second compare system?
- Are stale, absent, and ambiguous states visibly distinct from matches?
- Are visible-set RBAC boundaries fully preserved in columns, summaries, and drilldowns?
- Does `Compare assigned tenants` stay consistent with existing `OperationRun` semantics?
- Does the matrix stay operator-first rather than becoming a technical dashboard?
- Does drilldown land in existing follow-up workflows with enough context preserved?
- Has the spec stayed read-only apart from compare start and avoided new persistence?
## Definition of Done
This feature is complete when:
- a workspace-scoped compare matrix exists for one selected baseline profile,
- the matrix uses existing baseline, compare, finding, and run truth rather than a new persisted portfolio artifact,
- visible tenants are strictly filtered by RBAC and hidden tenants do not leak through summaries,
- `Compare assigned tenants` is available to authorized operators and reuses normal compare execution,
- per-tenant deviation and freshness summary is visible,
- per-subject deviation breadth is visible,
- ambiguous, stale, and not-compared states are rendered honestly,
- drilldown to existing tenant compare or finding surfaces works with understandable context,
- no promotion, approval, remediation, or manual match-override logic has been introduced,
- feature and, where appropriate, browser tests cover the core operator flows.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Baseline profiles resource | `app/Filament/Resources/BaselineProfileResource.php` and `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Existing list-header actions remain | Existing explicit `View` inspect affordance remains | Existing `View`; `Edit` and `Archive baseline profile` remain under `More` where already used | None | Existing create CTA remains | `Open compare matrix`, `Compare assigned tenants`, existing `Capture baseline`, `Edit`, and `Archive baseline profile` | Existing save/cancel unchanged | Yes | `Open compare matrix` requires `WORKSPACE_BASELINES_VIEW`. `Compare assigned tenants` requires `WORKSPACE_BASELINES_MANAGE`, is confirmation-gated, is `simulation only`, and remains visible-disabled with helper text for in-scope members who can view but cannot manage. `Archive baseline profile` remains the only destructive action and stays confirmation-gated. |
| Workspace baseline compare matrix page | New workspace page at `app/Filament/Pages/BaselineCompareMatrix.php` or equivalent | `Compare assigned tenants`, `Back to baseline profile` | Explicit tenant, subject, and cell drilldowns only; no row click | Inline `Focus subject` at most; all other follow-up opens use explicit drilldowns | None | One context-specific CTA such as `Capture baseline` or `Compare assigned tenants` depending on the blocked or empty state | Same as header actions | Not applicable | Yes through underlying compare runs | Action Surface Contract remains satisfied. The matrix is a narrow grid-surface exemption because a two-dimensional tenant-by-subject view is not a normal one-axis table. `Compare assigned tenants` remains visible-disabled with helper text for in-scope users missing manage capability, and forced execution still fails with `403`. No destructive actions are added. |
| Tenant compare and finding drilldowns from matrix context | `app/Filament/Pages/BaselineCompareLanding.php` and existing finding resource/detail surfaces | Existing destination actions remain in place | Existing destination inspect models remain in place | Existing destination row actions remain unchanged | Existing destination bulk actions remain unchanged | Existing destination empty-state behavior remains, but matrix arrival context must preserve return meaning | Existing destination view-header actions remain | Not applicable | Existing destination audit behavior remains | This spec reuses destination surfaces and adds bounded matrix-arrival context only. It does not add new destructive actions or competing inspect models there. |
### Key Entities *(include if feature involves data)*
- **Baseline compare matrix view**: The derived workspace projection for one selected baseline profile across visible assigned tenants and baseline subjects.
- **Compare target tenant**: A visible tenant assigned to the selected baseline profile whose existing compare truth can be refreshed or inspected.
- **Compare subject row**: The reusable baseline subject identity shown across the visible tenant set.
- **Matrix cell state**: The derived outcome for one subject and one visible tenant, including compare outcome, freshness, and trust.
- **Tenant freshness summary**: The per-tenant view of last compare time, deviation counts, and urgency against the selected baseline reference.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-190-001**: In validation scenarios with a selected baseline profile and multiple visible assigned tenants, an operator can identify the most divergent visible tenant and the broadest recurring subject from the matrix without opening tenant detail pages first.
- **SC-190-002**: In acceptance coverage, 100% of visible tenants or cells with stale, absent, or ambiguous compare truth remain visually distinct from matched truth.
- **SC-190-003**: An authorized operator can start compare execution for the full visible assigned set in one action and the product shows honest queued, running, completed, or partial progress without inventing a separate batch status language.
- **SC-190-004**: In negative visibility coverage, matrix summaries and counts disclose no hidden tenant identity or hidden-tenant aggregate when the actor can see only part of the assigned set.
- **SC-190-005**: From a differing, missing, or ambiguous cell, the operator can reach an existing tenant-level follow-up surface in one drilldown step with enough preserved context to understand why they arrived there.

View File

@ -0,0 +1,269 @@
# Tasks: Workspace Baseline Compare Matrix V1
**Input**: Design documents from `/specs/190-baseline-compare-matrix/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/baseline-compare-matrix.logical.openapi.yaml`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use Pest unit coverage for centralized matrix badge semantics, Pest feature coverage for matrix aggregation, page rendering, compare-all fan-out, and RBAC semantics, plus one browser smoke test for the rendered matrix surface and one core interaction.
**Operations**: This feature must reuse existing tenant-owned `baseline_compare` `OperationRun` semantics only. Tasks must preserve the Ops-UX 3-surface feedback contract, avoid any workspace umbrella run or shadow batch truth, keep `OperationRun.status` and `OperationRun.outcome` service-owned, keep reused `summary_counts` canonical via `OperationSummaryKeys` and numeric-only, prevent ad hoc queued, running, or completion database notifications, and keep compare-all feedback limited to canonical queued feedback plus existing Monitoring drilldowns.
**RBAC**: Existing workspace membership and tenant visibility remain authoritative. Tasks must preserve deny-as-not-found `404` behavior for non-members, `403` behavior for in-scope members missing `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, or `TENANT_FINDINGS_VIEW`, visible-disabled compare actions plus helper text for in-scope members missing capability where the surface contract requires it, and visible-set-only aggregation so hidden tenants never leak through counts, summaries, or drilldowns.
**Operator Surfaces**: The affected operator surfaces are baseline profile detail, the new workspace baseline compare matrix page, tenant compare landing, finding follow-up surfaces, and Monitoring run drilldowns.
**Filament UI Action Surfaces**: Baseline profile detail keeps its existing inspect model and gains `Open compare matrix` plus confirmation-gated `Compare assigned tenants` header actions. The matrix page exposes explicit tenant, subject, cell, and run drilldowns only, forbids row click, adds no destructive actions, and uses one narrow matrix-grid exception for the two-dimensional body.
**Filament UI UX-001**: The new page must keep Filament-native sections, summaries, legends, filters, and empty states. The grid body is the only approved UX-001 exception because a one-axis table cannot represent subject-by-tenant truth.
**Badges**: Matrix state, freshness, and trust surfaces must use centralized badge semantics through `BadgeDomain`, `BadgeCatalog`, and `BadgeRenderer`. No page-local status color or ad hoc legend mapping is allowed.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently once the shared matrix foundation is in place.
## Phase 1: Setup (Shared Matrix Harness)
**Purpose**: Prepare reusable fixtures and acceptance scaffolds for multi-tenant baseline matrix scenarios shared across all stories.
- [X] T001 [P] Add reusable visible-set baseline matrix fixture builders in `apps/platform/tests/Feature/Concerns/BuildsBaselineCompareMatrixFixtures.php`
- [X] T002 [P] Stage matrix acceptance scaffolds in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
**Checkpoint**: Shared matrix fixtures and empty acceptance seams exist for builder, page, compare-all, RBAC, and browser smoke coverage.
---
## Phase 2: Foundational (Blocking Matrix Core)
**Purpose**: Establish centralized badge semantics, action-surface guards, and reusable query seams that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Add centralized matrix state and trust badge coverage in `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`
- [X] T004 [P] Add matrix header-action, visible-disabled compare-action helper-text, and grid-surface guard coverage in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- [X] T005 [P] Register matrix state and trust badge semantics in `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixStateBadge.php`
- [X] T006 [P] Add visible-assignment and latest-compare query helpers for query-bounded matrix loading in `apps/platform/app/Models/BaselineTenantAssignment.php`, `apps/platform/app/Models/OperationRun.php`, and `apps/platform/app/Models/Finding.php`
- [X] T007 Reuse reference snapshot, freshness, and explanation seams for matrix aggregation in `apps/platform/app/Support/Baselines/BaselineSnapshotTruthResolver.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`, and `apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php`
**Checkpoint**: Central badge semantics and data-access seams are ready, so page, compare-all, drilldown, and degraded-state work can build on one authoritative matrix foundation.
---
## Phase 3: User Story 1 - Scan Visible Drift Across Assigned Tenants (Priority: P1) 🎯 MVP
**Goal**: Let workspace operators open one baseline-scoped matrix and understand visible tenant drift, subject breadth, freshness, and trust from a single page.
**Independent Test**: Open the matrix for a baseline profile with mixed compare outcomes and verify that reference truth, visible tenant columns, subject rows, summaries, filters, and cell states are truthful without opening tenant pages first.
### Tests for User Story 1
- [X] T008 [P] [US1] Add matrix aggregation coverage for visible-set-only counts, subject-axis derivation, and cell precedence in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- [X] T009 [P] [US1] Add matrix page coverage for reference truth, summaries, filters, and grid rendering in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Implement derived reference, tenant, subject, and cell bundle assembly in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T011 [US1] Add the workspace matrix page class, route state, and filter schema in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T012 [US1] Render reference sections, visible-set summaries, legends, and the subject-by-tenant grid in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T013 [US1] Add `Open compare matrix` header navigation on baseline detail in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T014 [US1] Register the matrix page entry seam on the baseline resource in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T015 [US1] Run focused US1 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
**Checkpoint**: Operators can scan one baseline-scoped visible-set matrix and identify the most divergent tenants and subjects without leaving the workspace surface.
---
## Phase 4: User Story 2 - Refresh Compare Truth For The Visible Assigned Set (Priority: P1)
**Goal**: Let authorized operators trigger compare across all visible assigned tenants from the baseline detail or matrix surface without inventing a workspace umbrella run.
**Independent Test**: Start `Compare assigned tenants` from the baseline detail or matrix page and verify that eligible visible tenants reuse normal compare execution, blocked tenants stay explicit, and no second batch truth is created.
### Tests for User Story 2
- [X] T016 [P] [US2] Add compare-all fan-out and Ops-UX regression coverage for queued, already-queued, blocked, no-umbrella-run, service-owned run transitions, canonical `summary_counts`, and no ad hoc non-terminal database notifications in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
- [X] T017 [P] [US2] Extend compare-start surface, confirmation, and visible-disabled helper-text coverage for `Compare assigned tenants` in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 2
- [X] T018 [US2] Implement visible-tenant batch compare start reuse in `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- [X] T019 [US2] Add confirmation-gated `Compare assigned tenants` execution, honest launch summaries, and visible-disabled helper text on the matrix page in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Rbac/UiEnforcement.php`
- [X] T020 [US2] Add confirmation-gated `Compare assigned tenants` execution with capability-gated disabled state on baseline detail in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` and `apps/platform/app/Support/Rbac/UiEnforcement.php`
- [ ] T021 [US2] Reuse canonical queued feedback, initiator-only run links, service-owned run transitions, canonical `summary_counts`, and no ad hoc notification or status writes for compare-all outcomes in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Services/OperationRunService.php`
- [X] T022 [US2] Run focused US2 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
**Checkpoint**: Authorized operators can refresh compare truth for the visible assigned set from one action while Monitoring and tenant compare runs remain the only execution truth.
---
## Phase 5: User Story 3 - Drill From The Matrix Into Existing Follow-Up Surfaces (Priority: P2)
**Goal**: Let operators move from matrix cells, tenant summaries, and subject focus into existing compare, finding, and run detail surfaces without reconstructing context.
**Independent Test**: Open a differing, missing, or ambiguous matrix cell and confirm the product lands on the existing tenant compare or finding path with a bounded return path back to the matrix.
### Tests for User Story 3
- [X] T023 [P] [US3] Add subject-focus and drilldown continuity coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T024 [P] [US3] Add matrix-to-tenant and matrix-to-finding authorization coverage for `404` versus `403` semantics in `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
### Implementation for User Story 3
- [X] T025 [US3] Add tenant, subject, finding, and run drilldown state handling in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T026 [US3] Reuse canonical matrix return-path context in `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
- [X] T027 [US3] Accept matrix source context on tenant compare and finding follow-up surfaces in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/app/Filament/Resources/FindingResource.php`
- [ ] T028 [US3] Expose bounded back-link and source-hint rendering for matrix arrivals in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/app/Filament/Resources/OperationRunResource.php`
- [X] T029 [US3] Run focused US3 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
**Checkpoint**: The matrix becomes a decision surface instead of a dead-end report because operators can drill directly into existing follow-up workflows and return cleanly.
---
## Phase 6: User Story 4 - Stay Honest In Degraded Or Low-Trust Conditions (Priority: P2)
**Goal**: Keep blocked, stale, ambiguous, not-compared, and low-trust states visibly distinct so the matrix never reads calmer than the underlying truth.
**Independent Test**: Open the matrix for a baseline profile with no usable snapshot, no visible tenants, stale results, ambiguous matches, and uncovered policy types, and verify that none of those states render as healthy matches.
### Tests for User Story 4
- [X] T030 [P] [US4] Add degraded-state page coverage for no usable snapshot, no assigned tenants, no visible tenants, and no compare results in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T031 [P] [US4] Add ambiguity, stale-result, uncovered-policy-type, policy-type filter honesty, and query-shape guard coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` and `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`
### Implementation for User Story 4
- [X] T032 [US4] Reuse stale-result and evidence-gap semantics inside `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`
- [X] T033 [US4] Render explicit blocked, empty, stale, ambiguous, and low-trust states in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T034 [US4] Add policy-type, state-group, severity, tenant-sort, subject-sort, and subject-focus state handling in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T035 [US4] Run focused US4 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
**Checkpoint**: The matrix stays honest when data is stale, missing, ambiguous, or invisible, so operators do not mistake degraded truth for clean posture.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finalize guard coverage, browser confidence, copy review, formatting, and the focused verification pack across all stories.
- [X] T036 [P] Add no-ad-hoc-badge and no-diagnostic-warning guard coverage for matrix state surfaces in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
- [X] T037 [P] Add browser smoke coverage for matrix render, one filter interaction, and one drilldown or compare affordance in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- [X] T038 [P] Review `Verb + Object`, `visible-set only`, and `simulation only` copy in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T041 [P] Tighten matrix scanability with active-filter scope summaries, visible-set scope disclosure, non-blocking refresh feedback, sticky subject-column treatment, and focused UI regression coverage in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- [X] T039 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/190-baseline-compare-matrix/quickstart.md`
- [ ] T040 Run the focused verification pack from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and prepares shared fixtures plus empty acceptance seams.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user-story work until centralized badge semantics, action-surface guards, and query seams exist.
- **User Story 1 (Phase 3)**: Starts after Foundational and is the recommended engineering MVP because it delivers the first truthful read-only matrix.
- **User Story 2 (Phase 4)**: Starts after User Story 1 because compare-all actions reuse the matrix page and baseline-detail seams established in the MVP.
- **User Story 3 (Phase 5)**: Starts after User Story 1 because drilldown continuity depends on matrix page state and cell metadata.
- **User Story 4 (Phase 6)**: Starts after User Story 1 and can proceed in parallel with User Story 2 or User Story 3 once the core matrix bundle and page exist.
- **Polish (Phase 7)**: Starts after all desired user stories are complete.
### User Story Dependencies
- **US1**: Depends only on the shared matrix foundation.
- **US2**: Depends on US1 for the matrix page, baseline-detail entry seams, and derived visible-set truth.
- **US3**: Depends on US1 for matrix page state and cell metadata used by drilldowns.
- **US4**: Depends on US1 for the matrix builder and page, then hardens degraded-state and filter honesty across the same surface.
### Within Each User Story
- Write or extend the story tests first and confirm they fail before implementation is considered complete.
- Land shared service, builder, or navigation changes before view and copy wiring in the same story.
- Keep each story shippable on its own before moving to the next priority.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003` and `T004` can run in parallel before the shared badge and action-surface seams land.
- `T005` and `T006` can run in parallel once the corresponding tests exist.
- Within US1, `T008` and `T009` can run in parallel, then `T011` and `T012` can split after `T010` defines the matrix bundle.
- Within US2, `T016` and `T017` can run in parallel, then `T019` and `T020` can split after `T018` lands the batch compare seam.
- Within US3, `T023` and `T024` can run in parallel, then `T026`, `T027`, and `T028` can split after `T025` defines the page drilldown state.
- Within US4, `T030` and `T031` can run in parallel, then `T033` and `T034` can split after `T032` lands the degraded-state semantics.
- Within Phase 7, `T036`, `T037`, and `T038` can run in parallel before formatting and the final verification pack.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T008 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
T009 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
# User Story 1 implementation split after the matrix bundle exists
T011 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
T012 apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T016 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
T017 apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
# User Story 2 implementation split after the batch start seam lands
T019 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
T020 apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T023 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
T024 apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
# User Story 3 implementation split after drilldown state exists
T026 apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
T027 apps/platform/app/Filament/Pages/BaselineCompareLanding.php
```
## Parallel Example: User Story 4
```bash
# User Story 4 tests in parallel
T030 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
T031 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
# User Story 4 implementation split after degraded-state semantics land
T033 apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
T034 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
```
---
## Implementation Strategy
### MVP First
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. **STOP and VALIDATE**: Confirm the read-only matrix answers the visible-set portfolio question honestly before adding refresh or drilldown behavior.
### Incremental Delivery
1. Deliver Setup plus Foundational to lock shared matrix fixtures, centralized badge semantics, action-surface guards, and query seams.
2. Deliver User Story 1 so operators can scan one truthful cross-tenant baseline matrix.
3. Deliver User Story 2 so operators can refresh visible-set compare truth from the same workflow.
4. Deliver User Story 3 so the matrix becomes a decision surface with bounded follow-up continuity.
5. Deliver User Story 4 so degraded and low-trust conditions remain explicit under the final filter set.
6. Finish with browser confidence, copy review, formatting, and the focused verification pack.
### Parallel Team Strategy
1. One contributor can prepare fixture scaffolds and browser seams while another adds badge and action-surface guards.
2. After the foundation lands, one contributor can own the matrix builder while another wires the page shell and Blade view.
3. Once the matrix page exists, one contributor can take compare-all fan-out while another handles drilldown continuity.
4. Degraded-state hardening can proceed in parallel with drilldown work once the base matrix bundle and page filters are stable.
5. Rejoin for Polish so guard suites, browser smoke coverage, copy review, formatting, and the final verification pack land together.
---
## Notes
- `[P]` tasks target different files or safe concurrent work after prerequisite seams are in place.
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the user stories in `spec.md`.
- The recommended engineering MVP scope is Phase 1 through Phase 3. The product-complete P1 scope is Phase 1 through Phase 4.
- This task plan stays compliant with Filament v5 on Livewire v4, makes no panel-provider changes in `bootstrap/providers.php`, introduces no new globally searchable resource, adds no destructive action, and requires no asset-strategy change beyond the existing deployment process.

View File

@ -0,0 +1,13 @@
# Requirements Checklist: Baseline Compare Matrix: High-Density Operator Mode
- [x] Spec candidate check is complete and scores the candidate before approval.
- [x] The spec is explicitly scoped as a follow-up to the existing workspace matrix rather than a new domain truth.
- [x] Multi-tenant dense mode is defined as the primary operator-density gain.
- [x] Single-tenant compact mode is defined as a separate adaptive presentation path.
- [x] Filters, legends, actions, and refresh surfaces are explicitly compressed as supporting context.
- [x] Visible-set-only semantics and existing RBAC rules are preserved.
- [x] No new persisted artifact, state family, or generalized UI framework is introduced.
- [x] Manual presentation override is local to the route and not stored as domain truth.
- [x] Functional requirements include mode selection, action calming, filter workflow, and last-updated visibility.
- [x] Definition of done is testable and aligned with operator scanability rather than generic visual polish.
- [x] Tasks are grouped by user story and include focused verification work.

View File

@ -0,0 +1,518 @@
openapi: 3.1.0
info:
title: Baseline Compare Matrix Operator Mode Internal Surface Contract
version: 0.2.0
summary: Internal logical contract for adaptive operator-density rendering on the existing baseline compare matrix route
description: |
This contract is an internal planning artifact for Spec 191. The affected surface
still renders HTML through Filament and Livewire. The schemas below define the
bounded request-scoped presentation models and staged filter interactions that must
be derivable from existing Spec 190 matrix truth before the operator-density
refactor can render safely.
servers:
- url: /internal
x-baseline-compare-operator-mode-consumers:
- surface: baseline.compare.matrix
sourceFiles:
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
mustRender:
- reference
- requested_vs_resolved_mode
- presentation_state
- support_surface_state
- applied_filters
- draft_filters
- staged_filter_changes
- tenant_summaries
- dense_rows_or_compact_results
- last_updated_at
- auto_refresh_state
mustAccept:
- mode
- policy_type
- state
- severity
- tenant_sort
- subject_sort
- subject_key
mustStage:
- selectedPolicyTypes
- selectedStates
- selectedSeverities
- tenantSort
- subjectSort
paths:
/admin/baseline-profiles/{profile}/compare-matrix:
get:
summary: Render the existing baseline compare matrix using adaptive operator-density presentation
operationId: viewBaselineCompareOperatorMode
parameters:
- name: profile
in: path
required: true
schema:
type: integer
- name: mode
in: query
required: false
schema:
$ref: '#/components/schemas/PresentationMode'
- name: policy_type
in: query
required: false
schema:
type: array
items:
type: string
- name: state
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
- name: severity
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
- name: tenant_sort
in: query
required: false
schema:
type: string
- name: subject_sort
in: query
required: false
schema:
type: string
- name: subject_key
in: query
required: false
schema:
type: string
responses:
'200':
description: Rendered matrix plus adaptive operator-density read models
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
schema:
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/apply-filters:
post:
summary: Apply staged heavy filters to the operator-density matrix route
operationId: applyBaselineCompareOperatorFilters
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixFilterDraft'
responses:
'200':
description: Updated operator-density bundle using the applied filter state
content:
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
schema:
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/reset-filters:
post:
summary: Reset staged and applied heavy filters for the operator-density matrix route
operationId: resetBaselineCompareOperatorFilters
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
responses:
'200':
description: Updated operator-density bundle with default filter state restored
content:
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
schema:
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
components:
schemas:
PresentationMode:
type: string
enum:
- auto
- dense
- compact
MatrixCellState:
type: string
enum:
- match
- differ
- missing
- ambiguous
- not_compared
- stale_result
FindingSeverity:
type: string
enum:
- low
- medium
- high
- critical
FreshnessState:
type: string
enum:
- fresh
- stale
- never_compared
- unknown
TrustLevel:
type: string
enum:
- trustworthy
- limited_confidence
- diagnostic_only
- unusable
AttentionLevel:
type: string
enum:
- aligned
- review
- refresh_recommended
- needs_attention
MatrixReference:
type: object
additionalProperties: false
required:
- baselineProfileId
- baselineProfileName
- referenceState
- assignedTenantCount
- visibleTenantCount
properties:
baselineProfileId:
type: integer
baselineProfileName:
type: string
referenceSnapshotId:
type:
- integer
- 'null'
referenceState:
type: string
assignedTenantCount:
type: integer
visibleTenantCount:
type: integer
MatrixFilterDraft:
type: object
additionalProperties: false
required:
- selectedPolicyTypes
- selectedStates
- selectedSeverities
- tenantSort
- subjectSort
properties:
selectedPolicyTypes:
type: array
items:
type: string
selectedStates:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
selectedSeverities:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
tenantSort:
type: string
subjectSort:
type: string
focusedSubjectKey:
type:
- string
- 'null'
MatrixPresentationState:
type: object
additionalProperties: false
required:
- requestedMode
- resolvedMode
- visibleTenantCount
- activeFilterCount
- hasStagedFilterChanges
- autoRefreshActive
- lastUpdatedAt
- canOverrideMode
- compactModeAvailable
properties:
requestedMode:
$ref: '#/components/schemas/PresentationMode'
resolvedMode:
type: string
enum:
- dense
- compact
description: |
Final render mode after evaluating the requested route mode against the
visible tenant count. A requested `compact` mode may still resolve to
`dense` when more than one visible tenant remains in scope.
visibleTenantCount:
type: integer
activeFilterCount:
type: integer
hasStagedFilterChanges:
type: boolean
autoRefreshActive:
type: boolean
lastUpdatedAt:
type:
- string
- 'null'
format: date-time
canOverrideMode:
type: boolean
compactModeAvailable:
type: boolean
MatrixTenantSummary:
type: object
additionalProperties: false
required:
- tenantId
- tenantName
- freshnessState
- differingCount
- missingCount
- ambiguousCount
- trustLevel
properties:
tenantId:
type: integer
tenantName:
type: string
freshnessState:
$ref: '#/components/schemas/FreshnessState'
lastComparedAt:
type:
- string
- 'null'
format: date-time
differingCount:
type: integer
missingCount:
type: integer
ambiguousCount:
type: integer
trustLevel:
$ref: '#/components/schemas/TrustLevel'
maxSeverity:
type:
- string
- 'null'
DenseCellView:
type: object
additionalProperties: false
required:
- tenantId
- subjectKey
- state
- freshnessState
- trustLevel
- attentionLevel
properties:
tenantId:
type: integer
subjectKey:
type: string
state:
$ref: '#/components/schemas/MatrixCellState'
freshnessState:
$ref: '#/components/schemas/FreshnessState'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
severity:
type:
- string
- 'null'
attentionLevel:
$ref: '#/components/schemas/AttentionLevel'
reasonSummary:
type:
- string
- 'null'
primaryDrilldownUrl:
type:
- string
- 'null'
secondaryDrilldownUrls:
type: object
additionalProperties:
type: string
DenseSubjectRowView:
type: object
additionalProperties: false
required:
- subjectKey
- displayName
- policyType
- deviationBreadth
- missingBreadth
- ambiguousBreadth
- trustLevel
- cells
properties:
subjectKey:
type: string
displayName:
type: string
policyType:
type: string
baselineExternalId:
type:
- string
- 'null'
deviationBreadth:
type: integer
missingBreadth:
type: integer
ambiguousBreadth:
type: integer
maxSeverity:
type:
- string
- 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
cells:
type: array
items:
$ref: '#/components/schemas/DenseCellView'
CompactSubjectResultView:
type: object
additionalProperties: false
required:
- tenantId
- subjectKey
- displayName
- policyType
- state
- freshnessState
- trustLevel
properties:
tenantId:
type: integer
subjectKey:
type: string
displayName:
type: string
policyType:
type: string
state:
$ref: '#/components/schemas/MatrixCellState'
freshnessState:
$ref: '#/components/schemas/FreshnessState'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
severity:
type:
- string
- 'null'
reasonSummary:
type:
- string
- 'null'
primaryDrilldownUrl:
type:
- string
- 'null'
runUrl:
type:
- string
- 'null'
MatrixSupportSurfaceState:
type: object
additionalProperties: false
required:
- legendMode
- showActiveFilterSummary
- showLastUpdated
- showAutoRefreshHint
- showBlockingRefreshState
properties:
legendMode:
type: string
showActiveFilterSummary:
type: boolean
showLastUpdated:
type: boolean
showAutoRefreshHint:
type: boolean
showBlockingRefreshState:
type: boolean
BaselineCompareOperatorModeBundle:
type: object
additionalProperties: false
required:
- reference
- presentation
- supportSurface
- appliedFilters
- draftFilters
- tenantSummaries
properties:
reference:
$ref: '#/components/schemas/MatrixReference'
presentation:
$ref: '#/components/schemas/MatrixPresentationState'
supportSurface:
$ref: '#/components/schemas/MatrixSupportSurfaceState'
appliedFilters:
$ref: '#/components/schemas/MatrixFilterDraft'
draftFilters:
$ref: '#/components/schemas/MatrixFilterDraft'
tenantSummaries:
type: array
items:
$ref: '#/components/schemas/MatrixTenantSummary'
denseRows:
type: array
items:
$ref: '#/components/schemas/DenseSubjectRowView'
compactResults:
type: array
items:
$ref: '#/components/schemas/CompactSubjectResultView'
$ref: '#/components/schemas/CompactSubjectResultView'

View File

@ -0,0 +1,166 @@
# Data Model: Baseline Compare Matrix: High-Density Operator Mode
## Overview
This follow-up introduces no new persisted entity. It reuses the existing Spec 190 matrix truth and adds derived presentation models for operator density, staged filtering, and non-blocking status cues.
## Existing Source Truths Reused Without Change
### Baseline compare truth from Spec 190
The following derived or canonical inputs remain authoritative and are not redefined by this spec:
- workspace-scoped baseline reference truth
- visible tenant summaries
- subject summaries
- subject-by-tenant matrix cells
- compare-start availability and existing drilldown destinations
This spec changes how those inputs are rendered and interacted with, not how they are computed.
## New Derived Presentation Models
### MatrixPresentationState
**Type**: request-scoped page presentation contract
**Source**: route/query state + visible tenant count + existing run state
| Field | Type | Notes |
|------|------|-------|
| `requestedMode` | string | `auto`, `dense`, or `compact` from route/query state |
| `resolvedMode` | string | Final mode used for rendering: `dense` or `compact` |
| `visibleTenantCount` | integer | Existing visible-set count from the matrix bundle |
| `activeFilterCount` | integer | Count of currently applied filters |
| `hasStagedFilterChanges` | boolean | Whether filter draft state differs from applied state |
| `autoRefreshActive` | boolean | True when background polling is active because compare work is queued or running |
| `lastUpdatedAt` | datetime or null | Timestamp for the currently rendered matrix data |
| `canOverrideMode` | boolean | Whether the operator may locally switch away from `auto` |
### MatrixFilterDraft
**Type**: request-scoped staged filter model
**Source**: page form state only
| Field | Type | Notes |
|------|------|-------|
| `selectedPolicyTypes` | array<string> | Draft policy-type filter selection |
| `selectedStates` | array<string> | Draft state-group selection |
| `selectedSeverities` | array<string> | Draft severity selection |
| `tenantSort` | string | Current tenant sort choice |
| `subjectSort` | string | Current subject sort choice |
| `focusedSubjectKey` | string or null | Optional current subject focus |
### DenseSubjectRowView
**Type**: request-scoped dense-mode row view
**Source**: existing subject summary + existing matrix cells
| Field | Type | Notes |
|------|------|-------|
| `subjectKey` | string | Stable row key |
| `displayName` | string | Primary row label |
| `policyType` | string | Compact secondary label |
| `baselineExternalId` | string or null | Optional secondary context |
| `deviationBreadth` | integer | Existing subject summary metric |
| `missingBreadth` | integer | Existing subject summary metric |
| `ambiguousBreadth` | integer | Existing subject summary metric |
| `maxSeverity` | string or null | Existing subject summary severity |
| `trustLevel` | string | Existing subject summary trust |
| `cells` | array<DenseCellView> | One condensed cell per visible tenant |
### DenseCellView
**Type**: request-scoped dense-mode cell view
**Source**: existing matrix cell + existing tenant summary freshness
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | Visible tenant identifier |
| `subjectKey` | string | Subject row key |
| `state` | string | Existing Spec 190 state |
| `freshnessState` | string | Freshness signal shown in compact form |
| `trustLevel` | string | Trust signal shown in compact form |
| `severity` | string or null | Optional attention signal |
| `attentionLevel` | string | Derived presentation label such as `aligned`, `refresh_recommended`, or `needs_attention` |
| `reasonSummary` | string or null | Short secondary explanation for compact reveal surfaces |
| `primaryDrilldownUrl` | string or null | Preferred next follow-up action |
| `secondaryDrilldownUrls` | array<string, string> | Additional compact follow-up links when available |
### CompactSubjectResultView
**Type**: request-scoped single-tenant row view
**Source**: one visible tenant summary + existing matrix cell + existing subject summary
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | The single visible tenant in compact mode |
| `subjectKey` | string | Stable subject key |
| `displayName` | string | Primary subject label |
| `policyType` | string | Secondary grouping/context |
| `state` | string | Existing Spec 190 state |
| `freshnessState` | string | Compact freshness label |
| `trustLevel` | string | Compact trust label |
| `severity` | string or null | Optional attention indicator |
| `reasonSummary` | string or null | Short explanation line |
| `primaryDrilldownUrl` | string or null | Main follow-up action |
| `runUrl` | string or null | Secondary run-level follow-up |
### MatrixSupportSurfaceState
**Type**: request-scoped supporting-context contract
**Source**: page state + existing legends + refresh metadata
| Field | Type | Notes |
|------|------|-------|
| `legendMode` | string | `grouped`, `collapsed`, or equivalent compact support behavior |
| `showActiveFilterSummary` | boolean | Whether applied filters are summarized inline |
| `showLastUpdated` | boolean | Whether the page displays last-updated metadata |
| `showAutoRefreshHint` | boolean | Whether passive auto-refresh copy is visible |
| `showBlockingRefreshState` | boolean | Reserved for deliberate user-triggered reloads only |
## Rendering and Resolution Rules
### Mode resolution rules
1. If `requestedMode = auto` and `visibleTenantCount > 1`, resolve to `dense`.
2. If `requestedMode = auto` and `visibleTenantCount = 1`, resolve to `compact`.
3. If a manual override is present, use it unless it would produce an invalid empty layout.
4. Manual override remains route-local and must never be persisted as product truth.
### Dense-mode rules
- The subject column remains sticky during horizontal scroll.
- The primary visible content per cell is state, trust, freshness, and attention.
- Long explanatory text and repeated action links do not render as the dominant cell body.
### Compact single-tenant rules
- The tenant header does not repeat as a pseudo-column structure.
- Each subject entry shows one primary status line and a reduced set of secondary metadata.
- Existing subject focus and drilldown continuity remain available.
### Filter workflow rules
- Heavy multi-select filters use staged state first and apply only when the operator confirms.
- Applied filter count and scope summary reflect the applied state, not merely the draft state.
- Reset may clear both draft and applied state in one explicit action.
### Status signal rules
- `blocking refresh` is reserved for deliberate user-triggered reload or recalculation moments.
- `auto-refresh active` indicates passive polling while compare work is still queued or running.
- `lastUpdatedAt` reflects the timestamp of the rendered matrix payload, not merely the latest compare run in the system.
### Safety rules
- No rendering path may widen tenant visibility beyond the existing visible set.
- No presentation-state change may change the underlying compare state, trust, or freshness semantics.
- No grouped legend or compact cell may invent new status vocabulary outside existing centralized badge semantics.
## Relationships
- One `MatrixPresentationState` governs one rendered matrix page.
- One `MatrixFilterDraft` belongs to one `MatrixPresentationState`.
- In dense mode, one `DenseSubjectRowView` maps to many `DenseCellView` entries.
- In compact mode, one visible tenant yields many `CompactSubjectResultView` entries.
- One `MatrixSupportSurfaceState` coordinates legends, refresh hints, and active-filter summaries for the same page render.

View File

@ -0,0 +1,200 @@
# Implementation Plan: Baseline Compare Matrix: High-Density Operator Mode
**Branch**: `191-baseline-compare-operator-mode` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md`
**Note**: This plan formalizes the existing 191 spec slice and keeps the work strictly inside the already-shipped Spec 190 matrix surface.
## Summary
Refactor the existing workspace baseline compare matrix into an adaptive operator-density surface. The route, baseline reference, visible-set-only truth, compare-start behavior, and drilldowns stay unchanged, but the page gains local presentation-mode state, dense multi-tenant scanning, compact single-tenant rendering, staged heavy-filter application, grouped legends, and clearer separation between blocking refresh, passive auto-refresh, and last-updated status.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
**Storage**: PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned
**Testing**: Pest feature tests and browser smoke coverage run through Laravel Sail
**Target Platform**: Laravel monolith web application under `apps/platform`
**Project Type**: web application
**Performance Goals**: Improve scan throughput without increasing query shape beyond Spec 190, keep heavy filter changes non-chatty, and preserve DB-only render-time matrix surfaces
**Constraints**: No compare-logic change, no new persistence, no hidden-tenant leakage, no generalized density framework, no provider or panel changes, and no new asset pipeline
**Scale/Scope**: One existing matrix page, one existing Blade view, one existing builder, one logical contract file, and focused feature plus browser regressions
## 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 spec changes presentation only and keeps Spec 190 truth sources intact. |
| Read/write separation | PASS | PASS | `Compare assigned tenants` remains the only mutation and is unchanged. |
| Graph contract path | N/A | N/A | No new Graph behavior or contract-registry work is introduced. |
| Deterministic capabilities | PASS | PASS | Existing capabilities remain canonical and unchanged. |
| Workspace + tenant isolation | PASS | PASS | Visible-set-only aggregation and drilldown scope remain unchanged. |
| RBAC-UX authorization semantics | PASS | PASS | Existing `404` vs `403` semantics and server-side enforcement remain unchanged. |
| Run observability / Ops-UX | PASS | PASS | Compare-run truth is reused exactly as in Spec 190; this spec only clarifies the visual cues around it. |
| Data minimization | PASS | PASS | No new data copies, exports, or persisted UI artifacts are introduced. |
| Proportionality / anti-bloat | PASS | PASS | The work stays local to one page and does not add a new abstraction or stored artifact. |
| Persisted truth / behavioral state | PASS | PASS | Presentation mode and staged filter state remain request-scoped only. |
| UI semantics / few layers | PASS | PASS | Existing state, trust, freshness, and severity semantics are reused rather than redefined. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The work remains inside the existing Filament page and Livewire-backed route. |
| Provider registration location | PASS | PASS | No provider changes are required; Laravel 11+ registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new searchable resource or page is introduced. |
| Destructive action safety | PASS | PASS | No destructive action is added. Existing confirmation behavior for compare-start remains unchanged. |
| Asset strategy | PASS | PASS | No new assets are required. Existing deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: This plan remains on Filament v5 + Livewire v4 and does not introduce legacy APIs.
- **Provider registration location**: No panel or provider changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
- **Global search**: The feature does not add a new globally searchable resource. Existing baseline-resource search behavior is unchanged.
- **Destructive actions**: No new destructive action is introduced. Existing compare-start actions remain confirmation-gated where already defined.
- **Asset strategy**: No new global or on-demand asset registration is planned. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Extend the existing matrix feature, builder, guard, and browser suites to cover presentation mode, staged filter application, and non-blocking status surfaces.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/research.md`.
Key decisions:
- Keep the existing matrix route and truth model and change presentation only.
- Resolve `auto`, `dense`, and `compact` mode from visible tenant count, with a route-local override only.
- Make dense mode state-first rather than action-first.
- Render single-tenant review as a compact compare list rather than a one-column matrix.
- Convert heavy filters to staged apply/reset semantics.
- Replace the long policy-type checkbox stack with a more compact operator-first selector.
- Group legends into compact support context and separate blocking refresh from passive auto-refresh and last-updated cues.
- Reuse existing drilldown and visible-set semantics unchanged.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/`:
- `research.md`: decisions and rejected alternatives for local operator-density work
- `data-model.md`: request-scoped presentation models for mode state, staged filters, dense rows, compact results, and support-surface state
- `contracts/baseline-compare-operator-mode.logical.openapi.yaml`: internal logical contract for adaptive rendering and staged filter application
- `quickstart.md`: implementation and verification sequence for the follow-up spec
Design decisions:
- `auto` remains the default requested mode and resolves to `dense` for multiple visible tenants and `compact` for exactly one visible tenant.
- Manual mode override remains route-local and must never become stored product truth.
- Dense mode reuses existing compare truth but condenses cell content to state, trust, freshness, and attention.
- Compact mode reuses the same truth but removes pseudo-matrix structure once only one visible tenant remains.
- Heavy filter inputs stage locally and apply explicitly; lightweight route-state changes may remain immediate.
- Grouped legends, passive auto-refresh, and last-updated signals become support context rather than competing top-level content.
## Project Structure
### Documentation (this feature)
```text
specs/191-baseline-compare-operator-mode/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── tasks.md
├── contracts/
│ └── baseline-compare-operator-mode.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Pages/
│ │ └── BaselineCompareMatrix.php
│ └── Support/
│ └── Baselines/
│ └── BaselineCompareMatrixBuilder.php
├── resources/views/filament/pages/
│ └── baseline-compare-matrix.blade.php
└── tests/
├── Browser/
│ └── Spec190BaselineCompareMatrixSmokeTest.php
├── Feature/
│ ├── Baselines/
│ │ └── BaselineCompareMatrixBuilderTest.php
│ ├── Filament/
│ │ └── BaselineCompareMatrixPageTest.php
│ └── Guards/
│ └── ActionSurfaceContractTest.php
└── Unit/
└── Badges/
```
**Structure Decision**: Keep the work inside the existing Spec 190 matrix implementation surface. This is a presentation refactor of one existing page and its supporting builder/view behavior, not a new domain slice or a new application area.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | N/A | The follow-up stays within the existing page, builder, and test surfaces and introduces no new structural violation. |
## Proportionality Review
- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning.
- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant workflows, so supporting context, action repetition, and cell chrome are too heavy in both cases.
- **Narrowest correct implementation**: Keep the same route, truth sources, drilldowns, and compare semantics while adding route-local presentation state, denser rendering, and staged filter application.
- **Ownership cost created**: Additional view-state logic, a logical contract file, and focused regression coverage for mode resolution, filter workflow, and status visibility.
- **Alternative intentionally rejected**: A generalized density framework, a separate dense-report route, or a stored matrix artifact were rejected because the problem is local to the existing matrix surface.
- **Release truth**: current-release operator workflow compression
## Implementation Strategy
### Phase A — Presentation Mode Contract
- Add route-local `auto`, `dense`, and `compact` mode state.
- Resolve the active mode from visible tenant count unless manually overridden.
- Expose `lastUpdatedAt`, `hasStagedFilterChanges`, and passive auto-refresh state to the page.
### Phase B — Dense Multi-Tenant Surface
- Keep the subject column sticky during horizontal scroll.
- Condense dense cells to state, trust, freshness, and attention signals.
- Move repeated actions into compact secondary affordances without breaking drilldown continuity.
### Phase C — Compact Single-Tenant Surface
- Replace pseudo-matrix rendering with a compact subject-result list when only one visible tenant remains.
- Remove repeated tenant headers and duplicated secondary metadata.
- Preserve subject focus and the existing compare/finding/run destinations.
### Phase D — Supporting Context Compression
- Convert heavy matrix filters to staged apply/reset behavior.
- Replace the current long policy-type control with a more compact selector.
- Group or collapse legends.
- Separate blocking refresh from passive auto-refresh and last-updated status.
### Phase E — Verification
- Extend focused feature coverage for mode resolution, staged filter behavior, and support-surface state.
- Extend browser smoke coverage for one dense-mode path and one compact-mode path.
- Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Dense mode becomes another framework | Medium | Low | Keep presentation logic local to the matrix page and avoid generalized shared abstractions. |
| Compact mode hides too much follow-up value | Medium | Medium | Preserve one clear primary drilldown per subject and keep existing follow-up destinations intact. |
| Staged filtering feels slow or unclear | Medium | Medium | Show explicit staged/applied state and keep reset obvious. |
| Manual override confuses operators | Low | Medium | Keep `auto` as the default and surface the resolved mode clearly. |
| Last-updated and auto-refresh cues drift out of sync | Medium | Low | Derive both cues from the same rendered matrix payload and active-run state. |
## Test Strategy
- Extend `BaselineCompareMatrixPageTest` for requested vs resolved mode, active filter application, compact vs dense rendering, and non-blocking refresh cues.
- Extend `BaselineCompareMatrixBuilderTest` for any new derived presentation metadata required by the page.
- Keep `ActionSurfaceContractTest` green so calmer actions do not regress the surface contract.
- Extend `Spec190BaselineCompareMatrixSmokeTest` to prove one dense-mode and one compact-mode operator path on the Livewire page.
- Run the focused Sail verification pack from `quickstart.md` and re-run `update-agent-context.sh copilot` after the plan is finalized.

View File

@ -0,0 +1,70 @@
# Quickstart: Baseline Compare Matrix: High-Density Operator Mode
## Goal
Turn the existing baseline compare matrix into a denser operator surface without changing its underlying compare truth. Multi-tenant use should favor high-density cross-tenant scanning, while single-tenant use should collapse into a calmer compact comparison view.
## Implementation Sequence
1. Add page-level presentation state.
- Add `auto`, `dense`, and `compact` route-local mode state.
- Resolve the active mode from visible tenant count unless the operator explicitly overrides it.
- Expose `lastUpdatedAt`, staged-filter state, and passive auto-refresh state on the page.
2. Build the dense multi-tenant rendering contract.
- Keep the subject column sticky.
- Reduce dense-cell chrome to state, trust, freshness, and attention.
- Move repeated follow-up links into compact secondary affordances.
3. Build the compact single-tenant rendering contract.
- Replace the pseudo-matrix layout with a compact subject-result list.
- Remove repeated tenant headers and repeated metadata blocks.
- Preserve subject focus and existing drilldowns.
4. Compress supporting context.
- Convert heavy filters to staged apply/reset semantics.
- Replace the current long policy-type list with a more compact operator-first control.
- Group or collapse legends so they remain available without dominating the page.
- Separate blocking refresh from passive auto-refresh and last-updated status.
5. Extend regression coverage.
- Cover mode resolution, dense multi-tenant layout, compact single-tenant layout, staged filters, and non-blocking refresh cues.
- Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green.
## Suggested Test Files
- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open a baseline profile whose matrix has multiple visible tenants and confirm `auto` resolves to dense mode.
2. Verify the first subject column remains visible while horizontally scrolling dense mode.
3. Confirm dense cells foreground compare state, trust, freshness, and attention before links or long prose.
4. Open a matrix that resolves to one visible tenant and confirm `auto` resolves to compact mode instead of a one-column matrix.
5. Change heavy filters and confirm the page stages those changes until the operator applies them.
6. Confirm active filter count and filter summary reflect the applied state clearly.
7. Confirm legends are still understandable but no longer dominate the top of the page.
8. Trigger or observe queued/running compare work and confirm passive auto-refresh does not look like a permanent blocking load.
9. Confirm the page shows when the current matrix payload was last updated.
10. Verify tenant compare, finding, and run drilldowns still preserve the existing matrix context.
## Deployment Notes
- No migration is expected.
- No new asset registration is expected.
- No queue topology change is expected because compare execution semantics stay unchanged.

View File

@ -0,0 +1,111 @@
# Research: Baseline Compare Matrix: High-Density Operator Mode
## Decision: Keep the existing matrix route and truth model, and change presentation only
### Rationale
Spec 190 already established the correct workspace route, the correct baseline reference model, and the correct visible-set-only compare truth. The operator-density follow-up should stay on `/admin/baseline-profiles/{record}/compare-matrix` and must not introduce a second route, a second report artifact, or a second source of matrix truth.
### Alternatives considered
- Add a separate `dense report` page: rejected because it would duplicate the same baseline-scoped workflow on a second route.
- Add a stored matrix snapshot: rejected because the operator problem is scan efficiency, not missing persistence.
## Decision: Resolve presentation mode from visible tenant count, with a local override only
### Rationale
The core operator split is real: one visible tenant is a compact review problem, while several visible tenants create a cross-tenant scan problem. The narrowest implementation is one requested mode (`auto`, `dense`, or `compact`) and one resolved mode at render time. `auto` should remain the default, while manual override stays local to the matrix route and must not become stored user preference or domain truth.
### Alternatives considered
- Separate feature flags or separate navigation entries for each mode: rejected because the matrix should remain one operator surface.
- Persist mode preference per user: rejected because the current need is local workflow control, not profile-level personalization.
## Decision: Dense mode must be state-first, not action-first
### Rationale
In multi-tenant reading, the primary questions are where drift exists, how severe it is, whether the signal is trustworthy, and what deserves follow-up next. Dense cells should therefore foreground compare state, trust, freshness, and attention, while detailed reasons and repeated links move into compact secondary affordances.
### Alternatives considered
- Keep the current repeated open-link pattern in every cell: rejected because repeated actions visually outrank the state being scanned.
- Remove cell-level follow-up completely: rejected because the matrix must remain a decision surface, not a dead-end report.
## Decision: Single-tenant mode should be a compact compare list, not a one-column matrix
### Rationale
Once only one visible tenant remains, the value of cross-tenant columns disappears. The surface should switch to a shorter subject-result list that reuses the same truth but removes repeated tenant headers, empty width, and oversized cell chrome.
### Alternatives considered
- Reuse dense mode even for one tenant: rejected because it preserves the wrong reading model.
- Route single-tenant viewing away to the tenant compare page: rejected because the operator still started from the workspace baseline matrix context and should not lose that context automatically.
## Decision: Heavy filters should use staged apply/reset semantics
### Rationale
The current matrix is dense enough that chatty recomputation on every multi-select click works against operator flow. Policy types and other heavy matrix filters should stage changes locally, show that staged state clearly, and apply them deliberately. This improves calmness and makes the surface feel less like a form page.
### Alternatives considered
- Keep all filters live: rejected because heavy multi-select controls create noisy redraw behavior.
- Convert every filter to manual apply: rejected because lightweight interactions such as mode switching or focused-subject clearing should remain immediate.
## Decision: Replace the long policy-type checkbox stack with a more compact operator-first selector
### Rationale
The policy-type filter is the most visually expensive control on the page. The follow-up spec should use a denser selection pattern such as searchable multi-select, type-to-find, or another compact control that exposes the same filter truth without the current long vertical list.
### Alternatives considered
- Keep the long checkbox list and only restyle it: rejected because vertical space is the actual product problem.
- Hide policy type filtering behind a modal by default: rejected because the filter remains core enough to deserve immediate access.
## Decision: Legends should become grouped support context, optionally collapsible
### Rationale
State, freshness, and trust legends remain semantically valuable, especially for onboarding or occasional operators, but they should no longer compete with the matrix for top-of-screen attention. Grouped, compact legend blocks are the narrowest way to preserve semantics while reducing dominance.
### Alternatives considered
- Remove legends entirely: rejected because trust and freshness semantics still need an on-page reference.
- Leave three separate full-width legend sections: rejected because they displace the primary working surface.
## Decision: Separate loading, auto-refresh, and last-updated cues
### Rationale
Spec 190 already exposed the risk of background polling reading like permanent blocking load. This follow-up should make three states explicit: active loading for user-triggered refresh, passive auto-refresh while queued or running compare work exists, and last-updated time for the currently rendered matrix.
### Alternatives considered
- Reuse one generic refresh chip for all states: rejected because operators cannot tell whether the page is blocked or simply polling.
- Hide refresh state entirely: rejected because operator trust depends on understanding when the matrix is current.
## Decision: Reuse the existing drilldown and visible-set semantics without change
### Rationale
This spec is a presentation refactor, not a navigation or authorization redesign. The existing tenant compare, finding, run-detail, and canonical-navigation context from Spec 190 remain correct and should carry forward unchanged.
### Alternatives considered
- Introduce a dense-mode-specific drilldown model: rejected because it would create new behavior where existing follow-up paths are already sufficient.
- Add aggregated hidden-tenant remainder summaries: rejected because visible-set-only semantics explicitly avoid hidden-tenant leakage.
## Decision: Validate primarily with focused page, builder, guard, and browser coverage
### Rationale
The highest-risk changes are mode resolution, dense-cell hierarchy, compact single-tenant rendering, filter apply behavior, and non-blocking refresh cues. These are best covered with focused feature tests plus one browser smoke path for the interactive Livewire surface.
### Alternatives considered
- Browser-test every combination exhaustively: rejected because most of the behavior is deterministic and cheaper to validate through feature tests.
- Limit validation to visual inspection: rejected because mode resolution and filter workflow are important enough to guard in CI.

View File

@ -0,0 +1,231 @@
# Feature Specification: Baseline Compare Matrix: High-Density Operator Mode
**Feature Branch**: `191-baseline-compare-operator-mode`
**Created**: 2026-04-11
**Status**: Approved
**Input**: User description: "Spec Candidate 190b — Baseline Compare Matrix: High-Density Operator Mode"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: The current baseline compare matrix is semantically strong but still too visually heavy for repeat operator use, especially when several visible tenants must be scanned quickly.
- **Today's failure**: Operators reach the right truth, but the page spends too much space on supporting context, repeated actions, and vertically expensive cells. Multi-tenant comparison is slower than it should be, and single-tenant viewing still feels like a stretched matrix instead of a compact operator surface.
- **User-visible improvement**: The same matrix route becomes faster to scan, calmer to use, and more obviously centered on drift detection. Multi-tenant work gets a true dense scan mode, while single-tenant work gets a compact compare list.
- **Smallest enterprise-capable version**: Rework the existing matrix route with adaptive presentation only: `auto` mode picks dense multi-tenant view for more than one visible tenant and compact single-tenant view for one visible tenant, while filters, legends, actions, and refresh feedback are compressed without changing compare logic.
- **Explicit non-goals**: No change to compare truth, no new finding semantics, no new persisted matrix artifact, no generalized table engine, no mobile-first redesign, no broader design-system rewrite.
- **Permanent complexity imported**: One page-level presentation-mode contract, denser cell-layout rules, compact control behavior, route/query persistence for local mode override, and focused regression coverage for the new operator surface behavior.
- **Why now**: Spec 190 established the truthful workspace compare surface. The next real bottleneck is not domain correctness but operator throughput and scan efficiency on the page that now exists.
- **Why not local**: Small CSS-only tweaks will not solve the actual product problem because the core issue is presentation mode, action hierarchy, and default information density rather than isolated spacing bugs.
- **Approval class**: Workflow Compression
- **Red flags triggered**: `New Meta-Infrastructure` risk if presentation-mode work grows into a reusable UI framework. Defense: this spec keeps all mode logic page-local to the existing baseline compare matrix and forbids a generalized density framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/baseline-profiles/{record}/compare-matrix` as the existing workspace matrix route that gains dense and compact operator modes
- `/admin/baseline-profiles/{record}` as the existing baseline profile detail that remains the canonical entry point into the matrix
- `/admin/t/{tenant}/baseline-compare` as the existing tenant drilldown destination
- `/admin/findings` and finding detail as the existing follow-up destinations
- Monitoring run-detail routes as existing compare-run drilldowns
- **Data Ownership**:
- Workspace-owned baseline profile, snapshot, and assignment truth remain unchanged.
- Tenant-owned compare runs and findings remain unchanged.
- Presentation mode, filter compaction, and dense cell rendering remain derived UI behavior only and introduce no new persisted truth.
- **RBAC**:
- Matrix access remains gated by workspace membership plus `WORKSPACE_BASELINES_VIEW`.
- `Compare assigned tenants` remains gated by `WORKSPACE_BASELINES_MANAGE`.
- Tenant and finding drilldowns continue to enforce their existing tenant-scope capabilities such as `TENANT_VIEW` and `TENANT_FINDINGS_VIEW`.
- Presentation-mode changes MUST NOT widen visibility, leak hidden tenants, or relax `404` vs `403` semantics already established in Spec 190.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Surface Type | 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Workspace baseline compare matrix | Workspace matrix / operator surface | Explicit subject, cell, and tenant drilldown controls | forbidden | Header controls, compact cell action slot, focused subject utilities | none | `/admin/baseline-profiles/{record}/compare-matrix` | same route with filter and presentation state | Active workspace, baseline profile, visible tenant count, active filter count, presentation mode, last updated | Baseline compare matrix | Drift hotspots, trust, freshness, and next follow-up path | dense-grid + compact-single-tenant exception |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Workspace baseline compare matrix | Workspace operator | Matrix / triage surface | Where is the meaningful drift across the visible tenant set, how trustworthy is it, and where should I go next? | Subject-by-tenant state, trust, freshness, severity or attention signal, visible-set filter scope, mode, last updated | Raw reason codes, run identifiers, detailed evidence gaps, low-level compare metadata | compare state, freshness, trust, severity/attention | `simulation only` for compare start; otherwise read-only | Compare assigned tenants, apply or reset filters, switch presentation mode, focus subject, drill into compare/finding/run | none |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace baseline compare matrix | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | `Compare assigned tenants` remains the sole primary header action; presentation mode, refresh status, and filter state stay in contextual support surfaces rather than the header | Explicit subject, cell, and tenant drilldown controls only; row click remains forbidden | none; follow-up links remain inside compact cell or compact-result affordances only | none | `Reset filters` becomes the single primary CTA when filters reduce the visible row set to zero; otherwise the surface keeps the existing compare-start guidance and no duplicate empty-state CTA | No separate detail header exists; the matrix route remains the canonical working surface | n/a | Existing compare-start run and audit semantics remain unchanged; no new audit event is introduced by presentation changes | Dense-grid and compact-single-tenant rendering are approved custom surface exceptions, but HDR-001 still applies: no pure-navigation header actions and only one primary visible header action |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning.
- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant use cases, so supporting context, cell chrome, and repeated actions stay too heavy for both.
- **Narrowest correct implementation**: Keep the same route, same truth sources, same drilldowns, and same compare semantics while adding one adaptive presentation contract and denser default rendering.
- **Ownership cost**: More page-view branching, additional view-state tests, and stricter UI regression coverage for density, action noise, and status visibility.
- **Alternative intentionally rejected**: A generalized dense-table framework or a second persisted reporting artifact was rejected because this need is local to the baseline compare matrix.
- **Release truth**: current-release operator workflow compression
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1)
As a workspace operator, I want the matrix to switch into a true high-density view when multiple visible tenants are in scope so I can read drift patterns quickly without losing the subject axis.
**Why this priority**: Multi-tenant scanning is the core operator value of the matrix. If this remains visually slow, the page does not earn its workspace-level role.
**Independent Test**: Open the matrix for a baseline profile with multiple visible tenants and verify that one subject row and one tenant column remain readable in a dense layout with a sticky subject column and compact cell states.
**Acceptance Scenarios**:
1. **Given** a baseline profile with more than one visible assigned tenant, **When** the operator opens the matrix route in auto mode, **Then** the page renders the dense multi-tenant mode with one subject row per baseline subject and one tenant column per visible tenant.
2. **Given** the operator scrolls horizontally in dense mode, **When** the matrix remains wider than the viewport, **Then** the first subject column stays visible and anchored for cross-tenant reading.
3. **Given** a dense-mode cell represents a visible tenant and subject, **When** the page renders it, **Then** the primary visible signal is the technical state plus condensed trust and freshness rather than a block of repeated links or prose.
---
### User Story 2 - Work a single visible tenant in compact mode (Priority: P2)
As a workspace operator, I want the matrix to stop pretending to be multi-tenant when only one visible tenant remains so the screen becomes shorter and calmer.
**Why this priority**: A single-tenant compare surface should not spend horizontal and vertical space simulating columns that do not exist.
**Independent Test**: Open the matrix for a baseline profile where only one tenant is visible and verify that the page uses a compact compare-list mode instead of the dense cross-tenant layout.
**Acceptance Scenarios**:
1. **Given** exactly one visible assigned tenant after RBAC scoping, **When** the operator opens the matrix in auto mode, **Then** the page renders compact single-tenant mode instead of dense mode.
2. **Given** more than one tenant is assigned to the baseline profile but RBAC scoping leaves only one tenant visible to the current actor, **When** the operator opens the matrix in auto mode, **Then** the page still resolves to compact mode and all counts and drilldowns remain visible-set-only.
3. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface.
---
### User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2)
As a workspace operator, I want supporting controls to stay available but compact so the matrix remains the primary working surface above the fold.
**Why this priority**: Filtering, legends, and refresh status are necessary, but they should support the matrix rather than compete with it.
**Independent Test**: Open the matrix, apply policy-type or state filters, and verify that active filter count, filter application, legend compaction, and refresh signals remain visible without dominating the page.
**Acceptance Scenarios**:
1. **Given** the operator changes multi-select filters, **When** those changes are staged, **Then** the page uses an explicit apply or reset pattern for heavy filter changes instead of re-rendering noisily on every click.
2. **Given** active compare runs or polling are present, **When** the matrix refreshes in the background, **Then** the operator sees a non-blocking update signal and a page-level freshness hint rather than a permanent loading impression.
3. **Given** the operator already understands the legends, **When** the page loads in daily-use mode, **Then** legends are grouped and visually compact, with deeper explanation still available on demand.
4. **Given** staged or applied filters reduce the visible subject set to zero, **When** the page renders the filtered result, **Then** it preserves the active presentation mode, shows a clear zero-results empty state, and offers `Reset filters` as the single primary CTA.
### Edge Cases
- If total assigned tenants are greater than one but only one tenant is visible to the current actor, auto mode MUST choose compact mode, not dense mode.
- If the operator manually overrides `auto` to `dense` or `compact`, the override MUST stay local to the matrix route and MUST NOT create a persisted user preference or domain artifact.
- If filters reduce the visible row set to zero, the page MUST preserve the active mode and still show a clear empty state.
- If compare runs are queued or running while the page is open, the refresh signal MUST remain distinct from a blocking loading state.
- If dense mode cannot fit all compact cell details legibly, secondary detail MUST move behind tooltip, popover, expand, or a deliberate drilldown instead of widening every cell again.
## Requirements *(mandatory)*
**Constitution alignment (required):** This follow-up spec changes only the operator presentation of the existing matrix surface. It introduces no new Microsoft Graph path, no new baseline or finding truth, and no new mutation beyond the already-existing compare-start behavior.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature must remain a local presentation refactor on top of Spec 190. It MUST NOT introduce a new persisted report, a new compare artifact, a new domain state family, or a reusable density framework.
**Constitution alignment (OPS-UX):** Any compare-start controls remain bound to the existing `baseline_compare` run semantics from Spec 190. This spec only changes the presentation around those controls and their feedback, not the run model.
**Constitution alignment (RBAC-UX):** All existing `404` versus `403` semantics, visible-set-only counts, drilldown authorization, and capability checks remain unchanged. Dense or compact mode MUST never reveal more tenant truth than the current actor can already see.
**Constitution alignment (BADGE-001):** Dense and compact mode MUST reuse centralized state, freshness, trust, and severity badge semantics. This spec MUST NOT create page-local status colors or a second status vocabulary.
**Constitution alignment (UI-FIL-001):** The matrix page should continue to use Filament-native sections, actions, and shared primitives. The dense matrix body and compact single-tenant layout may use custom Blade composition where Filament's one-axis primitives are insufficient, but the page MUST avoid inventing a local semantic component framework.
**Constitution alignment (UI-NAMING-001):** Operator-facing labels must stay aligned with the vocabulary established in Spec 190, including `Baseline compare matrix`, `Compare assigned tenants`, `Reference snapshot`, `Visible tenants`, and the existing compare-state labels.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-REVIEW-001):** The matrix remains a workspace operator surface with explicit inspect controls and forbidden row click. The primary working surface is the matrix body, while filters, legends, and status strips become supporting context.
### Functional Requirements
- **FR-191-001 Primary working surface**: On desktop operator viewports (`>= 1280px`), the initial render MUST show the first dense matrix row or first compact result without scrolling past expanded legends or a long filter stack. Reference context, filter summary, and legend summary MAY remain above the working surface, but detailed legend or helper text MUST stay collapsed or secondary by default.
- **FR-191-002 Auto presentation mode**: The page MUST support an `auto` presentation mode that chooses dense multi-tenant mode when more than one visible tenant is in scope and compact single-tenant mode when exactly one visible tenant is in scope.
- **FR-191-003 Manual override**: The page MUST allow a local manual override between `auto`, `dense`, and `compact` presentation without persisting that choice as domain truth or a stored user preference.
- **FR-191-004 Dense multi-tenant layout**: Dense mode MUST render one subject row and one visible-tenant column with a sticky first subject column.
- **FR-191-005 Dense cell contract**: Dense mode cells MUST default to compact state, trust, and freshness signals. Detailed reasons, long helper text, and multiple secondary links MUST NOT dominate the default cell chrome.
- **FR-191-006 Single-tenant compact layout**: Compact mode MUST render a shorter subject-result list optimized for one visible tenant instead of a pseudo-matrix with repeated tenant headers and oversized cells.
- **FR-191-007 Action calming**: Repeated follow-up actions such as tenant compare, finding, or run links MUST become visually secondary. The default focus in dense or compact mode MUST remain the compare state, not the link chrome.
- **FR-191-008 Filter density**: The page MUST show active filter count and active filter scope clearly while keeping the filter zone visually compact.
- **FR-191-009 Heavy-filter workflow**: Policy type and other heavy multi-select filters MUST use an explicit apply/reset interaction instead of forcing a full matrix recompute on every click.
- **FR-191-010 Policy type usability**: Policy type filtering MUST replace the long checkbox stack with a searchable multi-select or an equivalent compact selector that supports type-to-find behavior and stays one compact control when closed.
- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST default to one grouped support block with summary labels visible and detailed explanatory text hidden behind an explicit reveal so they do not displace the matrix in daily use.
- **FR-191-012 Honest status transitions**: The page MUST distinguish between active loading, background auto-refresh, and last-updated freshness so operators can tell whether the matrix is recalculating or simply polling for updates.
- **FR-191-013 Last updated visibility**: The page MUST show a page-level or matrix-level freshness hint indicating when the currently rendered matrix data was last refreshed.
- **FR-191-014 Visible-set truth preserved**: Dense and compact mode MUST preserve the visible-set-only semantics already defined in Spec 190 for all counts, subject breadth, and drilldowns.
- **FR-191-015 Drilldown continuity preserved**: Switching presentation mode MUST NOT break subject focus, tenant drilldowns, finding drilldowns, or return-path continuity already established on the matrix route.
- **FR-191-016 No compare-logic changes**: This spec MUST NOT change how drift, trust, freshness, severity, or evidence gaps are calculated.
- **FR-191-017 No new persistence**: This spec MUST NOT introduce a new matrix snapshot, portfolio report, stored view preference, or any other new persisted artifact.
- **FR-191-018 Automated regression coverage**: Automated coverage MUST prove mode selection, sticky dense layout, compact single-tenant layout, filter apply/reset behavior, legend compression, non-blocking refresh state, and preservation of existing drilldowns and RBAC semantics.
## Measurable Acceptance Thresholds
- Dense auto mode is accepted only when a multi-tenant matrix render shows the first sticky subject row without scrolling past expanded legends or a long filter stack on a desktop operator viewport.
- Compact auto mode is accepted only when the RBAC-visible single-tenant edge case renders the compact result list instead of the dense grid while preserving visible-set-only counts and drilldown continuity.
- Staged filtering is accepted only when draft multi-select or sort changes do not redraw the matrix until the operator explicitly applies or resets them, and the active filter summary continues to describe the applied route state.
- Support-surface compression is accepted only when legends stay grouped behind an explicit reveal, passive auto-refresh remains visibly distinct from deliberate refresh, and last-updated context stays visible on the page.
## Non-Goals
- No change to baseline compare logic or evidence resolution
- No new matrix export or stored report artifact
- No new generic dense-table framework for other pages
- No new finding workflow or remediation workflow
- No mobile-first redesign of the matrix surface
- No cross-workspace or tenant-vs-tenant compare feature
## Assumptions
- Spec 190 remains the canonical domain-truth foundation for the matrix.
- Existing builder outputs can be extended or re-rendered without introducing new persistence.
- Existing drilldown URLs and canonical navigation context can carry any local presentation override that must survive navigation.
- Existing badge semantics already cover the status information needed for denser rendering.
## Dependencies
- Spec 190 baseline compare matrix route and builder
- Existing matrix page and view
- Existing badge semantics for state, freshness, trust, and severity
- Existing tenant compare, finding, and run-detail destinations
## Risks
- Dense mode could drift into a local mini-framework if rendering rules become over-generalized.
- Compacting actions too aggressively could hide next steps instead of calming them.
- Apply/reset filtering could feel slower if the staged-filter state is not clearly signaled.
- Manual mode override could create confusion if `auto` behavior and override state are not explicit.
## Review Questions
- Does the page now clearly separate supporting context from the primary working surface?
- Is dense mode truly optimized for cross-tenant scanning rather than just a tighter version of the old layout?
- Is single-tenant mode clearly calmer and shorter than the current matrix?
- Are repeated actions secondary without becoming hard to discover?
- Are filter count, legend compression, and last-updated feedback visible without dominating the page?
- Does the spec stay local to the matrix surface and avoid importing a reusable UI framework?
## Definition of Done
This feature is complete when:
- the existing matrix route supports `auto`, `dense`, and `compact` presentation behavior,
- multi-tenant auto mode renders a clearly denser matrix with a sticky subject column,
- the RBAC-scoped case where more than one tenant is assigned but only one tenant is visible resolves to compact mode while preserving visible-set-only counts and drilldowns,
- single-tenant auto mode renders a compact compare-list presentation instead of the current matrix-heavy layout,
- supporting context is visibly lighter than the matrix body,
- repeated per-cell or per-row actions no longer dominate the reading flow,
- active filters are counted and heavy filters use an explicit apply/reset pattern,
- zero-result filtered states preserve the active mode and offer `Reset filters` as the single primary CTA,
- legends remain available but are grouped and visually compressed,
- page-level refresh and last-updated signals are honest and non-blocking,
- no compare logic, trust logic, freshness logic, or RBAC semantics have changed,
- and focused feature plus browser coverage proves the new operator-density behavior.

View File

@ -0,0 +1,180 @@
# Tasks: Baseline Compare Matrix: High-Density Operator Mode
**Input**: Design documents from `/specs/191-baseline-compare-operator-mode/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/baseline-compare-operator-mode.logical.openapi.yaml`
**Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route.
**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, run-summary contract, or notification channel should be introduced.
**RBAC**: Existing workspace and tenant visibility rules from Spec 190 remain authoritative. Tasks must preserve visible-set-only aggregation and existing `404` vs `403` behavior.
**Operator Surfaces**: The affected operator surface is the existing workspace baseline compare matrix route, with additive presentation changes only.
**Filament UI Action Surfaces**: The matrix page keeps explicit drilldown controls and forbidden row click. No destructive action is added.
**Badges**: Dense and compact rendering must continue to use centralized matrix state, trust, freshness, and severity semantics.
**Organization**: Tasks are grouped by user story so each operator-density improvement can be implemented and verified independently.
## Phase 1: Setup (Spec and Acceptance Seams)
**Purpose**: Lock the implementation contract and acceptance seams before page behavior changes.
- [X] T001 Finalize the UI Action Matrix, operator-surface assumptions, and measurable acceptance thresholds in `specs/191-baseline-compare-operator-mode/spec.md`
- [X] T002 [P] Reconcile the staged filter and presentation-mode interaction contract in `specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml`
- [X] T003 [P] Add acceptance scaffolds for multi-tenant, single-tenant, and staged-filter scenarios in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- [X] T004 [P] Extend browser and action-surface guard seams in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
**Checkpoint**: The spec contract and test seams are ready for implementation work.
---
## Phase 2: Foundational (Blocking Presentation Contract)
**Purpose**: Establish page-level presentation state and derived read models before reshaping dense and compact layouts.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T005 Add requested, resolved, and manual presentation-mode query handling plus staged filter state as request-scoped-only route state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T006 [P] Extend matrix bundle outputs for dense rows, compact results, support-surface state, and last-updated metadata in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T007 [P] Add foundational builder coverage for requested or resolved mode, filter metadata, support-surface state, and unchanged compare state, trust, freshness, and severity outputs in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- [X] T008 [P] Add foundational page coverage for mode resolution, route-state persistence, and derived-only non-persistence guarantees in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
**Checkpoint**: The page can resolve `auto`, `dense`, and `compact` mode and expose all derived state needed by the UI.
---
## Phase 3: User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1) 🎯 MVP
**Goal**: Make multi-tenant reading materially denser and faster without changing compare truth.
**Independent Test**: Open the matrix with multiple visible tenants and verify dense mode, sticky subject behavior, and state-first cells.
### Tests for User Story 1
- [X] T009 [P] [US1] Add dense-mode assertions for auto resolution, sticky subject behavior, and compact cell hierarchy in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T010 [P] [US1] Extend browser smoke coverage for dense-mode scanning and dense-mode drilldowns in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T012 [US1] Surface condensed dense-cell state, trust, freshness, and attention summaries in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T013 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T014 [US1] Preserve focused-subject and visible-set drilldown continuity for dense mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T015 [US1] Run focused dense-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
**Checkpoint**: Multi-tenant scanning is visibly denser and the matrix body reads as the primary working surface.
---
## Phase 4: User Story 2 - Work a single visible tenant in compact mode (Priority: P2)
**Goal**: Replace pseudo-matrix rendering with a compact comparison surface when only one visible tenant remains.
**Independent Test**: Open the matrix with one visible tenant and verify compact mode in auto state plus drilldown continuity.
### Tests for User Story 2
- [X] T016 [P] [US2] Add compact single-tenant page assertions for auto-to-compact resolution, including the assigned-greater-than-visible RBAC edge case, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T017 [P] [US2] Add compact single-tenant builder assertions for visible-set-only compact resolution and unchanged compare semantics in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
### Implementation for User Story 2
- [X] T018 [US2] Emit compact single-tenant result entries, compact drilldown metadata, and visible-set-only compact resolution when assigned tenants exceed visible tenants in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T019 [US2] Render the compact single-tenant compare list and reduced metadata shell in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T020 [US2] Preserve manual override, subject focus, and drilldown continuity for compact mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T021 [US2] Run focused compact-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
**Checkpoint**: One-tenant viewing is materially shorter and calmer than the current matrix surface.
---
## Phase 5: User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2)
**Goal**: Compress supporting context so it stays useful without pushing the matrix down or increasing visual noise.
**Independent Test**: Apply filters, inspect legends, and observe background refresh behavior without losing scanability.
### Tests for User Story 3
- [X] T022 [P] [US3] Add staged-filter, legend-compaction, refresh-cue, and zero-result empty-state assertions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T023 [P] [US3] Add browser smoke coverage for apply/reset filters, passive auto-refresh cues, and filtered zero-result empty states in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
### Implementation for User Story 3
- [X] T024 [US3] Implement staged heavy-filter draft, apply, reset, and zero-result empty-state behavior in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T025 [US3] Replace the long policy-type control with a searchable compact selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T026 [US3] Render applied-versus-draft filter summaries, one grouped collapsed legend block, and compressed support context in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T027 [US3] Render honest manual-refresh, passive polling, and last-updated cues in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T028 [US3] Keep calmer actions and forbidden row-click behavior enforced in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- [X] T029 [US3] Run focused support-surface verification, including zero-result empty-state behavior, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
**Checkpoint**: Filters, legends, and status surfaces support the operator without visually competing with the matrix.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finalize copy, formatting, and the focused verification pack.
- [X] T030 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T031 [P] Verify shared badge semantics remain centralized in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T032 [P] Run formatting for changed implementation files in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T033 Run the focused verification pack and confirm no compare-truth or persistence regressions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies. Start immediately.
- **Foundational (Phase 2)**: Depends on Phase 1. Blocks all user-story implementation.
- **User Story 1 (Phase 3)**: Depends on Phase 2. This is the MVP slice.
- **User Story 2 (Phase 4)**: Depends on Phase 2. Can proceed after the shared presentation contract is stable.
- **User Story 3 (Phase 5)**: Depends on Phase 2. Should land after the dense and compact layout branches exist.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1**: Independent after Phase 2 and should be delivered first.
- **US2**: Independent after Phase 2, but it reuses the shared presentation contract from US1-era foundational work.
- **US3**: Independent after Phase 2, but it should align with the final dense and compact layout structure.
### Within Each User Story
- Tests for that story should be written and made to fail before implementation.
- Builder and page state updates should land before Blade branching that depends on them.
- Each story must remain independently testable when finished.
## Parallel Execution Examples
### User Story 1
- Run `T009` and `T010` in parallel because they touch separate test files.
- After `T011` lands, `T012` can proceed while `T014` is prepared if the route-state contract is already stable.
### User Story 2
- Run `T016` and `T017` in parallel because they cover separate test layers.
- `T018` should land before `T019` because the compact Blade path depends on compact result entries.
### User Story 3
- Run `T022` and `T023` in parallel because they touch separate test files.
- `T024` and `T025` can be split between staged filter flow and selector compaction if coordinated on `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`.
## Implementation Strategy
### MVP First
1. Finish Setup and Foundational work.
2. Deliver US1 dense multi-tenant mode as the MVP operator gain.
3. Verify US1 independently before moving on.
### Incremental Delivery
1. Add US2 compact single-tenant mode on top of the shared presentation contract.
2. Add US3 filter, legend, and refresh-surface compression once both layout branches are stable.
3. Finish with copy review, formatting, and the focused verification pack.
### Validation Rule
1. Do not mark a story complete until its focused verification task passes.
2. Keep the existing Spec 190 truth, RBAC semantics, and drilldown continuity intact while implementing each story.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Record Page Header Discipline & Contextual Navigation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-11
**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 completed spec.
- No clarification markers remain.
- The spec keeps the cleanup bounded to classic record/detail/edit surfaces and documents the tenant admin resource view as the only explicit special-type exception.

View File

@ -0,0 +1,252 @@
openapi: 3.1.0
info:
title: Record Page Header Discipline Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for Spec 192 record-page header discipline
description: |
This contract is an internal planning artifact for Spec 192. The affected
surfaces continue to render HTML through Filament and Livewire. The schemas
below define the bounded render contract and regression expectations for
standard record/detail/edit headers, grouped secondary actions, contextual
navigation outside the header, and the explicit workflow-heavy exception.
servers:
- url: /internal
x-record-header-discipline-consumers:
- surface: standard-record-pages
sourceFiles:
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
- apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php
- apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
- apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php
mustRender:
- at_most_one_primary_header_action
- grouped_secondary_actions
- contextual_navigation_or_related_context_outside_header
- separated_danger_actions_when_present
mustNotRender:
- flat_navigation_mutation_strip
- multiple_competing_primary_actions
- empty_action_group_placeholders
- surface: workflow-heavy-special-type
sourceFiles:
- apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php
mustRender:
- explicit_exception_reason
- grouped_and_ordered_actions
- optional_single_primary_only_when_dominant
mustNotRender:
- silent_exception
- flat_multi_button_primary_strip
- surface: regression-guards
sourceFiles:
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
- apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
- apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php
paths:
/internal/action-surfaces/record-pages/{surface}:
get:
summary: Return the logical header-discipline contract for an in-scope record page
operationId: getRecordPageHeaderDisciplineContract
parameters:
- name: surface
in: path
required: true
schema:
$ref: '#/components/schemas/SurfaceKey'
responses:
'200':
description: Logical render contract and regression expectations for the requested surface
content:
application/vnd.tenantpilot.record-header-discipline+json:
schema:
$ref: '#/components/schemas/RecordHeaderSurfaceContract'
'404':
description: Requested surface is not in the Spec 192 inventory
components:
schemas:
SurfaceKey:
type: string
enum:
- baseline_profile_view
- evidence_snapshot_view
- finding_exception_view
- tenant_review_view
- tenant_edit
- tenant_view
- provider_connection_view
- finding_view
- review_pack_view
- alert_destination_view
- policy_version_view
- workspace_view
- baseline_snapshot_view
- backup_set_view
SurfaceClassification:
type: string
enum:
- remediation_required
- minor_alignment_only
- compliant_reference
- workflow_heavy_special_type
ActionKind:
type: string
enum:
- navigation
- mutation
- external_link
- lifecycle
- danger
Placement:
type: string
enum:
- primary_visible
- secondary_grouped
- contextual
- danger_grouped
OperationScope:
type: string
enum:
- TenantPilot only
- Microsoft tenant
- simulation only
- read-only
HeaderActionDescriptor:
type: object
additionalProperties: false
required:
- actionKey
- label
- actionKind
- placement
- requiresConfirmation
- usesUiEnforcement
- operationScope
properties:
actionKey:
type: string
label:
type: string
actionKind:
$ref: '#/components/schemas/ActionKind'
placement:
$ref: '#/components/schemas/Placement'
requiresConfirmation:
type: boolean
usesUiEnforcement:
type: boolean
capabilityKey:
type:
- string
- 'null'
writesAuditLog:
type: boolean
operationScope:
$ref: '#/components/schemas/OperationScope'
ContextualNavigationEntry:
type: object
additionalProperties: false
required:
- label
- sourceSection
- isAvailable
properties:
label:
type: string
targetUrl:
type:
- string
- 'null'
sourceSection:
type: string
enum:
- summary
- related_context
- field_context
- status_context
isAvailable:
type: boolean
SecondaryActionGroup:
type: object
additionalProperties: false
required:
- label
- orderedBuckets
- actions
properties:
label:
type: string
orderedBuckets:
type: array
items:
type: string
actions:
type: array
items:
$ref: '#/components/schemas/HeaderActionDescriptor'
HeaderRegressionExpectation:
type: object
additionalProperties: false
required:
- maxVisiblePrimaryActions
- requiresGroupedSecondaryActions
- allowsPrimaryNavigation
- requiresExplicitExceptionReason
properties:
maxVisiblePrimaryActions:
type: integer
minimum: 0
maximum: 1
requiresGroupedSecondaryActions:
type: boolean
allowsPrimaryNavigation:
type: boolean
requiresDangerSeparation:
type: boolean
requiresExplicitExceptionReason:
type: boolean
browserSmokeRequired:
type: boolean
RecordHeaderSurfaceContract:
type: object
additionalProperties: false
required:
- surfaceKey
- classification
- canonicalNoun
- primaryQuestion
- actions
- contextualNavigation
- regressionExpectation
properties:
surfaceKey:
$ref: '#/components/schemas/SurfaceKey'
classification:
$ref: '#/components/schemas/SurfaceClassification'
canonicalNoun:
type: string
primaryQuestion:
type: string
primaryAction:
anyOf:
- $ref: '#/components/schemas/HeaderActionDescriptor'
- type: 'null'
secondaryActionGroup:
anyOf:
- $ref: '#/components/schemas/SecondaryActionGroup'
- type: 'null'
dangerActions:
type: array
items:
$ref: '#/components/schemas/HeaderActionDescriptor'
contextualNavigation:
type: array
items:
$ref: '#/components/schemas/ContextualNavigationEntry'
explicitExceptionReason:
type:
- string
- 'null'
regressionExpectation:
$ref: '#/components/schemas/HeaderRegressionExpectation'

View File

@ -0,0 +1,155 @@
# Data Model: Record Page Header Discipline & Contextual Navigation
## Overview
This feature introduces no new persisted entity, table, enum, or long-lived artifact. It reuses existing Filament pages, existing action definitions, and existing authorization helpers, while adding a derived planning model for how record-page headers are classified, rendered, and regression-tested.
## Existing Source Truths Reused Without Change
The following truths remain authoritative and are not redefined by this feature:
- existing resource and page routes
- existing model ownership and scope semantics
- existing capability checks and `UiEnforcement` behavior
- existing confirmation, audit, and `OperationRun` behavior for underlying actions
- existing related-navigation truth from `RelatedNavigationResolver` and related helper methods
This feature changes action hierarchy and placement only.
## New Derived Planning Models
### HeaderSurfaceInventoryEntry
**Type**: spec and guard inventory entry
**Source**: explicit Spec 192 classification matrix + action-surface regression guard
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Stable identifier such as `baseline_profile_view` or `tenant_edit` |
| `pageClass` | string | Concrete Filament page class under review |
| `panelScope` | string | `admin`, `tenant`, or explicit special context |
| `ownerScope` | string | `workspace-owned` or `tenant-owned` |
| `classification` | string | `remediation_required`, `minor_alignment_only`, `compliant_reference`, or `workflow_heavy_special_type` |
| `canonicalNoun` | string | Stable operator-facing object noun |
| `routeKind` | string | `view` or `edit` |
| `requiresHeaderRemediation` | boolean | Whether the page must change under Spec 192 |
| `exceptionReason` | string or null | Required when classification is special-type |
### RecordHeaderLayoutState
**Type**: derived page render contract
**Source**: existing page action methods + page state + explicit Spec 192 rules
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Links the render state back to the inventory entry |
| `classification` | string | Same classification used by the inventory |
| `primaryActionKey` | string or null | The one visible primary action, if any |
| `primaryActionLabel` | string or null | Operator-facing label for the visible primary action |
| `primaryActionReason` | string | Why this action is primary for the current page state |
| `secondaryGroupLabel` | string or null | Usually `More`, `Actions`, or equivalent grouped-secondary label |
| `hasContextualNavigation` | boolean | Whether pure navigation moved to contextual placement outside the header |
| `hasSeparatedDangerActions` | boolean | Whether dangerous actions are structurally separated |
| `allowsNoPrimaryAction` | boolean | True for compliant reference pages or special types without one dominant next step |
### HeaderActionDescriptor
**Type**: derived action classification entry
**Source**: existing Filament action definitions on the target page
| Field | Type | Notes |
|------|------|-------|
| `actionKey` | string | Action name such as `capture`, `refresh_review`, or `archive` |
| `label` | string | Visible operator-facing label |
| `actionKind` | string | `navigation`, `mutation`, `external_link`, `lifecycle`, or `danger` |
| `placement` | string | `primary_visible`, `secondary_grouped`, `contextual`, or `danger_grouped` |
| `requiresConfirmation` | boolean | Mirrors existing destructive or governance friction |
| `usesUiEnforcement` | boolean | Whether the action is wrapped with a central enforcement helper |
| `capabilityKey` | string or null | Canonical capability requirement when applicable |
| `writesAuditLog` | boolean | Whether the underlying mutation writes audit truth |
| `operationScope` | string | `TenantPilot only`, `Microsoft tenant`, `simulation only`, or `read-only` |
### ContextualNavigationContract
**Type**: derived related-navigation placement entry
**Source**: existing related-navigation resolver output or page-local related links
| Field | Type | Notes |
|------|------|-------|
| `entryKey` | string | Stable identifier for a related destination |
| `label` | string | Operator-facing related-link label |
| `targetUrl` | string or null | Existing target destination |
| `sourceSection` | string | `summary`, `related_context`, `field_context`, or `status_context` |
| `isAvailable` | boolean | Mirrors existing helper availability logic |
| `leaksScopeIfMisplaced` | boolean | True when placing this in the wrong layer could imply broader access |
### WorkflowHeavyHeaderGroup
**Type**: derived grouped-action contract for special-type pages
**Source**: explicit exception handling for `ViewTenant`
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | Expected to map to the special-type surface |
| `groupLabel` | string | Visible grouped-action label, usually `Actions` |
| `orderedBuckets` | array<string> | Ordered buckets such as `external_links`, `verification`, `setup`, and `lifecycle`; pure navigation remains contextual outside the header |
| `visiblePrimaryActionKey` | string or null | Optional visible primary action when a dominant next step exists |
| `requiresExplicitException` | boolean | Always true for workflow-heavy special types |
### HeaderRegressionExpectation
**Type**: guard and test expectation entry
**Source**: Spec 192 regression-protection requirements
| Field | Type | Notes |
|------|------|-------|
| `surfaceKey` | string | The page under regression protection |
| `maxVisiblePrimaryActions` | integer | `1` for standard pages, `0..1` for special types, `0..1` for references as documented |
| `requiresGroupedSecondaryActions` | boolean | Whether secondaries must be grouped |
| `allowsPrimaryNavigation` | boolean | Usually false for remediated standard pages |
| `requiresDangerSeparation` | boolean | True when the page contains destructive or governance-sensitive actions |
| `requiresExplicitExceptionReason` | boolean | True for special-type pages |
| `browserSmokeRequired` | boolean | True for remediation-required pages, the explicit special-type exception, and the compliant reference baseline set |
## Resolution Rules
### Standard-page rules
1. A remediation-required standard record/detail/edit page resolves to at most one `primary_visible` action.
2. Pure navigation actions resolve to `contextual`, not to `primary_visible` or header-grouped placement.
3. Rare or administrative mutations resolve to `secondary_grouped`.
4. Destructive or governance-sensitive actions resolve to `danger_grouped` and keep confirmation.
### State-sensitive primary-action rules
- `baseline_profile_view` resolves `capture` as primary when no consumable snapshot exists.
- `baseline_profile_view` resolves `compareNow` as primary when a consumable snapshot exists.
- `tenant_review_view` resolves one of `refresh_review`, `publish_review`, or `export_executive_pack` as primary based on lifecycle state.
- `finding_exception_view` may resolve `renew_exception` as primary only when renewal is valid and visible.
- `tenant_edit` does not introduce a second page-header primary because save/cancel remain the true edit-surface primary affordance.
### Special-type rules
1. `tenant_view` may expose zero visible primaries when no single dominant next step exists.
2. If `tenant_view` exposes one visible primary, all remaining actions still stay grouped and internally ordered.
3. `tenant_view` must always carry an explicit exception reason in the inventory and regression expectations.
### Reference-page rules
1. A compliant reference page may keep a single contextual related-record link or single primary safe action without further restructuring.
2. Reference pages must not be rebuilt only to mimic the remediated pages.
## Relationships
- One `HeaderSurfaceInventoryEntry` maps to one `RecordHeaderLayoutState`.
- One `RecordHeaderLayoutState` contains many `HeaderActionDescriptor` entries.
- A page may contain zero or many `ContextualNavigationContract` entries.
- Only special-type pages use a `WorkflowHeavyHeaderGroup`.
- Every in-scope page must map to one `HeaderRegressionExpectation`.
## Safety Rules
- No derived header model may widen tenant or workspace visibility beyond existing route and helper semantics.
- No action may lose `UiEnforcement`, confirmation, audit, or `OperationRun` behavior when it changes placement.
- No grouped secondary structure may become an undocumented exception or empty placeholder.
- No regression expectation may silently exempt a page; every exception must be explicit and justified.

View File

@ -0,0 +1,325 @@
# Implementation Plan: Record Page Header Discipline & Contextual Navigation
**Branch**: `192-record-header-discipline` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/spec.md`
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 resource pages, existing related-navigation helpers, and the existing action-surface guard infrastructure. It explicitly avoids introducing a new header-action framework.
## Summary
Codify one bounded header-discipline contract for classic record/detail/edit pages in the admin panel. Reuse existing Filament header actions, `ActionGroup`, `UiEnforcement`, and `RelatedNavigationResolver` patterns to inventory all in-scope surfaces, remediate the five standard pages that currently have noisy headers, preserve already-clean reference pages, explicitly document `ViewTenant` as a workflow-heavy special type, and extend the existing action-surface and browser regression layers so new header sprawl does not re-enter the repo.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
**Storage**: PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned
**Testing**: Pest feature tests, existing guard tests, and browser smoke tests run through Laravel Sail
**Target Platform**: Laravel monolith web application under `apps/platform`, with workspace/admin routes under `/admin`, tenant-context routes under `/admin/t/{tenant}/...`, and no panel expansion planned
**Project Type**: web application
**Performance Goals**: Preserve the 5-second scan rule on record pages, keep all affected pages DB-only at render time, avoid new polling or asset work, and prevent header cleanup from adding extra query churn or remote calls
**Constraints**: No new action framework, no new persistence, no route or panel changes, no authorization-plane changes, no new status language, no silent special-type exemptions, and no expansion of Spec 133 body-composition requirements beyond current scope
**Scale/Scope**: 14 in-scope record/detail/edit surfaces, 5 remediation-required standard pages, 1 explicit workflow-heavy special-type exception, 2 minor-alignment audits, 6 compliant/no-op reference pages, and focused guard plus feature plus browser regression 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 does not alter inventory or snapshot truth; it only reorganizes page headers. |
| Read/write separation | PASS | PASS | Existing mutations keep their current confirmation, audit, and test behavior. No new writes are introduced. |
| Graph contract path | N/A | N/A | No new Microsoft Graph call path or contract-registry change is planned. |
| Deterministic capabilities | PASS | PASS | Capability checks remain in canonical registries and `UiEnforcement`; regrouping actions does not change entitlement logic. |
| Workspace + tenant isolation | PASS | PASS | Existing route scopes and related-navigation availability rules remain authoritative. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, in-scope capability denial remains `403`, and server-side authorization stays unchanged. |
| Run observability / Ops-UX | PASS | PASS | Underlying long-running actions such as capture, compare, refresh, and verification keep their existing `OperationRun` semantics. |
| Data minimization | PASS | PASS | No new persistence, caches, or header-state artifacts are introduced. |
| Proportionality / anti-bloat | PASS | PASS | The work stays inside existing pages and guard infrastructure instead of creating a new framework. |
| UI semantics / few layers | PASS | PASS | The feature relies on direct action placement and classification rather than a new presenter or semantic layer. |
| Filament-native UI | PASS | PASS | Native Filament header actions and `ActionGroup` remain the implementation path. |
| Surface taxonomy / HDR-001 | PASS | PASS | The plan explicitly classifies every in-scope page and documents the one workflow-heavy special type. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched pages remain inside the existing Filament v5 + Livewire v4 stack. |
| Provider registration location | PASS | PASS | No provider change is needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced; touched resources already have View/Edit pages where needed. |
| Destructive action safety | PASS | PASS | Existing destructive or governance-changing actions keep `->requiresConfirmation()` and stay authorization-gated. |
| Asset strategy | PASS | PASS | No new assets or lazy-load registrations are needed; existing deploy handling of `cd apps/platform && php artisan filament:assets` remains unchanged. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The plan remains on Filament v5 + Livewire v4 and introduces no legacy or mixed-version API usage.
- **Provider registration location**: No panel or provider changes are required; Laravel 11+ panel providers remain registered in `bootstrap/providers.php`.
- **Global search**: The feature does not add a new globally searchable resource. Touched resources continue to satisfy the Filament hard rule because they already have View and/or Edit pages; search behavior is otherwise unchanged.
- **Destructive actions**: `Expire snapshot`, `Revoke exception`, `Archive review`, tenant lifecycle actions, backup lifecycle actions, and provider credential danger actions remain routed through `Action::make(...)->action(...)` with `->requiresConfirmation()` and existing authorization.
- **Asset strategy**: No new global or on-demand asset registration is planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
- **Testing plan**: Extend the existing action-surface guard layer, add focused Livewire/Pest tests for the remediated pages and the explicit special type, and add a browser smoke suite that proves visible hierarchy on remediated headers.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/research.md`.
Key decisions:
- Reuse existing page-local action builders, `UiEnforcement`, `ActionGroup`, and `RelatedNavigationResolver` instead of introducing a header-action framework.
- Move pure navigation to contextual placement outside the header instead of equal-weight header placement.
- Treat `ViewTenant` as an explicit workflow-heavy special-type exception rather than a standard record page.
- Preserve already-clean pages as reference patterns instead of cosmetically normalizing them.
- Build regression protection on top of the existing `ActionSurfaceValidator`, focused page tests, and browser smoke patterns.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/`:
- `research.md`: decisions and rejected alternatives for bounded header discipline
- `data-model.md`: derived header-surface inventory, render contract, and regression expectation models
- `contracts/record-header-discipline.logical.openapi.yaml`: internal logical contract for standard-page headers, the special-type exception, and regression expectations
- `quickstart.md`: implementation and verification sequence for the feature
Design highlights:
- Keep all classification and render rules derived, not persisted.
- Represent each in-scope surface through one explicit inventory entry and one explicit regression expectation.
- Keep state-sensitive primary-action decisions local to the existing pages rather than moving them into a shared runtime resolver.
- Treat `ViewTenant` as the only explicit special type and require an exception reason in the regression layer.
- Extend the existing guard system rather than creating a new validation framework.
## 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 to keep the planning workflow complete.
## Project Structure
### Documentation (this feature)
```text
specs/192-record-header-discipline/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── contracts/
│ └── record-header-discipline.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Resources/
│ │ ├── BaselineProfileResource.php # MODIFY
│ │ ├── BaselineProfileResource/
│ │ │ └── Pages/
│ │ │ └── ViewBaselineProfile.php # MODIFY
│ │ ├── EvidenceSnapshotResource.php # MODIFY
│ │ ├── EvidenceSnapshotResource/
│ │ │ └── Pages/
│ │ │ └── ViewEvidenceSnapshot.php # MODIFY
│ │ ├── FindingExceptionResource.php # MODIFY
│ │ ├── FindingExceptionResource/
│ │ │ └── Pages/
│ │ │ └── ViewFindingException.php # MODIFY
│ │ ├── TenantReviewResource.php # MODIFY
│ │ ├── TenantReviewResource/
│ │ │ └── Pages/
│ │ │ └── ViewTenantReview.php # MODIFY
│ │ ├── TenantResource.php # MODIFY
│ │ ├── TenantResource/
│ │ │ └── Pages/
│ │ │ ├── EditTenant.php # MODIFY
│ │ │ └── ViewTenant.php # MODIFY (special type ordering only)
│ │ ├── ProviderConnectionResource/
│ │ │ └── Pages/
│ │ │ └── ViewProviderConnection.php # AUDIT / possible minor alignment
│ │ ├── FindingResource/
│ │ │ └── Pages/
│ │ │ └── ViewFinding.php # AUDIT / possible minor alignment
│ │ ├── BackupSetResource/
│ │ │ └── Pages/
│ │ │ └── ViewBackupSet.php # REFERENCE only
│ │ ├── BaselineSnapshotResource/
│ │ │ └── Pages/
│ │ │ └── ViewBaselineSnapshot.php # REFERENCE only
│ │ ├── ReviewPackResource/
│ │ │ └── Pages/
│ │ │ └── ViewReviewPack.php # REFERENCE only
│ │ ├── AlertDestinationResource/
│ │ │ └── Pages/
│ │ │ └── ViewAlertDestination.php # REFERENCE only
│ │ ├── PolicyVersionResource/
│ │ │ └── Pages/
│ │ │ └── ViewPolicyVersion.php # REFERENCE only
│ │ └── Workspaces/
│ │ └── Pages/
│ │ └── ViewWorkspace.php # REFERENCE only
│ ├── Support/
│ │ ├── Navigation/
│ │ │ └── RelatedNavigationResolver.php # REUSE
│ │ ├── Rbac/
│ │ │ └── UiEnforcement.php # REUSE
│ │ └── Ui/
│ │ └── ActionSurface/
│ │ ├── ActionSurfaceValidator.php # MODIFY
│ │ ├── ActionSurfaceExemptions.php # MODIFY
│ │ ├── ActionSurfaceProfileDefinition.php # POSSIBLE MODIFY
│ │ └── Enums/
│ │ └── ActionSurfaceProfile.php # POSSIBLE MODIFY
├── resources/
│ └── views/
│ └── filament/
│ └── infolists/
│ └── entries/
│ └── tenant-review-summary.blade.php # MODIFY
└── tests/
├── Feature/
│ ├── Guards/
│ │ ├── ActionSurfaceContractTest.php # MODIFY
│ │ ├── ActionSurfaceValidatorTest.php # MODIFY
│ │ └── Spec192RecordPageHeaderDisciplineGuardTest.php # NEW
│ └── Filament/
│ ├── BaselineProfileCaptureStartSurfaceTest.php # MODIFY or REUSE
│ ├── BaselineProfileCompareStartSurfaceTest.php # MODIFY or REUSE
│ ├── TenantViewHeaderUiEnforcementTest.php # MODIFY or REUSE
│ ├── FindingExceptionHeaderDisciplineTest.php # NEW
│ ├── TenantReviewHeaderDisciplineTest.php # NEW
│ └── EditTenantHeaderDisciplineTest.php # NEW
└── Browser/
├── Spec174EvidenceFreshnessPublicationTrustSmokeTest.php # REUSE for patterns
├── Spec190BaselineCompareMatrixSmokeTest.php # REUSE for patterns
└── Spec192RecordPageHeaderDisciplineSmokeTest.php # NEW
```
Additional reused test files referenced in `tasks.md`, such as `EvidenceSnapshotResourceTest.php`, `TenantReviewUiContractTest.php`, and RBAC regression suites, remain in scope even when they are not repeated in the summary tree above.
**Structure Decision**: Keep the work entirely inside the existing Laravel/Filament monolith under `apps/platform`. Modify only the affected resource classes, page classes, the tenant-review contextual Blade partial, the existing action-surface validation layer, and focused tests. Do not create a new support framework beyond the minimum needed to extend the existing guard patterns.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Cross-page header taxonomy and explicit exception catalog (BLOAT-001 trigger) | The feature must distinguish standard record pages, reference pages, minor-alignment pages, and the single workflow-heavy exception in a way CI can validate. | Pure page-local cleanup would reduce immediate noise but would not prevent future drift or document why `ViewTenant` is intentionally different. |
## Proportionality Review
- **Current operator problem**: Several classic record/detail/edit pages still present navigation, routine mutations, and danger as flat peers, slowing interpretation and weakening action hierarchy.
- **Existing structure is insufficient because**: The constitution now carries HDR-001, but the repo lacks a concrete inventory, a bounded classification model, and a regression hook that can distinguish standard pages from an allowed special type.
- **Narrowest correct implementation**: Keep all changes inside existing page classes and the existing action-surface validation layer, classify only the explicitly named pages, remediate only the pages that need it, and document exactly one special-type exception.
- **Ownership cost created**: A small amount of guard configuration, a few focused page tests, one smoke suite, and ongoing review discipline for future record pages.
- **Alternative intentionally rejected**: A new header-action framework, resolver, or interface layer was rejected because the current repo already has enough primitives to implement the discipline directly.
- **Release truth**: current-release operator clarity and action-surface discipline
## Implementation Strategy
### Phase A — Codify the inventory and the regression contract
Goal: turn the spec inventory into an enforceable project-level contract without introducing a new framework.
Changes:
- Extend the existing action-surface validation and exemption layer with Spec 192 surface expectations.
- Encode the explicit workflow-heavy exception for `ViewTenant`.
- Record which pages are remediation-required, which are audit-only, and which are compliant references.
Tests:
- Add `Spec192RecordPageHeaderDisciplineGuardTest.php`.
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` with Spec 192 expectations.
### Phase B — Remediate the highest-noise standard record pages
Goal: implement the clearest header-discipline wins first on the pages with the most obvious peer-action sprawl.
Changes:
- Refactor `ViewBaselineProfile` to expose one state-sensitive primary action and move navigation plus secondary actions out of the flat primary lane.
- Refactor `ViewTenantReview` so only one lifecycle action stays primary and the rest become grouped or contextual.
- Keep all existing action semantics, notifications, `OperationRun` links, confirmations, and authorization unchanged.
Tests:
- Reuse or extend existing baseline profile feature tests.
- Add runtime assertions for visible primary action count, grouped secondary placement, and preserved authorization behavior.
### Phase C — Remediate the remaining standard record and edit surfaces
Goal: finish the standard-page cleanup with lower-complexity but still important record surfaces.
Changes:
- Refactor `ViewEvidenceSnapshot` to keep one central next step and separate lifecycle danger from related navigation.
- Refactor `ViewFindingException` so navigation becomes secondary while governance lifecycle remains clear.
- Refactor `EditTenant` so the header no longer competes with the edit task.
Tests:
- Add focused feature tests for `EvidenceSnapshot`, `FindingException`, and `EditTenant`.
- Preserve existing capability gating and confirmation behavior in all assertions.
### Phase D — Tighten the explicit exception and audit-only pages
Goal: make the special type explicit and ensure audit-only pages stay calm.
Changes:
- Move pure navigation on `ViewTenant` into contextual placement outside the header and reorder grouped header actions by explicit buckets: external links, verification, setup, and lifecycle.
- Audit `ViewProviderConnection` and `ViewFinding` for minor alignment only and change them only if they still present real header-noise issues.
- Confirm `ViewBaselineSnapshot`, `ViewBackupSet`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace` remain compliant references.
Tests:
- Extend `TenantViewHeaderUiEnforcementTest.php` or add a dedicated special-type feature test.
- Cover the explicit exception reason in the guard layer.
### Phase E — Browser verification and final regression protection
Goal: prove the new hierarchy in a real browser and keep CI from accepting future header sprawl.
Changes:
- Add `Spec192RecordPageHeaderDisciplineSmokeTest.php` covering the remediated pages, the workflow-heavy exception, and a no-regression baseline over the compliant reference set.
- Ensure the guard layer fails on multiple competing primaries, missing grouped secondary structure where required, or silent exceptions.
- Re-run formatting and the focused Sail test pack.
Tests:
- Browser smoke coverage for visible hierarchy and no JavaScript errors.
- Focused guard and page-level tests for each remediated or exceptional surface.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Cleanup grows into a new action framework | Medium | Low | Keep all changes inside existing page classes and the current action-surface guard layer. |
| `More` groups become junk drawers | Medium | Medium | Treat internal order as part of the contract and test it on the special-type and remediated pages. |
| State-driven primary action is chosen incorrectly | High | Medium | Add focused runtime assertions for BaselineProfile and TenantReview state transitions. |
| `ViewTenant` silently bypasses the standard-page rule | Medium | Medium | Encode the special-type exception in the guard layer with an explicit reason and browser coverage. |
| Reference pages get unnecessary churn | Medium | Low | Keep a documented compliant-reference set and use it as a regression baseline. |
## Test Strategy
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` so Spec 192 becomes an explicit CI-enforced rule rather than a manual review note.
- Add `Spec192RecordPageHeaderDisciplineGuardTest.php` to validate remediation-required pages, the explicit special type, and any whitelisted references.
- Reuse existing baseline profile tests where possible and add focused feature tests for `EvidenceSnapshot`, `FindingException`, `TenantReview`, and `EditTenant` where no dedicated header-discipline tests exist yet.
- Extend `TenantViewHeaderUiEnforcementTest.php` or add a dedicated special-type test so grouped ordering and exception semantics stay covered.
- Add `Spec192RecordPageHeaderDisciplineSmokeTest.php` using the existing browser-smoke infrastructure and fixture traits already used by Spec 174 and Spec 190, including a no-regression pass over the compliant reference set.
- Add explicit regression assertions that this feature does not force Spec 133 body-layout rollout and does not expand confirmation depth, reason capture, or provider-dispatch semantics.
- 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`.
- No new globally searchable resource is introduced; touched resources already have View and/or Edit pages where relevant.
- Destructive actions remain confirmation-gated and authorization-gated.
- No new asset strategy is required; deploy handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- The testing plan covers the remediated standard pages, the explicit workflow-heavy exception, and the project-level regression guard for future record-page header drift.

View File

@ -0,0 +1,88 @@
# Quickstart: Record Page Header Discipline & Contextual Navigation
## Goal
Bring the in-scope record/detail/edit surfaces under one bounded header-action discipline: one clear next step on standard pages, contextual navigation near related content and outside the header, grouped secondary actions for non-navigation actions, separated danger, and one explicit workflow-heavy exception.
## Implementation Sequence
1. Confirm the in-scope inventory in code.
- Map each in-scope page class to the Spec 192 classification.
- Confirm which pages are remediation-required, minor-alignment only, compliant reference, or workflow-heavy special type.
2. Remediate the standard pages first.
- Refactor `ViewBaselineProfile` to expose only one visible primary action based on snapshot readiness.
- Refactor `ViewEvidenceSnapshot` so run and review-pack navigation move to contextual placement outside the header and `Expire snapshot` remains separated.
- Refactor `ViewFindingException` so navigation moves to contextual placement outside the header, `Renew exception` may remain primary, and `Revoke exception` stays isolated.
- Refactor `ViewTenantReview` so only one lifecycle action is primary, navigation becomes contextual outside the header, and infrequent lifecycle actions stay grouped.
- Refactor `EditTenant` so the header stops competing with the edit task and view/onboarding links move into contextual tenant-meta placement.
3. Tighten the explicit exception and minor-alignment pages.
- Audit `ViewTenant` as the workflow-heavy special type and order its grouped actions deliberately.
- Review `ViewProviderConnection` and `ViewFinding` for minor alignment only, and change them only if the audit proves real header noise.
- Confirm that `ViewBaselineSnapshot`, `ViewBackupSet`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace` remain valid references.
4. Add regression protection.
- Extend the existing action-surface guard or exemption mapping with Spec 192 expectations.
- Add focused Livewire/Pest page tests for remediated surfaces.
- Add one browser smoke suite covering the remediated pages, the explicit special-type exception, and a no-regression baseline over the compliant reference set.
- Add explicit regression checks that this feature does not force Spec 133 body-layout rollout and does not expand confirmation depth, reason capture, or provider-dispatch semantics.
5. Run focused verification.
- Run the guard tests, the remediated page tests, the browser smoke suite, and formatting through Sail.
## Suggested Source Files
- `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
- `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
- `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
## Suggested Test Files
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
- `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`
- `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
- `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`
- `apps/platform/tests/Browser/Spec174EvidenceFreshnessPublicationTrustSmokeTest.php`
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
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/Filament/BaselineProfileCaptureStartSurfaceTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open each remediated standard page and confirm the header shows at most one visible primary action.
2. Confirm pure navigation is no longer presented as an equal-weight peer to the primary mutation on remediated pages and now lives in contextual placement outside the header.
3. Confirm rare administrative actions live in a grouped secondary structure instead of a flat peer row.
4. Confirm destructive or governance-sensitive actions remain visually separated and keep confirmation.
5. Confirm the tenant edit page still reads as an edit surface first.
6. Confirm the tenant admin resource view stays grouped and ordered as an explicit workflow-heavy exception.
7. Confirm compliant reference pages do not regress or receive unnecessary cosmetic churn.
8. Confirm browser smoke checks show no JavaScript errors on the remediated pages, the workflow-heavy exception page, and the compliant reference baseline pages.
## Deployment Notes
- No migration is expected.
- No new provider registration 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,94 @@
# Research: Record Page Header Discipline & Contextual Navigation
## Decision: Reuse existing page-local action builders, `UiEnforcement`, `ActionGroup`, and related-navigation helpers instead of adding a new header-action framework
### Rationale
The codebase already has the right implementation primitives for this cleanup: page-private action builders, `UiEnforcement` for per-action RBAC handling, `ActionGroup` for secondary grouping, and `RelatedNavigationResolver` for contextual navigation. The spec needs discipline and regression protection, not a new registry, interface, or placement engine.
### Alternatives considered
- Add a `HeaderActionResolver` or `HeaderActionRegistry`: rejected because the repo already has several coordination abstractions and this feature does not justify another one.
- Add an `ActionPlacement` enum or interface hierarchy: rejected because the placement decisions remain page- and state-sensitive and would ossify too early.
## Decision: Keep contextual navigation close to the relevant content instead of leaving it in the flat header lane
### Rationale
Several touched pages already use related-navigation helpers or related-context rendering. The narrowest fix is to move pure navigation out of equal-weight header placement and into summary, field, badge, status, or related-context placement outside the header, using the existing related-navigation patterns rather than inventing new local UI concepts.
### Alternatives considered
- Keep `Open ...` and `View ...` actions in the primary header but restyle them: rejected because the problem is semantic weight, not only button color.
- Remove related navigation entirely: rejected because the navigation is still useful; it just belongs closer to its context.
## Decision: Treat the tenant admin resource view as an explicit workflow-heavy special-type exception
### Rationale
`ViewTenant` is not a simple record detail. It already acts as a workflow hub for verification, RBAC refresh, onboarding recovery, provider entry points, and lifecycle actions. The correct move is to keep it grouped and internally ordered, document it as a special type, and prevent it from silently bypassing the standard-page rule.
### Alternatives considered
- Force `ViewTenant` into the same single-next-step shape as simple record pages: rejected because it would misrepresent a multi-purpose operational hub.
- Leave `ViewTenant` undocumented as an exception: rejected because silent exceptions cause future drift and inconsistent review standards.
## Decision: Use existing calm pages as reference patterns and preserve no-op surfaces
### Rationale
The repo already contains pages that model the intended calmness well enough for this spec: `ViewBackupSet`, `ViewBaselineSnapshot`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace`. These pages should be preserved unless a minor alignment issue is found, because the goal is discipline, not cosmetic uniformity.
### Alternatives considered
- Rebuild every in-scope page to the same visible pattern: rejected because it would create churn without additional operator value.
- Ignore no-op pages and only document the problem pages: rejected because the spec requires an explicit project-wide classification matrix.
## Decision: Drive standard-page remediation with page-local state and existing action methods
### Rationale
The heaviest remediations, especially `ViewBaselineProfile` and `ViewTenantReview`, already express their action logic through page-private methods or explicit action blocks. The cleanest implementation is to keep that local state logic and only change placement and grouping. This preserves existing authorization, notifications, run links, and confirmations.
### Alternatives considered
- Rebuild header logic around a shared presenter object: rejected because the same state machine is not yet shared across enough pages to justify a new layer.
- Push all actions into a single generic `More` dropdown: rejected because the spec requires structured grouping and a visible next step, not a junk drawer.
## Decision: Build regression protection on top of the existing action-surface guard and focused page tests
### Rationale
The repo already has `ActionSurfaceValidator`, `ActionSurfaceContractTest`, `ActionSurfaceValidatorTest`, `FilamentTableStandardsGuardTest`, focused Livewire page tests, and browser smoke coverage. The narrowest regression strategy is to extend those existing layers with Spec 192 expectations: a whitelisted header-discipline guard, page-level visibility assertions on the remediated screens, and one browser smoke path for visible hierarchy.
### Alternatives considered
- Add a new standalone framework for header-discipline validation: rejected because the existing `ActionSurfaceValidator` and guard test style already provide the right enforcement hook.
- Rely on manual review only: rejected because header-sprawl regressions are exactly the kind of drift CI should catch.
## Decision: Keep testing layered but lightweight
### Rationale
Three test layers are enough for this feature:
- static or discovery-based guard coverage for the classification and header-discipline contract,
- focused Livewire/Pest page tests for state-driven primary-action visibility and grouped-secondary behavior,
- one browser smoke suite proving the visible hierarchy on remediated pages, the explicit special-type exception, and a no-regression baseline over the compliant reference set.
This matches existing repo patterns and avoids over-testing presentation indirection.
### Alternatives considered
- Browser-test every permutation of every page: rejected because it would be expensive and redundant with feature tests.
- Add only guard tests: rejected because page-level state transitions still need runtime assertions.
## Decision: No new asset or provider work is needed
### Rationale
The cleanup stays entirely within existing Filament v5 header action surfaces. No panel providers, custom assets, or lazy-loaded scripts are required. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
### Alternatives considered
- Introduce custom header components or new assets for grouped actions: rejected because native Filament header actions and `ActionGroup` already satisfy the need.

View File

@ -0,0 +1,341 @@
# Feature Specification: Record Page Header Discipline & Contextual Navigation
**Feature Branch**: `192-record-header-discipline`
**Created**: 2026-04-11
**Status**: Proposed
**Input**: User description: "Spec 192 - Record Page Header Discipline & Contextual Navigation"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Several classic admin record/detail/edit pages still expose flat header button rows where navigation, routine mutation, infrequent administration, and danger all compete at the same visual weight.
- **Today's failure**: Operators reach the right pages, but the header often does not signal the next best step. Navigation occupies prime action space, multiple mutations compete visibly, and rare or destructive actions appear too close to routine actions.
- **User-visible improvement**: Standard record pages become calmer and easier to scan. One next step becomes obvious, contextual navigation moves closer to the content it belongs to, and risky or rare actions stop cluttering the primary header lane.
- **Smallest enterprise-capable version**: Classify all in-scope record/detail/edit surfaces, remediate the clearly problematic standard pages with one shared header discipline, explicitly catalog the one workflow-heavy exception, and add lightweight regression protection.
- **Explicit non-goals**: No new global action framework for every surface class, no monitoring or workbench action cleanup, no new confirmation-depth policy, no provider-dispatch redesign, and no forced Spec 133 body-layout rollout onto simple pages.
- **Permanent complexity imported**: A narrow cross-page header-discipline contract, an explicit surface-classification matrix, a documented special-type exception, and focused regression coverage for record-page header sprawl.
- **Why now**: The constitution now contains HDR-001, and the repo already has both good and bad examples. Without a concrete inventory and rule rollout, drift will continue page by page.
- **Why not local**: Isolated page-by-page cleanup would reduce noise on one page at a time but would not create a stable repo-wide rule for when navigation belongs in context, when secondary actions must be grouped, or when a page deserves a special-type exception.
- **Approval class**: Cleanup
- **Red flags triggered**: Cross-domain UI rule risk if this grows into a generalized action framework. Defense: the spec stays limited to classic record/detail/edit surfaces, forbids a new engine, and explicitly excludes other surface classes.
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- Existing BaselineProfile resource view route
- Existing EvidenceSnapshot, FindingException, and TenantReview resource view routes
- Existing Tenant resource view and edit routes in the tenant admin plane
- Existing ProviderConnection and Finding resource view routes
- Existing ReviewPack, AlertDestination, PolicyVersion, Workspace resource view routes
- Existing BaselineSnapshot and BackupSet resource view routes
- **Data Ownership**:
- Workspace-owned records touched by this spec remain workspace-owned: BaselineProfile, BaselineSnapshot, AlertDestination, PolicyVersion, and Workspace views.
- Tenant-owned records touched by this spec remain tenant-owned: EvidenceSnapshot, Finding, FindingException, TenantReview, Tenant, ProviderConnection, ReviewPack, and BackupSet views.
- This spec introduces no new tables, persisted entities, route semantics, or record truth. It changes only action hierarchy, placement, and classification on existing pages.
- **RBAC**:
- Existing workspace membership plus capability checks continue to govern workspace-owned pages.
- Existing tenant membership plus capability checks continue to govern tenant-owned pages.
- Header regrouping does not change authorization semantics: non-members remain `404`, members lacking a capability remain `403`, and destructive actions retain confirmation plus server-side authorization.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: This feature adds no new filter behavior. Tenant-scoped record pages remain bound to the active tenant context, while workspace-owned record pages remain workspace-scoped even if a tenant was previously active. Moving navigation out of the primary header must not broaden scope or imply cross-tenant search.
- **Explicit entitlement checks preventing cross-tenant leakage**: Any contextual link that survives the header cleanup must still be built through existing related-navigation and capability-aware helpers. Inaccessible related records remain suppressed, non-members remain deny-as-not-found, and moving an action from primary header placement to contextual or grouped placement must not reveal a destination that was previously inaccessible.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Surface Type | 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Baseline profile detail | Workspace record detail | Existing BaselineProfile inspect flow into the view page | allowed | Summary context and grouped secondary header actions after remediation | none | Existing BaselineProfile collection route | Existing BaselineProfile view route | Workspace, active snapshot state, visible assigned-tenant count | Baseline profile | Whether a consumable snapshot exists and whether capture or compare is the next step | remediation required |
| Evidence snapshot detail | Tenant record detail | Existing EvidenceSnapshot inspect flow into the view page | allowed | Related-context links and grouped secondary header actions | Separated lifecycle danger action | Existing EvidenceSnapshot collection route | Existing EvidenceSnapshot view route | Tenant, freshness, expiry, latest review-pack relationship | Evidence snapshot | Whether the snapshot is current, reusable, or needs refresh/expiry handling | remediation required |
| Finding exception detail | Tenant governance detail | Existing FindingException inspect flow into the view page | allowed | Related finding and approval-queue links move to contextual placement outside the header | Revocation remains isolated danger | Existing FindingException collection or approval-queue route | Existing FindingException view route | Tenant, validity state, owner, review due state | Finding exception | Whether the exception remains valid and what governance step is next | remediation required |
| Tenant review detail | Tenant record detail | Existing TenantReview inspect flow into the view page | allowed | Related export/evidence/run links move to contextual placement outside the header, while infrequent lifecycle actions stay grouped secondary | Archive remains separated inside secondary danger placement | Existing TenantReview collection route | Existing TenantReview view route | Tenant, review status, evidence/export linkage, operation context | Tenant review | Which lifecycle step is next and whether the review is ready to refresh, publish, or export | remediation required |
| Tenant edit surface | Tenant edit record | Existing Tenant edit entry from tenant list or tenant detail | allowed | Related view/context links move to contextual placement outside the header | Archive or restore stays separate from ordinary edit context | Existing Tenant collection route | Existing Tenant edit route | Tenant lifecycle, workspace context, related onboarding status | Tenant | That this surface is for editing and not for multi-purpose workflow dispatch | remediation required |
| Tenant detail (tenant admin resource view) | Workflow-heavy tenant detail hub | Existing Tenant inspect flow into the resource view page | allowed | Contextual navigation remains outside the header, while internal grouped header actions remain mandatory for external links, verification, setup, and lifecycle work | Lifecycle actions remain isolated inside grouped danger placement | Existing Tenant collection route | Existing Tenant resource view route | Tenant lifecycle, verification state, RBAC health, recent operations, onboarding context | Tenant | What setup, verification, or lifecycle step currently deserves attention | workflow-heavy special-type exception |
| Provider connection detail | Tenant configuration detail | Existing ProviderConnection inspect flow into the view page | allowed | Secondary admin actions stay in a deliberate grouped override-management structure | Dangerous credential actions stay inside separated danger slot within the group | Existing ProviderConnection collection route | Existing ProviderConnection view route | Tenant, provider, connection type, consent and verification state | Provider connection | Whether the connection is usable and which override or consent step is relevant | minor alignment only |
| Finding detail | Tenant record detail | Existing Finding inspect flow into the view page | allowed | Back-link, related-context link, and approval-queue navigation stay secondary or grouped | Workflow danger stays inside the workflow group | Existing Finding collection route | Existing Finding view route | Tenant, severity or health context, related approval state | Finding | What the finding means and where the next governance or remediation path lives | minor alignment only |
| Review pack detail | Tenant export detail | Existing ReviewPack inspect flow into the view page | allowed | Regenerate remains secondary to the pack outcome | none | Existing ReviewPack collection route | Existing ReviewPack view route | Tenant, pack status, export options, readiness | Review pack | Whether the pack is ready for download | compliant / no-op reference |
| Alert destination detail | Workspace configuration detail | Existing AlertDestination inspect flow into the view page | allowed | Deep-link navigation stays secondary | none | Existing AlertDestination collection route | Existing AlertDestination view route | Workspace, destination type, enabled state, last-test state | Alert destination | Whether delivery is enabled and whether the last test succeeded | compliant / no-op reference |
| Policy version detail | Workspace detail | Existing PolicyVersion inspect flow into the view page | allowed | Single related-record navigation remains contextual | none | Existing PolicyVersion collection route | Existing PolicyVersion view route | Workspace, version identity, policy relationship | Policy version | What version is being inspected and what record it belongs to | compliant / no-op reference |
| Workspace resource detail | Workspace detail | Existing Workspace inspect flow into the resource view page | allowed | No secondary header cluster required | none | Existing Workspace collection route | Existing Workspace resource view route | Workspace identity and manage capability state | Workspace | Which workspace is being viewed and whether it can be edited | compliant / no-op reference |
| Baseline snapshot detail | Workspace detail | Existing BaselineSnapshot inspect flow into the view page | allowed | Single related-record navigation remains contextual | none | Existing BaselineSnapshot collection route | Existing BaselineSnapshot view route | Workspace, source profile, snapshot recency | Baseline snapshot | What snapshot is current and what related record it belongs to | compliant / no-op reference |
| Backup set detail | Tenant recovery detail | Existing BackupSet inspect flow into the view page | allowed | Related-record navigation plus grouped secondary mutations | Grouped restore/archive/force-delete danger structure already exists | Existing BackupSet collection route | Existing BackupSet view route | Tenant, archive state, restore relevance | Backup set | Whether the backup set is active, archived, or eligible for restore | compliant / no-op reference |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Baseline profile detail | Workspace operator | Standard record detail | Is this baseline ready to capture or compare, and what is the next step? | Snapshot readiness, capture mode, visible assignment scope | Run IDs, rollout reason details | readiness, snapshot freshness | `simulation only` for compare; existing workspace capture path for capture | `Capture baseline` or `Compare now` | none |
| Evidence snapshot detail | Tenant operator | Standard record detail | Is this evidence current enough, and do I need to refresh or inspect related outputs? | Snapshot status, expiry, related pack/run availability | Internal run identifiers | freshness, lifecycle | Existing evidence refresh scope only | `Refresh evidence` when applicable | `Expire snapshot` |
| Finding exception detail | Tenant manager | Governance record detail | Is this exception still valid, and do I need to renew or revoke it? | Current validity, owner, review due, linked finding context | Detailed evidence references | validity, governance lifecycle | `TenantPilot only` | `Renew exception` when applicable | `Revoke exception` |
| Tenant review detail | Tenant manager | Standard record detail | What is the next lifecycle step for this review? | Review status, evidence/export linkage, current operation context | Low-level run metadata | lifecycle, readiness, freshness | `TenantPilot only` for publish/archive; existing export path for pack generation | One of `Refresh review`, `Publish review`, or `Export executive pack` depending on state | `Archive review` |
| Tenant edit surface | Tenant manager or owner | Edit surface | Can I update this tenant safely without losing context? | Editable tenant identity, lifecycle state, related onboarding context | Technical provider details remain outside the primary edit task | lifecycle | `TenantPilot only` | Save the edit form | `Archive` or `Restore` when available |
| Tenant detail (tenant admin resource view) | Tenant operator or manager | Workflow-heavy detail hub | What tenant setup, verification, or lifecycle step needs attention right now? | Verification report, recent operations, RBAC health, onboarding state | Raw run detail and lower-level provider metadata | lifecycle, verification readiness, RBAC health | Mixed existing tenant-operation scopes | `Verify configuration` only if it is the clearly dominant next step; otherwise grouped actions only | `Archive` or `Restore` |
| Provider connection detail | Tenant manager | Configuration detail | Is this connection healthy, and what provider-management step is next? | Consent, connection type, override state | Credential-specific technical detail | consent, verification, lifecycle | Existing provider-management scopes | `Edit` or `Grant admin consent` only if one clearly dominates | Credential delete and override reversion remain grouped danger |
| Finding detail | Tenant operator | Standard record detail | What does this finding mean, and where should I go next? | Finding summary, related record context, governance path | Low-level payload detail | governance state, health or severity | Existing finding workflow scope only | Existing workflow primary, if any, stays inside governed group | Existing workflow danger only |
| Review pack detail | Tenant operator | Export detail | Is this pack ready to use? | Status, download readiness, pack options summary | Raw generation metadata | readiness, lifecycle | Existing export scope only | `Download` | none |
| Alert destination detail | Workspace operator | Configuration detail | Is this destination working? | Destination type, enabled state, last-test result | Delivery log drilldown context | enablement, delivery health | Existing alert-test scope only | `Send test message` | none |
| Policy version detail | Workspace operator | Reference detail | Which version am I looking at, and what record should I open next? | Version identity and parent policy context | Raw payload footer content | version lifecycle | read-only | Related-record open action only | none |
| Workspace resource detail | Workspace owner or manager | Standard record detail | What workspace is this, and can I manage it? | Workspace identity and top-level manage affordance | Low-level metadata | none beyond manage eligibility | `TenantPilot only` | `Edit` | none |
| Baseline snapshot detail | Workspace operator | Reference detail | What snapshot is this and what should I open next? | Snapshot identity, related profile context | Detailed snapshot payload content | recency, completeness | read-only | Related-record open action only | none |
| Backup set detail | Tenant manager | Recovery detail | Is this backup set active, archived, or ready to restore? | Archive state, related record context, restore relevance | Deep backup item detail | lifecycle, restore readiness | Existing backup mutation scope only | Related-record open action stays secondary; restore remains grouped | `Archive` and `Force delete` remain grouped danger |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: yes
- **Current operator problem**: Classic record pages are inconsistent about what belongs in the primary header lane, so operators see avoidable noise and weak hierarchy on pages that should be fast to scan.
- **Existing structure is insufficient because**: HDR-001 exists at the constitutional level, but the repo still lacks a concrete inventory, a bounded standard-record rule set, and an explicit way to justify a workflow-heavy exception without page-local improvisation.
- **Narrowest correct implementation**: Apply the rule only to the explicitly named record/detail/edit surfaces, remediate only the pages that need it, preserve already clean pages, and document a single special-type exception instead of inventing a framework.
- **Ownership cost**: Ongoing review discipline for header-action placement, a small regression-test burden, browser smoke maintenance for remediated pages, and explicit exception tracking.
- **Alternative intentionally rejected**: Purely local cleanup on the five obvious problem pages was rejected because it would reduce immediate noise but would not stop future drift or explain why some pages are compliant while one page is an allowed exception.
- **Release truth**: current-release operator clarity and action-surface discipline
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See one next step on standard record pages (Priority: P1)
As an operator opening a standard record/detail/edit page, I want the header to show one clear next step instead of a horizontal row of competing buttons.
**Why this priority**: This is the core workflow benefit. If the next step still competes with several peer actions, the feature has not solved the problem it targets.
**Independent Test**: Open each remediation-required standard page and verify that no more than one visible emphasized header action remains while routine navigation and rare actions move out of the flat primary lane.
**Acceptance Scenarios**:
1. **Given** a remediation-required standard record page, **When** the page renders after cleanup, **Then** it shows at most one visible primary header action.
2. **Given** a standard page whose next step depends on state, **When** the record state changes, **Then** the page promotes only the state-appropriate next action and keeps others secondary.
---
### User Story 2 - Follow contextual navigation near the relevant content (Priority: P1)
As an operator, I want related navigation to live near the summary or context it belongs to, instead of taking the same visual weight as a mutation.
**Why this priority**: Contextual navigation is still important, but it should support the reading flow instead of dominating the header.
**Independent Test**: Open remediated pages that currently mix navigation and mutation in the header and confirm that pure navigation moves to related-context or other inline contextual placement outside the header.
**Acceptance Scenarios**:
1. **Given** a remediated standard page with related-record navigation, **When** the page renders, **Then** the navigation no longer appears as a peer to the main mutation and instead lives in contextual placement outside the header.
2. **Given** a page offers both navigation and a next-step mutation, **When** the operator scans the header, **Then** the mutation reads as the next step and the navigation reads as supporting context.
---
### User Story 3 - Keep rare and dangerous actions available without clutter (Priority: P2)
As an operator, I want infrequent administrative actions and destructive actions to remain available without visually competing with routine work.
**Why this priority**: The pages still need power-user actions, but those actions should not make every visit look risky or overloaded.
**Independent Test**: Open pages with lifecycle or dangerous actions and confirm that rare actions are grouped and danger remains visibly separated with existing confirmation behavior intact.
**Acceptance Scenarios**:
1. **Given** a remediated page includes rare administrative actions, **When** the page renders, **Then** those actions live in a deliberate secondary group instead of as peer buttons.
2. **Given** a remediated page includes destructive or governance-sensitive actions, **When** the page renders, **Then** those actions remain separated from safe routine actions and still require confirmation.
---
### User Story 4 - Treat workflow-heavy pages as explicit exceptions (Priority: P3)
As a product reviewer, I want workflow-heavy record pages to be explicitly identified as exceptions so they stay disciplined without being forced into the wrong pattern.
**Why this priority**: The cleanup should not flatten important operational hubs into misleadingly simple pages.
**Independent Test**: Review the special-type page and verify that it is explicitly classified, internally structured, and not silently exempted.
**Acceptance Scenarios**:
1. **Given** the tenant admin resource view is a workflow-heavy hub, **When** the spec and implementation are reviewed, **Then** it is marked as a special-type exception with a documented internal action order.
2. **Given** a special-type page does not have one obvious next step, **When** it renders, **Then** it does not reintroduce a flat multi-button header row in the name of consistency.
### Edge Cases
- If a baseline profile has no consumable snapshot, `Capture baseline` remains the sole visible primary action and compare actions stay secondary or disabled.
- If a standard page has no related destination currently available, the cleanup must not insert a dead contextual-navigation placeholder just to satisfy consistency.
- If the current actor can view a record but cannot execute its mutation, the action may remain visible-but-disabled per existing RBAC rules, but it still must not become a competing primary if it is not executable.
- If a workflow-heavy page has no clearly dominant next step, it should keep grouped actions only rather than manufacturing a fake primary.
- If a compliant reference page already has one clean related action or one clean grouped danger structure, the spec must not force cosmetic churn.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new persisted truth, and no new queue model. It only reorganizes existing operator-facing action surfaces on existing record/detail/edit pages. Existing mutations keep their current preview, confirmation, audit, and run-observability behavior.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The spec adds only a narrow cross-page UI discipline plus an explicit exception vocabulary. It introduces no new persistence, no new state family, and no generalized action engine. The proportionality review above documents why a bounded cross-page rule is justified and why a broader framework is rejected.
**Constitution alignment (OPS-UX):** Existing operations such as baseline capture, baseline compare, evidence refresh, tenant review refresh, verification, and provider-management actions continue to use their current `OperationRun`, toast, and audit contracts. This spec does not create a new run type or alter existing run summary semantics.
**Constitution alignment (RBAC-UX):** The feature spans workspace/admin and tenant-context surfaces but does not change authorization logic. Non-members remain `404`, members lacking required capabilities remain `403`, grouped or relocated actions still enforce server-side authorization, and destructive actions continue to require confirmation. At least one positive and one negative regression test must confirm that regrouping actions does not loosen access.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior changes.
**Constitution alignment (BADGE-001):** The cleanup does not introduce new badge semantics. Existing status and health badges remain centralized and unchanged.
**Constitution alignment (UI-FIL-001):** The feature must use native Filament header actions, `ActionGroup`, and existing shared UI primitives. It must avoid a page-local button framework or ad-hoc badge language. The only approved exception is keeping the tenant admin resource view as a workflow-heavy surface with explicit grouped action ordering.
**Constitution alignment (UI-NAMING-001):** Action labels must stay domain-first and consistent as actions move between primary, contextual, and grouped placement. Target verbs include `Capture baseline`, `Compare now`, `Refresh review`, `Publish review`, `Verify configuration`, `Open …`, `Archive`, and `Restore`. Implementation-first labels must not become primary operator labels.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** This spec classifies each affected surface, defines the one inspect/open model already in use, preserves existing list inspect affordances, and reassigns secondary and destructive actions according to surface type. Standard record pages must expose one clear next step; explicit exceptions must be catalogued and justified.
**Constitution alignment (OPSURF-001):** Default-visible header content must stay operator-first. Pure navigation becomes contextual content outside the header, diagnostics remain secondary, and dangerous actions keep existing confirmation and mutation-scope language. Workspace and tenant context must remain explicit through the same routes, related links, and action copy already in use.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature introduces no new presenter or semantic layer. It uses direct action placement and classification rather than a new interpretation framework. Tests should prove business consequences of header discipline, not a thin abstraction.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied for all remediated standard record/detail/edit pages after cleanup: one visible primary header action, no redundant flat navigation buttons, no empty action groups, and destructive actions in the correct secondary or danger placement. The tenant admin resource view is the only explicit exception and must document why grouped workflow actions remain appropriate.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes header-action hierarchy only. View pages continue to rely on their existing infolists or structured sections, and the tenant edit page keeps its edit-form save/cancel affordances as the primary edit path. The spec does not justify any regression toward disabled edit forms or naked-field layouts.
### Functional Requirements
- **FR-192-001 Surface inventory**: The spec and implementation MUST maintain an explicit inventory of every in-scope record/detail/edit surface covered by this feature.
- **FR-192-002 Explicit classification**: Every in-scope surface MUST be assigned exactly one classification: `compliant / no-op reference`, `remediation required`, `minor alignment only`, or `workflow-heavy special-type exception`.
- **FR-192-003 Standard-page primary rule**: Every remediated standard record/detail/edit surface MUST expose at most one visible emphasized primary header action.
- **FR-192-004 Contextual navigation rule**: Pure related-record navigation MUST leave the flat primary header lane on remediated standard pages and move into contextual placement outside the header.
- **FR-192-005 Secondary grouping rule**: Infrequent, administrative, or secondary actions MUST move into deliberate grouped secondary placement rather than remaining as peer buttons.
- **FR-192-006 Group-order rule**: Secondary grouped actions MUST remain internally ordered so navigation, routine mutation, external links, and danger do not become an undifferentiated junk drawer.
- **FR-192-007 Danger separation rule**: Destructive, irreversible, or governance-sensitive actions MUST remain visibly separated from safe routine actions and keep their existing confirmation behavior.
- **FR-192-008 Baseline profile hierarchy**: On BaselineProfile detail, `Capture baseline` MUST be the only visible primary action when no consumable snapshot exists, and `Compare now` MUST be the only visible primary action when a consumable snapshot exists. `View snapshot` and `Open compare matrix` MUST move to contextual placement outside the header, while `Compare assigned tenants` and `Edit` become secondary.
- **FR-192-009 Evidence snapshot hierarchy**: On EvidenceSnapshot detail, only one central next action MAY remain visible. `Open operation` and `View review pack` MUST move to contextual placement outside the header, and `Expire snapshot` MUST remain a separated lifecycle danger action.
- **FR-192-010 Finding exception hierarchy**: On FindingException detail, related navigation MUST move to contextual placement outside the header, `Renew exception` MAY be primary when renewal is valid, and `Revoke exception` MUST remain separated as a danger-governance action.
- **FR-192-011 Tenant review hierarchy**: On TenantReview detail, only one dominant lifecycle action MAY remain primary based on review state. Refresh, publish, export, and related navigation MUST no longer appear as a flat row of peer buttons; pure navigation moves to contextual placement outside the header, and archive remains secondary danger.
- **FR-192-012 Tenant edit discipline**: EditTenant MUST remain edit-first. Save/cancel remain the primary edit affordance, while `View` and related onboarding move to contextual placement outside the header and lifecycle actions stay secondary and structurally separated.
- **FR-192-013 Workflow-heavy exception contract**: The tenant admin resource view MUST be explicitly marked as a workflow-heavy special type. It MAY expose one visible primary action only when a clear next step dominates; otherwise its actions remain grouped with deliberate internal order.
- **FR-192-014 Minor-alignment review**: ViewProviderConnection and ViewFinding MUST be reviewed against the same discipline but changed only when a real header-noise issue exists.
- **FR-192-015 Reference preservation**: ViewBaselineSnapshot, ViewBackupSet, ViewReviewPack, ViewAlertDestination, ViewPolicyVersion, and the Workspace resource view MUST stay unchanged or receive only minimal alignment if they already satisfy the discipline.
- **FR-192-016 No body-layout expansion**: This feature MUST NOT force Spec 133 body-layout rollout onto simple view pages that are already structurally acceptable.
- **FR-192-017 No governance-friction expansion**: This feature MUST NOT widen confirmation depth, reason-capture rules, or provider-dispatch semantics beyond the behavior already owned by the underlying actions.
- **FR-192-018 Authorization continuity**: Moving, grouping, or relabeling actions MUST NOT change route scope, capability enforcement, deny-as-not-found behavior, or audit obligations.
- **FR-192-019 Vocabulary continuity**: Header labels, modal titles, notifications, and related helper copy MUST keep the same domain vocabulary when actions move between primary, contextual, and grouped placement.
- **FR-192-020 Regression guard**: The repo MUST add a lightweight project-wide guard that prevents new standard record pages from reintroducing multiple competing primary header actions, flat navigation-mutation mixes, or silent special-type exceptions.
- **FR-192-021 Browser verification**: Browser or UI smoke checks MUST cover all remediation-required pages, the explicit workflow-heavy exception, and a no-regression baseline over the compliant or no-op reference pages.
## Surface Decision Matrix
- **Remediation required**:
- BaselineProfile detail
- EvidenceSnapshot detail
- FindingException detail
- TenantReview detail
- EditTenant
- **Workflow-heavy special-type exception**:
- Tenant detail (tenant admin resource view)
- **Minor alignment only**:
- ProviderConnection detail
- Finding detail
- **Compliant / no-op reference**:
- BaselineSnapshot detail
- BackupSet detail
- ReviewPack detail
- AlertDestination detail
- PolicyVersion detail
- Workspace resource detail
## Target Outcomes by Key Surface
- **BaselineProfile detail**: The header presents one obvious next step. Snapshot and matrix navigation stop competing with compare or capture. Secondary compare variants and edit move into grouped secondary placement.
- **EvidenceSnapshot detail**: One next action stays visible. Run and review-pack navigation move to contextual placement outside the header. `Expire snapshot` remains clearly separated as lifecycle danger.
- **FindingException detail**: Governance lifecycle stops competing with related navigation. `Renew exception` and `Revoke exception` are no longer presented as flat equals.
- **TenantReview detail**: Review lifecycle becomes easier to scan. The page distinguishes viewing related outputs from refreshing, publishing, exporting, or archiving.
- **EditTenant**: The page reads as an edit surface first. View/context links move to contextual placement outside the header, and archive or restore stops competing with the edit task.
- **Tenant detail (tenant admin resource view)**: The page remains a workflow hub, but grouped header actions gain an explicit internal order separating external links, verification, setup, and lifecycle, while pure navigation moves into contextual placement outside the header.
## Non-Goals
- Creating a new global action framework for every surface class
- Cleaning up monitoring, queue, or workbench surfaces
- Hardening confirmation-depth, reason-capture, or danger-vocabulary policy
- Changing dispatch, preflight, provider-start, or other backend operation semantics
- Extending Spec 133 body composition to every view page by default
- Cosmetic flattening of already-clean pages for the sake of uniformity alone
## Assumptions
- Spec 133 remains a body-composition reference only and is not the rollout vehicle for this feature.
- Existing pages such as BaselineSnapshot detail and BackupSet detail remain valid internal reference patterns for calm record-page headers.
- Existing server-side authorization, audit, and run-observability behavior is already correct for the underlying actions and will be preserved as actions move.
- The tenant admin resource view is the only currently known page in this scope that deserves an explicit workflow-heavy exception rather than a standard-record cleanup.
## Dependencies
- HDR-001 in the constitution as the source rule for header action discipline
- Existing related-navigation and contextual-link helpers already used by several detail pages
- Existing Filament header action surfaces and `ActionGroup` patterns
- Existing browser and regression testing infrastructure for Filament surfaces
## Risks
- The cleanup could drift into a broader action-framework project if it is not kept strictly to classic record/detail/edit surfaces.
- A grouped secondary menu could become a junk drawer if internal order is not treated as part of the contract.
- Workflow-heavy pages could be flattened incorrectly if the exception rule is not explicit and narrow.
- Cosmetic overreach could create churn on already-compliant pages and dilute the value of the cleanup.
## Review Questions
- Does every remediated standard page now make the next likely step obvious?
- Has pure navigation moved closer to the content it belongs to instead of living in the primary header lane?
- Are rare and dangerous actions calmer without becoming hard to find?
- Is the tenant admin resource view clearly documented as a special type rather than a silent inconsistency?
- Have compliant reference pages been preserved instead of cosmetically rebuilt?
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| BaselineProfile detail | `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | `Capture baseline` or `Compare now` becomes the sole visible primary; `View snapshot` and `Open compare matrix` move to contextual placement outside the header, while `Compare assigned tenants` and `Edit` become grouped secondary actions | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Flat multi-button header is removed; contextual navigation leaves the primary lane | n/a | Existing compare and capture run/audit behavior unchanged | Remediation-required standard record page |
| EvidenceSnapshot detail | `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` | One visible primary action only; `Open operation` and `View review pack` move to contextual placement outside the header; `Expire snapshot` remains separated danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Header becomes `one next step + contextual links + separated danger` | n/a | Existing evidence refresh and expire behavior unchanged | Remediation-required standard record page |
| FindingException detail | `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php` | `Renew exception` may be primary; `Open finding` and `Open approval queue` move to contextual placement outside the header; `Revoke exception` remains separated danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Navigation and lifecycle actions are no longer flat peers | n/a | Existing exception-service audit behavior unchanged | Remediation-required standard record page |
| TenantReview detail | `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` | One of `Refresh review`, `Publish review`, or `Export executive pack` may be primary depending on state; `Open operation`, `View executive pack`, and `View evidence snapshot` move to contextual placement outside the header; `More` retains infrequent actions with `Archive review` separated as danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Flat peer row of navigation plus lifecycle actions is removed | n/a | Existing review lifecycle and export behavior unchanged | Remediation-required standard record page |
| EditTenant | `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php` | Header no longer carries `View` or related onboarding navigation; those move into contextual tenant-meta placement outside the header, while lifecycle actions remain grouped or separated and do not compete with the edit task | Existing resource edit entry unchanged | Unchanged by this spec | none | unchanged | Header no longer competes with form submission | Save and cancel remain the primary edit affordance | Existing tenant lifecycle audit behavior unchanged | Remediation-required edit surface |
| Tenant detail (tenant admin resource view) | `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php` | Existing grouped `Actions` menu stays for external links, verification, setup, and lifecycle work, while pure navigation moves to contextual placement outside the header; `Verify configuration` may be lifted only as a sole visible primary if clearly justified | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No flat multi-button row is introduced to fake standardization | n/a | Existing tenant verification, RBAC refresh, and lifecycle audit behavior unchanged | Explicit workflow-heavy special-type exception |
| ProviderConnection detail | `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` | Audit whether `Grant admin consent` and `Edit` should remain peers; dedicated override actions stay grouped under one managed secondary structure | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Only minor cleanup if the header still reads as two competing primaries | n/a | Existing dedicated-override audit behavior unchanged | Minor alignment only |
| Finding detail | `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php` | `Back to origin`, `Open related record`, and `Open approval queue` remain contextual outside the header; workflow actions stay inside the existing governed group | Existing resource inspect flow unchanged | Unchanged by this spec | Existing list behavior unchanged | unchanged | Only adjust if flat navigation still competes with the workflow group | n/a | Existing workflow audit behavior unchanged | Minor alignment only |
| ReviewPack detail | `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` | `Download` remains the clear primary and `Regenerate` remains secondary | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing review-pack generation behavior unchanged | Compliant / no-op reference |
| AlertDestination detail | `apps/platform/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php` | `Send test message` stays primary and `View last delivery` stays secondary | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing alert-test behavior unchanged | Compliant / no-op reference |
| PolicyVersion detail | `apps/platform/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php` | Existing calm related-record access remains contextual and needs no expansion in this feature | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Read-only surface; no new audit behavior | Compliant / no-op reference |
| Workspace resource detail | `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` | Single `Edit` action remains acceptable | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing workspace-manage behavior unchanged | Compliant / no-op reference |
| BaselineSnapshot detail | `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` | Existing calm related-record access remains contextual and needs no expansion in this feature | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Read-only surface; no new audit behavior | Compliant / no-op reference |
| BackupSet detail | `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php` | Existing `More` structure with grouped restore and delete lifecycle actions remains acceptable | Existing resource inspect flow unchanged | Unchanged by this spec | Existing list behavior unchanged | unchanged | No structural header change expected unless grouped ordering needs small tightening | n/a | Existing restore/delete audit behavior unchanged | Compliant / no-op reference |
### Key Entities *(include if feature involves data)*
- **Record Header Discipline**: The cross-page contract for standard record/detail/edit pages: one visible primary action, contextual navigation near content, grouped secondary actions, and separated danger.
- **Surface Classification Matrix**: The explicit catalog assigning each in-scope surface to remediation required, minor alignment only, compliant/no-op reference, or workflow-heavy special-type exception.
- **Special-Type Exception**: The explicit allowance for a workflow-heavy record page to stay grouped and internally ordered without pretending to be a standard single-next-step detail page.
- **Contextual Navigation Slot**: The related-context placement that keeps navigation near the summary or section it belongs to instead of in a flat primary header row.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-192-001**: In acceptance review and smoke coverage, 100% of remediation-required standard record/detail/edit pages show no more than one visible primary header action.
- **SC-192-002**: 100% of in-scope surfaces are explicitly classified in the spec and implementation notes, and no affected page remains an undocumented exception.
- **SC-192-003**: Every remediated standard page relocates at least one pure navigation action out of the flat primary header lane into contextual placement outside the header.
- **SC-192-004**: During acceptance walkthroughs, reviewers can identify the next likely step on each remediated standard page within 5 seconds without scanning more than one header action cluster.
- **SC-192-005**: No compliant or no-op reference page receives a structural header rebuild unless a documented minor-alignment finding exists, and smoke coverage confirms no regression on that reference set.
- **SC-192-006**: The tenant admin resource view remains explicitly marked as a workflow-heavy special type and passes review without reverting to a flat peer-button header row.
## Definition of Done
This feature is complete when:
- every in-scope record/detail/edit surface is classified explicitly,
- every remediated standard page shows at most one visible primary header action,
- contextual navigation has left the flat primary header lane on remediated standard pages,
- rare and administrative actions are grouped deliberately rather than left as peer buttons,
- destructive or governance-sensitive actions remain structurally separated,
- the tenant admin resource view is explicitly documented and treated as a workflow-heavy special type,
- compliant and no-op reference pages are preserved rather than cosmetically rebuilt,
- a lightweight regression guard exists for future record-page header changes,
- and browser smoke checks confirm the visible hierarchy on the remediated pages.
## Recommended Sequencing
- Spec 193 should handle monitoring and workbench action hierarchy, where different surface rules are needed.
- Spec 194 should handle governance-friction hardening, including confirmation depth, reason capture, and danger-language policy.

View File

@ -0,0 +1,237 @@
# Tasks: Record Page Header Discipline & Contextual Navigation
**Input**: Design documents from `/specs/192-record-header-discipline/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/record-header-discipline.logical.openapi.yaml`
**Tests**: Tests are REQUIRED. Extend the existing guard layer, focused Pest feature coverage, and browser smoke coverage for the affected Filament pages.
**Operations**: This feature reuses existing action semantics only. No new `OperationRun` type, summary-count contract, or notification channel should be introduced.
**RBAC**: Existing workspace and tenant authorization semantics remain authoritative. Tasks must preserve non-member `404`, member-without-capability `403`, central capability registry usage, and per-action `UiEnforcement`.
**Filament v5 / Livewire v4**: All touched surfaces remain inside the existing Filament v5 + Livewire v4 stack.
**Provider Registration**: No panel or provider changes are planned; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
**Global Search**: Touched resources already have their current View/Edit coverage and search settings; no new globally searchable resource is introduced.
**Destructive Actions**: Existing destructive or governance-changing actions must remain `->requiresConfirmation()` and authorization-gated after regrouping.
**Asset Strategy**: No new asset registration is planned; existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently, while acknowledging that some stories touch the same page files and are therefore safer to land sequentially.
## Phase 1: Setup (Acceptance Seams)
**Purpose**: Create the focused guard, feature, and browser test entry points used by the implementation work.
- [X] T001 Create the Spec 192 guard entry point in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`
- [X] T002 [P] Create the focused page-test entry points in `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
- [X] T003 [P] Create the browser smoke entry point and compliant-reference baseline cases in `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
**Checkpoint**: Focused verification entry points exist for guard, page-level, and browser-level work.
---
## Phase 2: Foundational (Blocking Regression Contract)
**Purpose**: Encode the surface inventory, classification, and explicit exception model before touching page behavior.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Encode the Spec 192 surface inventory, classifications, and explicit `ViewTenant` exception metadata in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
- [X] T005 [P] Extend the record-page header-discipline validation rules for standard pages and the workflow-heavy special type in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
- [X] T006 [P] Add foundational guard assertions for classified surfaces, explicit exceptions, and compliant references in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`, and `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`
**Checkpoint**: The repo can fail CI when a standard record page regresses into multiple competing primaries or when a special-type exception is left undocumented.
---
## Phase 3: User Story 1 - See One Next Step On Standard Record Pages (Priority: P1) 🎯 MVP
**Goal**: Standard record/detail/edit pages expose one clear visible next step instead of a flat row of competing peer actions.
**Independent Test**: Open each remediation-required standard page and verify that it renders at most one visible primary header action while preserving existing authorization and action semantics.
### Tests for User Story 1
- [X] T007 [P] [US1] Extend state-sensitive primary-action assertions for baseline profile detail in `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
- [X] T008 [P] [US1] Add one-primary-action assertions for evidence snapshot and finding exception detail in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`
- [X] T009 [P] [US1] Add one-primary-action assertions for tenant review and tenant edit surfaces in `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Refactor state-sensitive primary-action selection and grouped secondaries in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T011 [US1] Refactor evidence snapshot header hierarchy so only one visible next-step action remains in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
- [X] T012 [US1] Refactor finding exception header hierarchy so renewal can be primary and revocation stays separated in `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- [X] T013 [US1] Refactor tenant review header hierarchy to promote only one lifecycle primary action in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
- [X] T014 [US1] Refactor the tenant edit header so it stops competing with the form primary affordance in `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
- [X] T015 [US1] Run focused primary-action verification in `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
**Checkpoint**: The five remediation-required standard pages expose no more than one visible primary header action.
---
## Phase 4: User Story 2 - Follow Contextual Navigation Near The Relevant Content (Priority: P1)
**Goal**: Pure navigation leaves the flat primary header lane and appears nearer to the summary, related context, or grouped secondary actions it belongs to.
**Independent Test**: Open remediated pages with related destinations and verify that navigation is no longer presented as an equal-weight primary peer to the main mutation.
### Tests for User Story 2
- [X] T016 [P] [US2] Add contextual-navigation assertions for baseline profile and evidence snapshot detail in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
- [X] T017 [P] [US2] Add contextual-navigation assertions for finding exception, tenant review, and tenant edit surfaces in `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
### Implementation for User Story 2
- [X] T018 [US2] Move active snapshot and compare-matrix navigation into contextual baseline-profile sections in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T019 [US2] Move operation and review-pack navigation into evidence snapshot summary or related-context sections in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
- [X] T020 [US2] Move finding and approval-queue navigation into finding-exception related-context placement in `apps/platform/app/Filament/Resources/FindingExceptionResource.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- [X] T021 [US2] Move operation, executive-pack, and evidence navigation into tenant-review contextual summary surfaces in `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php`
- [X] T022 [US2] Move tenant edit view and onboarding links into contextual tenant-meta placement outside the header in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
- [X] T023 [US2] Run focused contextual-navigation verification in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
**Checkpoint**: Related navigation has left the flat primary header lane on the remediated standard pages.
---
## Phase 5: User Story 3 - Keep Rare And Dangerous Actions Available Without Clutter (Priority: P2)
**Goal**: Rare administrative actions and destructive or governance-sensitive actions remain available without visually competing with routine work.
**Independent Test**: Open pages with grouped secondaries or danger actions and verify that rare actions live in grouped structures while danger stays visibly separated and confirmation-gated.
### Tests for User Story 3
- [X] T024 [P] [US3] Extend grouped-secondary and danger-separation assertions for baseline profile, tenant review, and tenant edit in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`, and `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`
- [X] T025 [P] [US3] Add grouped-secondary and danger assertions for evidence snapshot, finding exception, provider connection, and finding detail in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, `apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php`, `apps/platform/tests/Feature/Filament/FindingViewRbacEvidenceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
### Implementation for User Story 3
- [X] T026 [US3] Group non-primary baseline profile actions into deliberate secondary and admin buckets in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T027 [US3] Separate lifecycle danger from safe secondaries on evidence snapshot and finding exception detail in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
- [X] T028 [US3] Group tenant review lifecycle and export actions while keeping archive in a danger bucket in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
- [X] T029 [US3] Keep tenant edit lifecycle actions secondary and aligned with tenant lifecycle naming in `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php` and `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T030 [US3] Audit provider connection and finding detail headers for real minor-alignment issues in `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` and `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and only apply cleanup if the audit proves current header noise
- [X] T031 [US3] Run focused grouped-secondary and danger verification in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, `apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php`, `apps/platform/tests/Feature/Filament/FindingViewRbacEvidenceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
**Checkpoint**: Rare and dangerous actions remain available, but no longer clutter standard record-page headers.
---
## Phase 6: User Story 4 - Treat Workflow-Heavy Pages As Explicit Exceptions (Priority: P3)
**Goal**: Workflow-heavy pages remain disciplined through explicit exception handling instead of silent non-conformance.
**Independent Test**: Review `ViewTenant` and verify that it is explicitly marked as a special type, internally ordered, and not silently exempted from the record-page rule.
### Tests for User Story 4
- [X] T032 [P] [US4] Extend workflow-heavy exception assertions for tenant detail in `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
- [X] T033 [P] [US4] Extend guard assertions so `ViewTenant` requires an explicit special-type reason in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 4
- [X] T034 [US4] Move pure tenant-detail navigation into contextual placement outside the header and reorder grouped header actions into explicit external-link, verification/setup, and lifecycle buckets in `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
- [X] T035 [US4] Encode the workflow-heavy special-type reason and max-primary-action rule for tenant detail in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` and `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
- [X] T036 [US4] Run focused special-type verification in `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
**Checkpoint**: The workflow-heavy exception is explicit, tested, and structurally disciplined.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Confirm compliant references, align operator copy, and run the focused verification pack.
- [X] T037 [P] Audit and explicitly confirm compliant reference pages remain no-op in `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php`, `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, `apps/platform/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php`, `apps/platform/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`, and `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`
- [X] T038 [P] Align `Verb + Object` labels and grouped-action copy in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
- [X] T039 [P] Add or update compliant-reference and no-regression assertions in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- [X] T040 [P] Extend `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php` with no-regression coverage for the compliant reference set named in Spec 192
- [X] T041 [P] Add explicit no-body-layout-expansion assertions for the remediated and reference views in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
- [X] T042 [P] Add explicit no-governance-friction-expansion assertions that regrouping preserves confirmation depth and action semantics in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, and `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`
- [X] T043 [P] Run the focused verification commands documented in `specs/192-record-header-discipline/quickstart.md`
- [X] T044 [P] Run formatting on touched Filament page and guard files from `apps/platform/` via `./vendor/bin/sail bin pint --dirty --format agent`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user-story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the MVP slice.
- **User Story 2 (Phase 4)**: Depends on Foundational completion. It is independently testable, but it touches several of the same page files as US1, so it is safer to land after US1 unless work is split carefully.
- **User Story 3 (Phase 5)**: Depends on Foundational completion. It reuses the same page files as US1 and US2, so it is also safest after the primary and contextual hierarchy work is stable.
- **User Story 4 (Phase 6)**: Depends on Foundational completion and should land after the standard-page rule is clear, because it documents the explicit exception to that rule.
- **Polish (Phase 7)**: Depends on all desired story phases being complete.
### User Story Dependencies
- **US1**: Independent after Phase 2 and should be delivered first as the MVP.
- **US2**: Independent after Phase 2 from a behavior standpoint, but shares files with US1 and should usually follow it in the same branch.
- **US3**: Independent after Phase 2 from a behavior standpoint, but depends on the stabilized header hierarchy from US1 and US2 for low-risk implementation.
- **US4**: Independent after Phase 2 and focused on the explicit special-type exception for tenant detail.
### Within Each User Story
- Story-level tests should be written and made to fail before the implementation tasks for that story.
- Page-level behavior changes should preserve current authorization, confirmation, notification, and audit semantics.
- Each story should be verified through its focused test files before moving on.
## Parallel Opportunities
- T002 and T003 can run in parallel during Setup.
- T005 and T006 can run in parallel once the inventory in T004 exists.
- Within each story, the test tasks marked `[P]` can run in parallel because they touch separate files.
- US1, US2, and US3 share several of the same page classes, so their implementation tasks are not good parallel candidates even though the stories are independently testable.
- US4 can proceed in parallel with late US3 polish if one contributor is focused only on `ViewTenant`, the guard layer, and the smoke suite.
---
## Parallel Example: User Story 1
```bash
# Launch the focused header-hierarchy tests together:
Task: "Extend state-sensitive primary-action assertions for baseline profile detail in apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php"
Task: "Add one-primary-action assertions for evidence snapshot and finding exception detail in apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php and apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php"
Task: "Add one-primary-action assertions for tenant review and tenant edit surfaces in apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php, apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php, and apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php"
```
---
## Parallel Example: User Story 4
```bash
# Split the explicit exception work across tests and implementation:
Task: "Extend workflow-heavy exception assertions for tenant detail in apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php, apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php, and apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php"
Task: "Move pure tenant-detail navigation into contextual placement outside the header and reorder grouped header actions into explicit external-link, verification/setup, and lifecycle buckets in apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. **STOP and VALIDATE**: Confirm the five remediation-required standard pages expose one clear next step.
### Incremental Delivery
1. Deliver US1 to remove competing primary actions.
2. Deliver US2 to move navigation closer to the content it belongs to.
3. Deliver US3 to calm rare and dangerous actions without hiding them.
4. Deliver US4 to make the workflow-heavy tenant detail exception explicit and disciplined.
5. Finish with compliant-reference confirmation, vocabulary cleanup, validation, and formatting.
### Validation Rule
1. Do not mark a story complete until its focused verification task passes.
2. Preserve existing `404` vs `403` behavior, confirmation requirements, and `OperationRun` semantics throughout implementation.
3. Treat the compliant-reference set as a regression baseline, not as a cosmetic rewrite target.
---
## Notes
- `[P]` tasks touch different files and can be executed in parallel.
- User-story labels map directly to the prioritized stories in `spec.md`.
- This feature deliberately prefers existing Filament action builders and guard infrastructure over introducing a new header-action framework.