From 1212412db6ec4147ab62551c8a09b071a0d37793 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 29 Mar 2026 11:30:45 +0200 Subject: [PATCH 1/6] refactor: finish post-wave-1 singleton page cleanup Enroll InventoryCoverage, Monitoring Operations, NoAccess, TenantlessOperationRunViewer, TenantDiagnostics, and TenantRequiredPermissions in the action surface contract. Keep Monitoring Alerts explicitly exempt because /admin/alerts resolves through the alerts cluster entry rather than the page-class route. --- app/Filament/Pages/InventoryCoverage.php | 18 ++++++++++ app/Filament/Pages/Monitoring/Operations.php | 19 +++++++++++ app/Filament/Pages/NoAccess.php | 13 ++++++++ .../TenantlessOperationRunViewer.php | 18 ++++++++++ app/Filament/Pages/TenantDiagnostics.php | 13 ++++++++ .../Pages/TenantRequiredPermissions.php | 13 ++++++++ .../ActionSurface/ActionSurfaceExemptions.php | 8 +---- .../Guards/ActionSurfaceContractTest.php | 33 +++++++++++++++++-- 8 files changed, 126 insertions(+), 9 deletions(-) diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php index 7a733ad8..f5902764 100644 --- a/app/Filament/Pages/InventoryCoverage.php +++ b/app/Filament/Pages/InventoryCoverage.php @@ -19,6 +19,10 @@ use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use App\Support\Inventory\InventoryPolicyTypeMeta; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use BackedEnum; use Filament\Actions\Action; use Filament\Facades\Filament; @@ -52,6 +56,20 @@ class InventoryCoverage extends Page implements HasTable protected string $view = 'filament.pages.inventory-coverage'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->withDefaults(new ActionSurfaceDefaults( + moreGroupLabel: 'More', + exportIsDefaultBulkActionForReadOnly: false, + )) + ->exempt(ActionSurfaceSlot::ListHeader, 'Inventory coverage stays read-only and uses KPI widgets instead of header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.'); + } + public static function shouldRegisterNavigation(): bool { if (Filament::getCurrentPanel()?->getId() === 'admin') { diff --git a/app/Filament/Pages/Monitoring/Operations.php b/app/Filament/Pages/Monitoring/Operations.php index a49c7b7f..adb56627 100644 --- a/app/Filament/Pages/Monitoring/Operations.php +++ b/app/Filament/Pages/Monitoring/Operations.php @@ -14,6 +14,11 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Operations\OperationLifecyclePolicy; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions\Action; @@ -50,6 +55,20 @@ class Operations extends Page implements HasForms, HasTable // Must be non-static protected string $view = 'filament.pages.monitoring.operations'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->withDefaults(new ActionSurfaceDefaults( + moreGroupLabel: 'More', + exportIsDefaultBulkActionForReadOnly: false, + )) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve scope context and return navigation for the monitoring operations list.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Operation runs remain immutable on the monitoring list and intentionally omit bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no operation runs exist for the active workspace scope.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.'); + } + public function mount(): void { $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; diff --git a/app/Filament/Pages/NoAccess.php b/app/Filament/Pages/NoAccess.php index 02c2e64e..09389ff9 100644 --- a/app/Filament/Pages/NoAccess.php +++ b/app/Filament/Pages/NoAccess.php @@ -7,6 +7,9 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Forms\Components\TextInput; @@ -27,6 +30,16 @@ class NoAccess extends Page protected string $view = 'filament.pages.no-access'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header provides a create-workspace recovery action when the user has no tenant access yet.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'The no-access page is a singleton recovery surface without record-level inspect affordances.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The no-access page does not render row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The no-access page does not expose bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a dedicated recovery message instead of a list-style empty state.'); + } + /** * @return array */ diff --git a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index a1d88815..334a0b6f 100644 --- a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -25,6 +25,10 @@ use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantOperabilityQuestion; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -47,6 +51,20 @@ class TenantlessOperationRunViewer extends Page protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->withDefaults(new ActionSurfaceDefaults( + moreGroupLabel: 'More', + exportIsDefaultBulkActionForReadOnly: false, + )) + ->exempt(ActionSurfaceSlot::ListHeader, 'Canonical tenantless run viewing is a detail-only page and does not render list header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'The tenantless run viewer is itself the canonical detail destination for a selected operation run.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Detail viewing does not expose bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a selected run detail instead of a list empty state.') + ->satisfy(ActionSurfaceSlot::DetailHeader, 'Header keeps scope context, back navigation, refresh, related links, and resumable capture actions when applicable.'); + } + public OperationRun $run; /** diff --git a/app/Filament/Pages/TenantDiagnostics.php b/app/Filament/Pages/TenantDiagnostics.php index e412f54f..1ce207ef 100644 --- a/app/Filament/Pages/TenantDiagnostics.php +++ b/app/Filament/Pages/TenantDiagnostics.php @@ -12,6 +12,9 @@ use App\Support\Auth\Capabilities; use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiTooltips; +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\Pages\Page; @@ -25,6 +28,16 @@ class TenantDiagnostics extends Page protected string $view = 'filament.pages.tenant-diagnostics'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.'); + } + public bool $missingOwner = false; public bool $hasDuplicateMembershipsForCurrentUser = false; diff --git a/app/Filament/Pages/TenantRequiredPermissions.php b/app/Filament/Pages/TenantRequiredPermissions.php index f9270096..91ce2f4e 100644 --- a/app/Filament/Pages/TenantRequiredPermissions.php +++ b/app/Filament/Pages/TenantRequiredPermissions.php @@ -10,6 +10,9 @@ use App\Models\User; use App\Models\WorkspaceMembership; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use Filament\Pages\Page; use Livewire\Attributes\Locked; @@ -27,6 +30,16 @@ class TenantRequiredPermissions extends Page protected string $view = 'filament.pages.tenant-required-permissions'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->exempt(ActionSurfaceSlot::ListHeader, 'Required permissions keeps guidance, copy flows, and filter reset actions inside body sections instead of page header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Required permissions rows are reviewed inline inside the diagnostic matrix and do not open a separate inspect destination.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Permission rows are read-only and do not expose row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Required permissions does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.'); + } + public string $status = 'missing'; public string $type = 'all'; diff --git a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index c8cd60f5..b45d2834 100644 --- a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -23,15 +23,9 @@ public static function baseline(): self 'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.', 'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.', 'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.', - 'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage intentionally omits inspect affordances because rows are runtime-derived metadata; spec 124 requires search, sort, filters, and a resettable empty state instead.', - 'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.', - 'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.', - 'App\\Filament\\Pages\\NoAccess' => 'No-access page has no actionable surface by design.', - 'App\\Filament\\Pages\\Operations\\TenantlessOperationRunViewer' => 'Tenantless run viewer retrofit deferred; run-link semantics are covered by monitoring tests.', + 'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts remains exempt because the active admin alerts surface resolves through the cluster entry at /admin/alerts, not this page-class route.', 'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.', 'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.', - 'App\\Filament\\Pages\\TenantDiagnostics' => 'Diagnostics page retrofit deferred to tenant-RBAC diagnostics spec.', - 'App\\Filament\\Pages\\TenantRequiredPermissions' => 'Permissions page retrofit deferred; capability checks already enforced by dedicated tests.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.', 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index 47992ff3..b6e3fc72 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -161,6 +161,12 @@ $profiles = new ActionSurfaceProfileDefinition; $declarations = [ + \App\Filament\Pages\InventoryCoverage::class => \App\Filament\Pages\InventoryCoverage::actionSurfaceDeclaration(), + \App\Filament\Pages\Monitoring\Operations::class => \App\Filament\Pages\Monitoring\Operations::actionSurfaceDeclaration(), + \App\Filament\Pages\NoAccess::class => \App\Filament\Pages\NoAccess::actionSurfaceDeclaration(), + \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class => \App\Filament\Pages\Operations\TenantlessOperationRunViewer::actionSurfaceDeclaration(), + \App\Filament\Pages\TenantDiagnostics::class => \App\Filament\Pages\TenantDiagnostics::actionSurfaceDeclaration(), + \App\Filament\Pages\TenantRequiredPermissions::class => \App\Filament\Pages\TenantRequiredPermissions::actionSurfaceDeclaration(), AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(), BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(), BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(), @@ -234,8 +240,12 @@ it('keeps first-slice trusted-state page action-surface status explicit', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); - expect($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeTrue() - ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toContain('dedicated tests'); + expect(method_exists(\App\Filament\Pages\TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue() + ->and($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeFalse(); + + expect(method_exists(\App\Filament\Pages\Monitoring\Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse() + ->and($baselineExemptions->hasClass(\App\Filament\Pages\Monitoring\Alerts::class))->toBeTrue() + ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Monitoring\Alerts::class))->toContain('cluster entry'); expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue() ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests'); @@ -244,6 +254,25 @@ ->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse(); }); +it('keeps cleanup-slice pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + \App\Filament\Pages\InventoryCoverage::class, + \App\Filament\Pages\Monitoring\Operations::class, + \App\Filament\Pages\NoAccess::class, + \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class, + \App\Filament\Pages\TenantDiagnostics::class, + \App\Filament\Pages\TenantRequiredPermissions::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); -- 2.45.2 From 62c9b97d00b9969e271241ccfc0563e374e497a0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 29 Mar 2026 23:11:24 +0200 Subject: [PATCH 2/6] docs: amend operator UI constitution --- .specify/memory/constitution.md | 474 ++++++++++++++++------ .specify/templates/plan-template.md | 7 +- .specify/templates/spec-template.md | 26 +- .specify/templates/tasks-template.md | 13 +- docs/HANDOVER.md | 6 +- docs/product/principles.md | 27 +- docs/product/standards/README.md | 4 +- specs/082-action-surface-contract/spec.md | 11 +- 8 files changed, 422 insertions(+), 146 deletions(-) diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 8338df7b..ddd66d0b 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,32 +1,32 @@ diff --git a/specs/166-finding-governance-health/spec.md b/specs/166-finding-governance-health/spec.md index d0ebe8d8..6d7545c4 100644 --- a/specs/166-finding-governance-health/spec.md +++ b/specs/166-finding-governance-health/spec.md @@ -238,9 +238,9 @@ ## Dependencies ## Follow-up Spec Candidates -- **Spec 167 — Finding Resolution Origin & Workflow Truth Foundation** if the current data model cannot stably distinguish workflow resolution from no-longer-observed truth on all required surfaces -- **Spec 168 — Exception Expiry Alerts & Governance Notifications** for proactive alerting around expiring or expired governance -- **Spec 169 — Finding Detail Workflow History & Reopen Context** for deeper recurrence, prior-resolution, and historical workflow storytelling +- **Finding Resolution Origin & Workflow Truth Foundation** if the current data model cannot stably distinguish workflow resolution from no-longer-observed truth on all required surfaces +- **Exception Expiry Alerts & Governance Notifications** for proactive alerting around expiring or expired governance +- **Finding Detail Workflow History & Reopen Context** for deeper recurrence, prior-resolution, and historical workflow storytelling ## Definition of Done diff --git a/specs/168-tenant-governance-aggregate-contract/checklists/requirements.md b/specs/168-tenant-governance-aggregate-contract/checklists/requirements.md new file mode 100644 index 00000000..feb1ac65 --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Tenant Governance Aggregate Contract + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-28 +**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 passed on first draft. The spec introduces one narrow derived aggregate contract, explicitly avoids new persistence and new state families, and keeps the work bounded to existing tenant-governance summary surfaces. \ No newline at end of file diff --git a/specs/168-tenant-governance-aggregate-contract/contracts/tenant-governance-aggregate.openapi.yaml b/specs/168-tenant-governance-aggregate-contract/contracts/tenant-governance-aggregate.openapi.yaml new file mode 100644 index 00000000..cfa925da --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/contracts/tenant-governance-aggregate.openapi.yaml @@ -0,0 +1,335 @@ +openapi: 3.1.0 +info: + title: Tenant Governance Aggregate Internal Surface Contract + version: 0.1.0 + summary: Internal logical contract for the shared tenant-governance summary aggregate + description: | + This contract is an internal planning artifact for Spec 168. It documents the + derived tenant-governance aggregate and the way tenant dashboard, banner, and + Baseline Compare landing surfaces consume it. The rendered routes still return + HTML. The structured schemas below describe the internal page and widget models + that must be derivable before rendering. This does not add a public HTTP API. +servers: + - url: /internal +x-governance-consumers: + - surface: tenant.dashboard.needs_attention + summarySource: tenant_governance_aggregate + accessPattern: widget_safe + guardScope: + - app/Filament/Widgets/Dashboard/NeedsAttention.php + requiredMarkers: + - 'TenantGovernanceAggregate' + - 'Needs Attention' + maxOccurrences: + - needle: 'Finding::query()' + max: 0 + - surface: tenant.dashboard.baseline_governance + summarySource: tenant_governance_aggregate + accessPattern: widget_safe + guardScope: + - app/Filament/Widgets/Dashboard/BaselineCompareNow.php + requiredMarkers: + - 'TenantGovernanceAggregate' + - 'Baseline Governance' + - surface: tenant.banner.baseline_compare_coverage + summarySource: tenant_governance_aggregate + accessPattern: widget_safe + guardScope: + - app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php + requiredMarkers: + - 'TenantGovernanceAggregate' + - 'nextActionUrl' + - surface: tenant.page.baseline_compare_landing + summarySource: tenant_governance_aggregate + accessPattern: page_safe + guardScope: + - app/Filament/Pages/BaselineCompareLanding.php + requiredMarkers: + - 'TenantGovernanceAggregate' + - 'Compare now' +paths: + /tenants/{tenant}/governance-aggregate: + get: + summary: Resolve the derived tenant-governance aggregate for one tenant + operationId: resolveTenantGovernanceAggregate + parameters: + - name: tenant + in: path + required: true + schema: + type: string + description: Tenant route key or canonical tenant identifier for the current tenant scope. + - name: surface + in: query + required: false + schema: + $ref: '#/components/schemas/GovernanceSurface' + responses: + '200': + description: Aggregate resolved for the current tenant scope + content: + application/vnd.tenantpilot.tenant-governance-aggregate+json: + schema: + $ref: '#/components/schemas/TenantGovernanceAggregate' + '403': + description: Actor is in scope but lacks the capability required to view the target surface + '404': + description: Tenant is outside workspace or tenant entitlement scope + /admin/t/{tenant}: + get: + summary: Tenant dashboard governance summary surfaces + operationId: viewTenantDashboardGovernance + parameters: + - name: tenant + in: path + required: true + schema: + type: string + responses: + '200': + description: Rendered tenant dashboard with shared governance summary surfaces + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.tenant-dashboard-governance+json: + schema: + $ref: '#/components/schemas/TenantDashboardGovernanceBundle' + '403': + description: Actor is in scope but lacks the required tenant capability + '404': + description: Tenant is not visible in the current workspace or membership scope + /admin/t/{tenant}/baseline-compare-landing: + get: + summary: Baseline Compare landing governance summary and diagnostics split + operationId: viewBaselineCompareLandingGovernance + parameters: + - name: tenant + in: path + required: true + schema: + type: string + responses: + '200': + description: Rendered Baseline Compare landing page + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.baseline-compare-landing-governance+json: + schema: + $ref: '#/components/schemas/BaselineCompareLandingGovernanceView' + '403': + description: Actor is in scope but lacks the required tenant capability + '404': + description: Tenant is not visible in the current workspace or membership scope +components: + schemas: + GovernanceSurface: + type: string + enum: + - dashboard_needs_attention + - dashboard_baseline_governance + - baseline_compare_landing + - coverage_banner + NextActionTarget: + type: string + enum: + - findings + - run + - landing + - none + GovernanceCounts: + type: object + additionalProperties: false + required: + - visibleDriftFindingsCount + - overdueOpenFindingsCount + - expiringGovernanceCount + - lapsedGovernanceCount + - activeNonNewFindingsCount + - highSeverityActiveFindingsCount + properties: + visibleDriftFindingsCount: + type: integer + minimum: 0 + overdueOpenFindingsCount: + type: integer + minimum: 0 + expiringGovernanceCount: + type: integer + minimum: 0 + lapsedGovernanceCount: + type: integer + minimum: 0 + activeNonNewFindingsCount: + type: integer + minimum: 0 + highSeverityActiveFindingsCount: + type: integer + minimum: 0 + NextActionIntent: + type: object + additionalProperties: false + required: + - label + - target + properties: + label: + type: string + target: + $ref: '#/components/schemas/NextActionTarget' + ComparePosture: + type: object + additionalProperties: false + required: + - compareState + - stateFamily + - tone + - headline + - positiveClaimAllowed + properties: + compareState: + type: string + enum: + - no_tenant + - no_assignment + - no_snapshot + - idle + - comparing + - failed + - ready + stateFamily: + type: string + enum: + - positive + - caution + - stale + - action_required + - in_progress + - unavailable + tone: + type: string + enum: + - success + - warning + - danger + - info + - gray + headline: + type: string + supportingMessage: + type: + - string + - 'null' + reasonCode: + type: + - string + - 'null' + lastComparedLabel: + type: + - string + - 'null' + positiveClaimAllowed: + type: boolean + TenantGovernanceAggregate: + type: object + additionalProperties: false + required: + - tenantId + - workspaceId + - posture + - counts + - nextAction + properties: + tenantId: + type: integer + workspaceId: + type: integer + profileName: + type: + - string + - 'null' + posture: + $ref: '#/components/schemas/ComparePosture' + counts: + $ref: '#/components/schemas/GovernanceCounts' + nextAction: + $ref: '#/components/schemas/NextActionIntent' + operationRunId: + type: + - integer + - 'null' + diagnosticsOwner: + type: string + enum: + - baseline_compare_stats + NeedsAttentionItem: + type: object + additionalProperties: false + required: + - title + - badge + - badgeColor + properties: + title: + type: string + body: + type: + - string + - 'null' + supportingMessage: + type: + - string + - 'null' + badge: + type: string + badgeColor: + type: string + nextStep: + type: + - string + - 'null' + TenantDashboardGovernanceBundle: + type: object + additionalProperties: false + required: + - aggregate + properties: + aggregate: + $ref: '#/components/schemas/TenantGovernanceAggregate' + needsAttentionItems: + type: array + items: + $ref: '#/components/schemas/NeedsAttentionItem' + baselineGovernanceCard: + type: object + additionalProperties: false + required: + - aggregate + properties: + aggregate: + $ref: '#/components/schemas/TenantGovernanceAggregate' + BaselineCompareLandingGovernanceView: + type: object + additionalProperties: false + required: + - aggregate + - diagnosticsPolicy + properties: + aggregate: + $ref: '#/components/schemas/TenantGovernanceAggregate' + diagnosticsPolicy: + type: object + additionalProperties: false + required: + - compareDiagnosticsRemainLocal + - evidenceGapDetailsRemainLocal + - compareActionUnchanged + properties: + compareDiagnosticsRemainLocal: + type: boolean + evidenceGapDetailsRemainLocal: + type: boolean + compareActionUnchanged: + type: boolean \ No newline at end of file diff --git a/specs/168-tenant-governance-aggregate-contract/data-model.md b/specs/168-tenant-governance-aggregate-contract/data-model.md new file mode 100644 index 00000000..89f27e49 --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/data-model.md @@ -0,0 +1,213 @@ +# Phase 1 Data Model: Tenant Governance Aggregate Contract + +## Overview + +This feature does not add a database table or persisted summary artifact. It formalizes the existing persistent source truths that already drive tenant governance posture and adds one derived runtime contract plus request-scoped reuse rules for the shared summary family. + +## Persistent Source Truths + +### Tenant + +**Purpose**: The tenant is the scope boundary for the aggregate and for every covered surface. + +**Key fields**: +- `id` +- `workspace_id` +- `external_id` + +**Validation rules**: +- Aggregate resolution is allowed only for one explicit tenant scope at a time. +- Workspace membership and tenant entitlement remain authoritative before any summary surface renders. + +### BaselineTenantAssignment and BaselineProfile + +**Purpose**: Define whether the tenant has an assigned baseline and which profile and snapshot chain determine compare availability. + +**Key fields**: +- `baseline_tenant_assignments.tenant_id` +- `baseline_tenant_assignments.baseline_profile_id` +- `baseline_profiles.id` +- `baseline_profiles.name` +- `baseline_profiles.active_snapshot_id` + +**Validation rules**: +- Missing assignment and missing snapshot remain derived availability states, not new persisted governance states. + +### BaselineSnapshot + +**Purpose**: Supplies consumable compare-snapshot truth for the assigned baseline profile. + +**Key fields**: +- `id` +- `baseline_profile_id` +- lifecycle/completion fields already used by `BaselineSnapshotTruthResolver` + +**Validation rules**: +- Snapshot usability remains governed by existing compare truth logic. +- The aggregate must not invent a second snapshot-availability rule set. + +### OperationRun + +**Purpose**: Supplies baseline-compare progress, completion, failure, and freshness context. + +**Key fields**: +- `id` +- `tenant_id` +- `workspace_id` +- `type` +- `status` +- `outcome` +- `completed_at` +- `context` + +**Validation rules**: +- Only existing baseline-compare runs influence compare posture in this slice. +- The aggregate does not introduce a new run type or a second operational state model. + +### Finding and FindingException + +**Purpose**: Supply overdue workflow state, visible drift pressure, and accepted-risk governance validity. + +**Key fields**: +- `findings.tenant_id` +- `findings.status` +- `findings.severity` +- `findings.due_at` +- `finding_exceptions.current_validity_state` + +**Validation rules**: +- Overdue, expiring, lapsed, active-non-new, and high-severity-active counts remain derived from current findings truth. +- The aggregate must not redefine accepted-risk validity semantics. + +## Existing Runtime Source Objects + +### BaselineCompareStats + +**Purpose**: Existing query-backed compare truth object that already combines compare availability, diagnostics, and governance-attention counts for one tenant. + +**Key fields consumed by this feature**: +- `state` +- `profileName` +- `operationRunId` +- `findingsCount` +- `lastComparedHuman` +- `lastComparedIso` +- `reasonCode` +- `overdueOpenFindingsCount` +- `expiringGovernanceCount` +- `lapsedGovernanceCount` +- `activeNonNewFindingsCount` +- `highSeverityActiveFindingsCount` + +**Relationship to the new aggregate**: +- The aggregate is built from one `BaselineCompareStats` resolution. +- Landing diagnostics continue to read `BaselineCompareStats` directly. + +### BaselineCompareSummaryAssessment + +**Purpose**: Existing summary interpretation object that maps `BaselineCompareStats` to posture family, tone, headline, supporting message, and next-action target. + +**Key fields consumed by this feature**: +- `stateFamily` +- `headline` +- `supportingMessage` +- `tone` +- `positiveClaimAllowed` +- `reasonCode` +- `nextActionTarget()` +- `nextActionLabel()` + +**Relationship to the new aggregate**: +- The aggregate uses the summary assessment as its summary-semantics input. +- The feature must not fork a second state-family interpretation path. + +## New Derived Runtime Entities + +### TenantGovernanceAggregate + +**Purpose**: One tenant-scoped operator summary contract that owns the shared posture, count family, and next-action intent for tenant-governance summary surfaces. + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `tenant_id` | int | yes | Tenant scope for the aggregate | +| `workspace_id` | int | yes | Workspace scope for request-local reuse safety | +| `profile_name` | string nullable | no | Assigned baseline profile name when available | +| `compare_state` | string | yes | Existing compare availability or execution state from `BaselineCompareStats` | +| `state_family` | string | yes | Existing summary posture family from `BaselineCompareSummaryAssessment` | +| `tone` | string | yes | Existing tone family used by covered summary surfaces | +| `headline` | string | yes | Operator-facing summary headline | +| `supporting_message` | string nullable | no | Secondary operator-facing explanation | +| `reason_code` | string nullable | no | Summary reason code when available | +| `last_compared_label` | string nullable | no | Human-readable freshness label | +| `visible_drift_findings_count` | int | yes | Visible drift findings count from compare stats | +| `overdue_open_findings_count` | int | yes | Overdue open findings count | +| `expiring_governance_count` | int | yes | Accepted-risk governance nearing expiry | +| `lapsed_governance_count` | int | yes | Accepted-risk governance no longer valid | +| `active_non_new_findings_count` | int | yes | Active non-new findings pressure | +| `high_severity_active_findings_count` | int | yes | High-severity active findings pressure | +| `next_action_label` | string | yes | Stable operator-facing next-step label | +| `next_action_target` | enum(`findings`,`run`,`landing`,`none`) | yes | Stable next-action intent; surfaces map this to local URLs | +| `positive_claim_allowed` | bool | yes | Whether the current summary posture qualifies as a trustworthy all-clear | +| `stats` | `BaselineCompareStats` | yes | Embedded source truth used when a consumer also needs compare diagnostics | +| `summary_assessment` | `BaselineCompareSummaryAssessment` | yes | Embedded summary truth used by all covered surfaces | + +#### Validation rules + +- The aggregate must be built from exactly one `BaselineCompareStats` instance and exactly one `BaselineCompareSummaryAssessment` derived from that stats instance. +- Count fields must be copied from the same stats instance; surfaces must not recompute them locally. +- Final URLs, local badges, and layout-specific copy remain outside the aggregate. + +### TenantGovernanceAggregateResolver + +**Purpose**: Service seam that resolves one `TenantGovernanceAggregate` per tenant scope and handles request-local reuse. + +#### Fields / Inputs + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `tenant` | `Tenant` nullable | yes | Target tenant; nullable only for explicit no-tenant handling paths | +| `workspace_id` | int nullable | no | Optional explicit scope input for guardable key composition | +| `surface_variant` | string | yes | Stable consumer variant used when request-local reuse or guard declarations need surface-specific context | + +#### Validation rules + +- The resolver must stay derived-only. +- Reuse must be request-local only. +- A no-tenant or wrong-tenant path must never reuse a previous tenant’s aggregate. + +### DerivedStateFamily::TenantGovernanceAggregate + +**Purpose**: Extends the existing Spec 167 request-scoped derived-state contract so the aggregate follows the same consumer-declaration and guard rules as other supported deterministic families. + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `family` | enum value | yes | Stable family identifier for request-local aggregate reuse | +| `default_freshness_policy` | enum | yes | Expected to remain `invalidate_after_mutation` for landing refresh paths | +| `allows_negative_result_cache` | bool | yes | Aggregate resolution should be reusable for deterministic no-tenant or unavailable states only if the final key contract explicitly supports it | + +## Consumer Mapping + +| Consumer | Aggregate responsibility | Local responsibility | +|---|---|---| +| `NeedsAttention` | Overdue, expiring, lapsed, high-severity counts; baseline posture headline; next-action intent | Operations-in-progress count and widget-specific healthy fallback layout | +| `BaselineCompareNow` | Baseline posture family, headline, supporting message, next-action intent | Tenant-panel URL mapping for findings, run detail, and landing drill-down | +| `BaselineCompareCoverageBanner` | Banner visibility posture family, tone, headline, supporting message, next-action intent | Banner-specific show/hide threshold and local URL mapping | +| `BaselineCompareLanding` | Default-visible posture zone and next-action intent | Compare diagnostics, evidence-gap detail, duplicate-name detail, and existing `Compare now` action | + +## Derived State Lifecycle + +1. A covered tenant summary surface asks the resolver for the current tenant aggregate. +2. The resolver resolves or reuses one request-scoped aggregate for that tenant scope. +3. Covered surfaces read the same posture family, count family, and next-action intent from the shared aggregate. +4. If an in-request mutation or refresh path makes the current aggregate stale, the resolver uses explicit invalidation or fresh-resolution semantics. +5. The next request builds a new aggregate from current source truth. + +## Migration Notes + +- No schema migration is required. +- Existing `BaselineCompareStats` tests remain the low-level source-truth tests. +- If the implementation adds a new derived-state family, it must also extend the Spec 167 consumer-declaration contract and guard test. \ No newline at end of file diff --git a/specs/168-tenant-governance-aggregate-contract/plan.md b/specs/168-tenant-governance-aggregate-contract/plan.md new file mode 100644 index 00000000..a26e3f50 --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/plan.md @@ -0,0 +1,260 @@ +# Implementation Plan: Tenant Governance Aggregate Contract + +**Branch**: `168-tenant-governance-aggregate-contract` | **Date**: 2026-03-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce one explicit, derived, tenant-scoped governance aggregate that turns the existing compare posture and governance-attention counts into a single cross-surface contract. The first implementation slice will build the aggregate from `BaselineCompareStats` and its summary assessment, reuse the request-scoped derived-state infrastructure added in Spec 167, and align the tenant dashboard `Baseline Governance` card, tenant governance banner, and Baseline Compare landing around the same posture family and next-action intent while preserving existing landing action semantics and diagnostics hierarchy. A follow-on slice then moves `NeedsAttention` off its local findings-count ownership so the tenant dashboard can render both governance summary cards from one request-local contract without adding persistence or new mutation behavior. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167 +**Storage**: PostgreSQL unchanged; no new persistence, cache store, or durable summary artifact +**Testing**: Pest 4 unit and feature tests, including Livewire component coverage and derived-state guard tests, run through Laravel Sail +**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production +**Project Type**: web application +**Performance Goals**: One governance-aggregate resolution per request for the same tenant and summary scope; no duplicate aggregate-owned findings queries across the tenant dashboard render; covered summary surfaces keep DB-only render behavior and reuse one stable posture family +**Constraints**: Derived-only implementation, no new Graph calls, no cross-request cache, no new mutation surfaces, existing `Compare now` confirmation and authorization remain unchanged, diagnostics stay secondary, and no cross-tenant or cross-workspace summary leakage is allowed +**Scale/Scope**: One tenant at a time, with phased adoption across four tenant-facing summary consumers: MVP parity for `BaselineCompareNow`, `BaselineCompareCoverageBanner`, and `BaselineCompareLanding`, followed by `NeedsAttention` adoption for multi-card request stability + +## 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 aggregate is derived from current findings, exception validity, baseline assignment, snapshot availability, and compare-run truth; no new source-of-truth path is introduced. | +| Read/write separation | PASS | PASS | The feature changes read-time summary ownership only. Existing `Compare now` remains the only mutation-adjacent action and stays unchanged. | +| Graph contract path | N/A | N/A | No Graph calls, provider contracts, or `config/graph_contracts.php` changes are required. | +| Deterministic capabilities | PASS | PASS | Authorization and drill-down capability enforcement remain existing server-side checks; the aggregate is read-only and tenant-scoped. | +| Workspace + tenant isolation | PASS | PASS | The aggregate is resolved for one tenant at a time and must never outlive the current request or tenant context. | +| RBAC-UX authorization semantics | PASS | PASS | No new permissions, no role-string checks, and no change to 404-for-non-members / 403-for-in-scope-capability-denial semantics. | +| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type or feedback path. Existing baseline-compare run visibility remains the operational source. | +| Data minimization | PASS | PASS | No persistence is added; reuse remains request-local only. | +| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One narrow runtime contract plus one resolver is justified because four surfaces already share the same summary family and `NeedsAttention` still re-queries data already owned elsewhere. | +| Persisted truth / behavioral state | PASS | PASS | No new tables, artifacts, reason-code families, or persisted statuses are introduced. | +| UI semantics / few layers | PASS | PASS | The aggregate stays below the existing surface layouts. It replaces split summary ownership instead of adding a second presentation framework. | +| Badge semantics (BADGE-001) | PASS | PASS | Existing summary tone and badge mapping stay authoritative; the aggregate supplies data, not ad hoc visual semantics. | +| Filament-native UI / Action Surface Contract | PASS | PASS | Widgets and landing page remain native Filament surfaces. No new row, bulk, or destructive actions are introduced. | +| Filament UX-001 | PASS | PASS | No create/edit/view layout redesign is proposed; only existing summary zones change data ownership. | +| Filament v5 / Livewire v4 compliance | PASS | PASS | The design remains inside the existing Filament v5 + Livewire v4 stack with no legacy API introduction. | +| Provider registration location | PASS | PASS | No panel or provider registration changes; Laravel 11+ registration remains in `bootstrap/providers.php`. | +| Global search hard rule | PASS | PASS | No globally searchable resource changes are proposed in this slice. | +| Destructive action safety | PASS | PASS | No new destructive actions are added. The existing Baseline Compare landing action remains `->action(...)->requiresConfirmation()` and capability-gated. | +| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment changes are required. | +| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds parity, memoization, and tenant-scope safety tests that protect operator-visible truth rather than thin adapters only. | + +## Phase 0 Research + +Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/research.md`. + +Key decisions: + +- Build the new aggregate from `BaselineCompareStats` and `BaselineCompareSummaryAssessment` instead of creating a second query-backed summary path. +- Introduce one narrow `TenantGovernanceAggregate` runtime contract so summary ownership is explicit without moving landing-page diagnostics into a bloated new object. +- Reuse the existing request-scoped derived-state infrastructure from Spec 167 rather than adding widget-local or resource-local caches. +- Keep next-action intent inside the aggregate but leave final URLs and panel-specific drill-down mapping local to each surface. +- Let `BaselineCompareLanding` keep `BaselineCompareStats` for deep diagnostics while switching its default-visible posture zone to the shared aggregate. +- Protect the feature with focused parity, memoization, and guard tests instead of ad hoc performance scripts. + +## Phase 1 Design + +Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/`: + +- `data-model.md`: persistent source truths plus the new derived tenant-governance runtime contract +- `contracts/tenant-governance-aggregate.openapi.yaml`: internal logical contract for resolving and consuming the aggregate on tenant summary surfaces +- `quickstart.md`: focused implementation and verification workflow + +Design decisions: + +- The shared contract is one derived runtime object, not a new persisted summary or reporting subsystem. +- `BaselineCompareStats` remains the heavy query-backed source for compare posture, findings counts, and landing diagnostics. +- The aggregate owns shared summary semantics only: posture family, count family, and next-action intent. +- Request-local reuse will extend the Spec 167 derived-state contract instead of introducing local static caches in widgets or pages. +- Consumer surfaces will adopt named helper seams so CI can guard against future reintroduction of local findings queries or direct duplicate aggregate resolution. + +## Project Structure + +### Documentation (this feature) + +```text +specs/168-tenant-governance-aggregate-contract/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── tenant-governance-aggregate.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ │ ├── BaselineCompareLanding.php +│ │ └── TenantDashboard.php +│ └── Widgets/ +│ ├── Dashboard/ +│ │ ├── BaselineCompareNow.php +│ │ └── NeedsAttention.php +│ └── Tenant/ +│ └── BaselineCompareCoverageBanner.php +├── Support/ +│ ├── Baselines/ +│ │ ├── BaselineCompareStats.php +│ │ ├── BaselineCompareSummaryAssessor.php +│ │ ├── BaselineCompareSummaryAssessment.php +│ │ ├── TenantGovernanceAggregate.php +│ │ └── TenantGovernanceAggregateResolver.php +│ └── Ui/ +│ └── DerivedState/ +│ └── DerivedStateFamily.php + +tests/ +├── Feature/ +│ ├── Baselines/ +│ │ ├── BaselineCompareStatsTest.php +│ │ ├── BaselineCompareSummaryAssessmentTest.php +│ │ └── TenantGovernanceAggregateResolverTest.php +│ ├── Filament/ +│ │ ├── BaselineCompareSummaryConsistencyTest.php +│ │ ├── BaselineCompareNowWidgetTest.php +│ │ ├── BaselineCompareCoverageBannerTest.php +│ │ ├── BaselineCompareLandingAdminTenantParityTest.php +│ │ ├── BaselineCompareLandingDuplicateNamesBannerTest.php +│ │ ├── BaselineCompareLandingRbacLabelsTest.php +│ │ ├── BaselineCompareLandingStartSurfaceTest.php +│ │ ├── BaselineCompareLandingWhyNoFindingsTest.php +│ │ ├── NeedsAttentionWidgetTest.php +│ │ └── TenantGovernanceAggregateMemoizationTest.php +│ └── Guards/ +│ └── DerivedStateConsumerAdoptionGuardTest.php +``` + +**Structure Decision**: Keep the existing Laravel monolith structure. Add one narrow runtime contract plus resolver under `app/Support/Baselines`, adopt it through the current Filament widgets and landing page, and extend the existing request-scoped derived-state infrastructure instead of creating new base directories or a broader presentation layer. + +## Implementation Strategy + +### Phase A — Introduce the Aggregate Contract and Resolver + +**Goal**: Add one explicit tenant-governance runtime object and one resolver that derives it from existing compare truth. + +| Step | File | Change | +|------|------|--------| +| A.1 | `app/Support/Baselines/TenantGovernanceAggregate.php` | Add the derived runtime contract that holds the shared summary posture, count family, and next-action intent for one tenant | +| A.2 | `app/Support/Baselines/TenantGovernanceAggregateResolver.php` | Add the resolver that derives the aggregate from `BaselineCompareStats` and `BaselineCompareSummaryAssessment` without adding persistence | +| A.3 | `app/Support/Baselines/BaselineCompareStats.php` | Expose the aggregate's count family from the existing source path and keep the landing diagnostics sourced from the same stats object | + +### Phase B — Reuse the Existing Derived-State Infrastructure + +**Goal**: Make repeated reads of the same tenant aggregate request-stable without adding ad hoc widget caches. + +| Step | File | Change | +|------|------|--------| +| B.1 | `app/Support/Ui/DerivedState/DerivedStateFamily.php` | Add a dedicated family for tenant-governance aggregate reuse, unless the final implementation can reuse the existing family contract without ambiguity | +| B.2 | `app/Support/Baselines/TenantGovernanceAggregateResolver.php` and `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml` | Route aggregate resolution through the existing request-scoped derived-state store and declare the supported first-slice consumer paths for the new family | +| B.3 | `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Extend the allowed-family and guarded-consumer validation so first-slice summary surfaces cannot fall back to local caches or direct duplicate findings queries | + +### Phase C — Adopt the First Shared Summary Surfaces + +**Goal**: Make the tenant dashboard governance card, landing page, and tenant banner consume one shared semantic source before the second dashboard summary surface joins the contract. + +| Step | File | Change | +|------|------|--------| +| C.1 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | Replace direct `BaselineCompareStats` summary reads with the aggregate while keeping local URL mapping for findings, run, and landing destinations | +| C.2 | `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` | Drive banner visibility, tone, and next-action intent from the same aggregate-backed posture family | +| C.3 | `app/Filament/Pages/BaselineCompareLanding.php` | Use the aggregate for the default-visible posture summary while preserving existing compare diagnostics, evidence-gap detail, and `Compare now` behavior | +| C.4 | `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php` | Preserve unchanged compare-start authorization and confirmation semantics while proving diagnostics remain secondary to the aggregate-owned posture zone | + +### Phase D — Align Next-Action Intent + +**Goal**: Make the first shared summary surfaces point operators toward the same class of next step for the same tenant state. + +| Step | File | Change | +|------|------|--------| +| D.1 | `app/Support/Baselines/TenantGovernanceAggregate.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php` | Add next-action label and target ownership to the aggregate contract | +| D.2 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`, and `app/Filament/Pages/BaselineCompareLanding.php` | Map shared next-action intent to local URLs and operator-facing labels without reintroducing local business rules | +| D.3 | `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php` | Prove next-action parity for running, failed, unavailable, open-findings, overdue-without-new-drift, lapsed-without-new-drift, caution, and stale states | + +### Phase E — Extend to `NeedsAttention` and Multi-Card Stability + +**Goal**: Bring the second dashboard summary surface onto the same request-local contract and remove duplicate findings-count ownership. + +| Step | File | Change | +|------|------|--------| +| E.1 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Replace local `Finding::query()` ownership for overdue, expiring, lapsed, and high-severity summary counts with the shared aggregate and keep only local operations-in-progress logic | +| E.2 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Prove request-local reuse, no-tenant safety, tenant-switch safety, and extension of the guarded consumer contract to cover removal of hidden local findings-count ownership in `NeedsAttention` | + +### Phase F — Regression Protection and Verification + +**Goal**: Prove semantic consistency, request reuse, and tenant-scope safety. + +| Step | File | Change | +|------|------|--------| +| F.1 | `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php` | Add focused resolver coverage for the full FR-168-014 matrix: unavailable prerequisites, in-progress compare, failed compare, open findings requiring action, overdue-without-new-drift, lapsed-without-new-drift, caution, stale, and trustworthy all-clear | +| F.2 | `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`, and `tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Assert that covered surfaces share the same count family, posture family, diagnostics hierarchy, and next-action intent for seeded tenant states | +| F.3 | `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php` and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Assert that one request stores one derived governance aggregate per tenant scope, no tenant state leaks, and no covered consumer reintroduces local findings-count ownership | +| F.4 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack that covers stats, landing authorization and hierarchy, widgets, memoization, and the derived-state guard | + +## Key Design Decisions + +### D-001 — The new contract is summary-focused and derived from existing compare truth + +The shared aggregate must not become a second diagnostics object. `BaselineCompareStats` already owns compare availability, findings counts, and low-level detail. The aggregate will summarize the operator-facing posture family built from that truth. + +### D-002 — Request reuse must flow through Spec 167 infrastructure, not local caches + +The repo already has `RequestScopedDerivedStateStore` and a guard model for supported families. This feature should extend that path instead of introducing widget-level static arrays or request-attribute caches. + +### D-003 — Next-action intent belongs in the aggregate; final URLs stay local + +The operator problem includes conflicting next steps, so the aggregate must own whether the next action is “findings”, “run”, “landing”, or “none”. Each surface will continue to map that intent to panel-specific URLs and capability-aware affordances. + +### D-004 — Baseline Compare landing keeps diagnostics but loses summary re-ownership + +The landing page remains the home for deep compare diagnostics and the compare-start action. It should stop being a separate semantic owner for the default-visible posture zone. + +## Risk Assessment + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Aggregate and compare stats drift into parallel truths | High | Medium | Build the aggregate directly from one `BaselineCompareStats` resolution and keep diagnostics sourced from that same object | +| Request-scoped reuse omits tenant or route-sensitive inputs | High | Medium | Keep the aggregate user-agnostic, key it by workspace and tenant scope, and add tenant-switch / no-tenant regression tests | +| `NeedsAttention` keeps hidden local count ownership | High | Medium | Add consumer guard declarations plus tests that cover overdue, lapsed, and expiring states with zero new drift | +| Landing summary and dashboard summary stay semantically misaligned | Medium | Medium | Add cross-surface parity tests that assert one posture family and one next-action intent across dashboard, banner, and landing | +| The aggregate grows into a second presentation framework | Medium | Low | Limit it to summary posture, counts, and next-action intent; leave URLs, layout, and diagnostics local | + +## Test Strategy + +- Add focused resolver tests that cover the full FR-168-014 matrix: unavailable prerequisites, compare in progress, compare failed, open findings requiring action, overdue-without-new-drift, lapsed governance without new drift, cautionary limited-confidence results, stale results, and trustworthy all-clear results. +- Keep existing Baseline Compare stats and summary-assessment tests as the canonical low-level truth tests. +- Extend Filament widget and landing tests so covered surfaces assert the same headline family, tone, next-action label, preserved drill-down destination continuity, and diagnostics-secondary hierarchy for the same tenant state. +- Preserve existing `Compare now` confirmation, capability gating, and tenant-safe landing authorization behavior through explicit landing regression coverage. +- Add one request-scoped memoization test that renders a page with multiple consumers and proves a single stored governance aggregate exists for that tenant scope. +- Extend the Spec 167 consumer-adoption guard so the new family and consumer surfaces cannot regress back to local ad hoc caches or repeated findings-count queries. +- Preserve Livewire v4-compatible component tests and run the minimum focused Sail verification pack before implementation completion. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| New derived runtime contract plus derived-state family | Four existing summary surfaces already share one operator posture family, and the repo already standardized request-local reuse for similar deterministic derived state | Per-widget hardening would preserve split semantic ownership and allow local findings queries or ad hoc caches to reappear | + +## Proportionality Review + +- **Current operator problem**: Tenant operators can already see overlapping dashboard cards, banners, and landing summaries that answer the same governance question through different logic paths, especially when no new drift exists but overdue or unhealthy governance still does. +- **Existing structure is insufficient because**: `BaselineCompareStats` already holds most of the needed truth, but no explicit summary contract owns the shared posture family. `NeedsAttention` still re-queries counts already derivable from compare stats, which keeps ownership split across surfaces. +- **Narrowest correct implementation**: Add one derived `TenantGovernanceAggregate` plus one resolver that builds from existing compare truth and reuses the existing request-scoped derived-state store. +- **Ownership cost created**: One new runtime DTO/resolver pair, one derived-state family declaration, one consumer guard extension, and a focused set of parity + memoization tests. +- **Alternative intentionally rejected**: Continuing to harden each widget or page independently was rejected because it would preserve multiple semantic owners and repeated queries. A persisted summary record or cross-request cache was rejected because the current-release need is request-time consistency only. +- **Release truth**: Current-release truth. The affected surfaces are already shipped and already overlap semantically. diff --git a/specs/168-tenant-governance-aggregate-contract/quickstart.md b/specs/168-tenant-governance-aggregate-contract/quickstart.md new file mode 100644 index 00000000..aa5bb2d2 --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/quickstart.md @@ -0,0 +1,81 @@ +# Quickstart: Tenant Governance Aggregate Contract + +## Goal + +Validate that one derived tenant-governance aggregate now drives the shared summary posture across the tenant dashboard, the tenant governance banner, and the Baseline Compare landing without adding persistence or reintroducing local findings-count ownership. + +## Prerequisites + +1. Start Sail. +2. Ensure a tenant exists with an assigned baseline profile and a consumable snapshot. +3. Seed compare-run scenarios for: no result yet, compare in progress, compare failed, trustworthy no-drift, stale no-drift, overdue findings without new drift, and lapsed governance without new drift. +4. Ensure the current user is a tenant member with access to the tenant dashboard and Baseline Compare landing. + +## Implementation Validation Order + +### 1. Run low-level compare and aggregate coverage + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareStatsTest.php +vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php +vendor/bin/sail artisan test --compact --filter=TenantGovernanceAggregate +``` + +Expected outcome: +- Existing compare truth still resolves the same availability and posture states. +- The new aggregate contract maps those states into one stable summary object without introducing a second query-backed truth path. + +### 2. Run focused cross-surface parity coverage + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareCoverageBannerTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php +``` + +Expected outcome: +- `NeedsAttention`, `BaselineCompareNow`, `BaselineCompareCoverageBanner`, and the landing summary agree on posture family, headline, and next-action intent for the same tenant state. +- A tenant with zero visible drift but overdue or unhealthy governance still renders as action-needed on every covered surface. +- The landing keeps `Compare now` confirmation and capability gating unchanged, and diagnostics remain clearly secondary to the shared summary posture. + +### 3. Run request-local reuse and guard coverage + +```bash +vendor/bin/sail artisan test --compact --filter=TenantGovernanceAggregateMemoization +vendor/bin/sail artisan test --compact tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php +``` + +Expected outcome: +- One request stores one governance aggregate per tenant scope. +- Covered surfaces do not fall back to widget-local ad hoc caches or repeated local findings queries. + +### 4. Format touched implementation files + +```bash +vendor/bin/sail bin pint --dirty --format agent +``` + +Expected outcome: +- All touched implementation files conform to the repo’s Pint rules. + +## Manual Smoke Check + +1. Open `/admin/t/{tenant}` for a tenant with lapsed governance and no new drift findings. +2. Confirm the dashboard `Needs Attention` widget and `Baseline Governance` card both show action-needed posture. +3. Open `/admin/t/{tenant}/baseline-compare-landing` and confirm the landing summary agrees with the dashboard posture while still showing deeper diagnostics below. +4. Use a tenant with a trustworthy no-drift result and confirm the banner hides, the dashboard falls back to healthy checks, and the landing summary presents the same all-clear posture. +5. Use a tenant with a queued or running compare and confirm dashboard, banner, and landing all present progress-aware follow-up instead of stale “all clear” messaging. +6. Switch from one tenant to another and confirm the second tenant does not reuse the first tenant’s summary posture. + +## Non-Goals For This Slice + +- No database migration. +- No new Graph call or provider-contract work. +- No new assets or Filament panel registration change. +- No new mutation surface beyond the existing `Compare now` action, which remains unchanged. \ No newline at end of file diff --git a/specs/168-tenant-governance-aggregate-contract/research.md b/specs/168-tenant-governance-aggregate-contract/research.md new file mode 100644 index 00000000..34a2df77 --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/research.md @@ -0,0 +1,49 @@ +# Phase 0 Research: Tenant Governance Aggregate Contract + +## Decision: Build the shared contract from `BaselineCompareStats` and its summary assessment instead of creating a second query-backed summary path + +**Rationale**: `BaselineCompareStats::forTenant()` already resolves baseline assignment, consumable snapshot availability, latest compare-run posture, findings visibility counts, and the governance-attention count family that this spec needs. `NeedsAttention` is the main surface still re-querying overdue, expiring, lapsed, and high-severity finding counts locally even though those values are already derivable from compare stats. Building the aggregate from `BaselineCompareStats` keeps one query-backed truth and avoids a second summary service that would drift. + +**Alternatives considered**: +- Create a new persisted tenant-governance summary record: rejected because the current-release problem is request-time consistency, not independent lifecycle or historical reporting truth. +- Create a new query service that bypasses `BaselineCompareStats`: rejected because it would duplicate compare availability and attention logic that already exists. + +## Decision: Introduce one narrow `TenantGovernanceAggregate` runtime contract instead of continuing to treat `BaselineCompareStats` as an implicit shared summary + +**Rationale**: `BaselineCompareStats` is a broad compare-detail object that also carries diagnostics and landing-specific support data. The operator problem is narrower: multiple surfaces need one shared summary posture, count family, and next-action intent. A dedicated aggregate keeps that summary ownership explicit while allowing the landing page to keep deeper diagnostics in `BaselineCompareStats`. + +**Alternatives considered**: +- Use `BaselineCompareStats` directly everywhere with no new contract: rejected because it would leave summary ownership implicit and make it easier for widgets to keep local business rules. +- Move every compare diagnostic into the new aggregate: rejected because it would bloat a summary contract into a second detail model. + +## Decision: Reuse the existing request-scoped derived-state infrastructure from Spec 167 + +**Rationale**: The repo already binds `RequestScopedDerivedStateStore` in the Laravel container and uses it for request-local reuse of `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver`. This feature needs the same boundary: one request-local derived result per deterministic question, explicit invalidation rules, and a guard path that prevents local ad hoc caches from coming back. + +**Alternatives considered**: +- Add a static cache inside `NeedsAttention`, `BaselineCompareNow`, or `BaselineCompareCoverageBanner`: rejected because Spec 167 explicitly moved the codebase away from that pattern. +- Use `Cache::remember()` or another cross-request store: rejected because the spec requires request-local reuse only and stale cross-request posture would be harder to reason about. + +## Decision: Keep next-action intent inside the aggregate, but keep final URLs and panel-specific drill-down mapping local to surfaces + +**Rationale**: The semantic drift problem is not only conflicting counts; it is also conflicting operator follow-up. The aggregate must therefore answer the stable business question “what is the next action target category for this tenant state?” while leaving local surfaces free to map that answer to tenant-panel URLs, run links, or findings links appropriate to their context. + +**Alternatives considered**: +- Store absolute URLs inside the aggregate: rejected because final URLs remain panel-aware and surface-local, and capability-gated destinations should stay local to the consuming surface. +- Leave next-action computation local to every widget or page: rejected because next-action drift is part of the problem this spec is supposed to solve. + +## Decision: Keep `BaselineCompareLanding` as the home for diagnostics, but move its default-visible posture semantics onto the aggregate + +**Rationale**: The spec explicitly distinguishes default-visible operator posture from diagnostics-only information. `BaselineCompareLanding` should still own evidence gaps, duplicate-name diagnostics, detailed compare context, and the existing `Compare now` action. What changes is that its primary posture zone should no longer be another local semantic owner. + +**Alternatives considered**: +- Leave the landing page fully outside the aggregate: rejected because the landing page is one of the authoritative summary surfaces listed in the spec. +- Collapse the landing page into a pure aggregate view: rejected because operators still need compare diagnostics there. + +## Decision: Protect the feature with parity, memoization, and guard tests rather than ad hoc performance scripts + +**Rationale**: The highest-risk regressions are semantic drift, hidden local re-queries, and request-scope leakage. Focused Pest tests can assert those business truths directly, and the derived-state adoption guard can stop future surfaces from quietly reintroducing local caches or unsupported family access patterns. + +**Alternatives considered**: +- Rely only on query counting or manual profiling: rejected because it would miss operator-visible drift and consumer-adoption regressions. +- Treat this as a pure unit-test problem: rejected because the spec is about cross-surface agreement and request behavior, which requires feature-level coverage too. \ No newline at end of file diff --git a/specs/168-tenant-governance-aggregate-contract/spec.md b/specs/168-tenant-governance-aggregate-contract/spec.md new file mode 100644 index 00000000..8e3420bc --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/spec.md @@ -0,0 +1,202 @@ +# Feature Specification: Tenant Governance Aggregate Contract + +**Feature Branch**: `168-tenant-governance-aggregate-contract` +**Created**: 2026-03-28 +**Status**: Draft +**Input**: User description: "Spec 168 — Tenant Governance Aggregate Contract" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant +- **Primary Routes**: + - `/admin/t/{tenant}` as the tenant dashboard where Baseline Governance and Needs Attention currently surface overlapping governance posture + - `/admin/t/{tenant}/baseline-compare-landing` as the tenant baseline-compare hub that owns compare posture, next action, and supporting diagnostics + - tenant-context governance banners and summary cards that surface baseline posture or governance attention inside the tenant panel +- **Data Ownership**: + - Tenant-owned: findings, finding-exception validity, overdue workflow state, and tenant-scoped compare-run visibility that together describe the tenant's current governance attention posture + - Workspace-owned but tenant-resolved: baseline assignment, baseline profile, effective baseline snapshot availability, and workspace-level prerequisites that shape tenant compare posture + - This feature introduces no new persisted tenant-governance record; the shared aggregate remains derived for one tenant at a time +- **RBAC**: + - Existing workspace membership and tenant entitlement remain required for every surface that consumes the aggregate + - Existing tenant inspection capabilities remain the gate for opening findings, baseline compare detail, and other drill-down destinations reached from the aggregate + - Existing compare-start authorization and confirmation rules remain unchanged for the Baseline Compare landing action + +## 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 | +|---|---|---|---|---|---|---|---|---|---| +| Tenant dashboard `Needs Attention` | Tenant operator | Summary widget | What needs action right now across governance and baseline posture? | Overdue findings, lapsed governance, expiring governance, high-severity active findings, baseline-posture summary | Deep reason codes, raw compare diagnostics, low-level evidence gaps, run internals | governance attention, due urgency, compare posture, severity urgency | Read-only summary with drill-down navigation | Review dashboard attention items, open related destination | None | +| Tenant dashboard `Baseline Governance` card | Tenant operator | Summary card | Can I trust the latest baseline posture, and what should I do next? | Aggregate headline, supporting message, next action, visible drift count, high-severity count, last compared label | Deep coverage details, duplicate-name stats, evidence-gap drill-down, run diagnostics | compare freshness, compare outcome, governance attention, trustworthiness | Read-only summary with drill-down navigation | Open findings, open run, open Baseline Compare | None | +| Baseline Compare landing | Tenant operator | Landing page | What is the tenant's current baseline governance posture, and should I compare now or investigate detail? | Aggregate posture, assignment or snapshot availability, findings attention, next action, compare availability | Coverage detail, evidence-gap detail, duplicate-name diagnostics, low-level run reason detail | compare availability, compare freshness, compare outcome, governance attention, coverage limits | Existing compare action unchanged; page state itself is read-focused | Review posture, open findings, start `Compare now`, open run detail | No new dangerous action; existing `Compare now` remains confirmation-gated and capability-gated | +| Tenant governance banner | Tenant operator | Inline warning banner | Is there a governance or compare limitation I should not miss on this screen? | Shared aggregate headline, supporting message, next action intent, warning tone | Full compare diagnostics and detailed count breakdowns | compare caution or stale state, governance attention, next action intent | Read-only summary with drill-down navigation | Follow next action to findings, run detail, or Baseline Compare | None | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: No. Findings, exception validity, baseline assignment, snapshot availability, and compare-run truth remain the source data. +- **New persisted entity/table/artifact?**: No. The aggregate is derived and request-scoped. +- **New abstraction?**: Yes. This feature introduces one explicit tenant-scoped aggregate contract for governance summary state that multiple surfaces share. +- **New enum/state/reason family?**: No. Existing compare posture, governance validity, and attention semantics remain the source vocabulary. +- **New cross-domain UI framework/taxonomy?**: No. The work consolidates one summary family; it does not create a new presentation framework. +- **Current operator problem**: Operators can see overlapping tenant-governance cards and pages that recompute similar facts independently, which makes counts, headlines, and next-action guidance harder to trust and more expensive to keep aligned. +- **Existing structure is insufficient because**: The current structure lets baseline compare posture live in one shared path while overdue, expiring, and lapsed governance attention can still be owned locally by individual widgets or banners. That fragmentation means no single contract is accountable for the tenant's shared governance summary. +- **Narrowest correct implementation**: Introduce one derived tenant-scoped governance aggregate that absorbs the overlapping summary facts already reused across landing, dashboard, and banner surfaces, while leaving presentation mapping local to each surface. +- **Ownership cost**: The codebase takes on one explicit shared contract plus cross-surface regression tests and request-reuse guarantees, but removes duplicated query ownership and semantic drift from each consuming surface. +- **Alternative intentionally rejected**: Continuing to harden each widget or page independently was rejected because it preserves duplicate ownership. Adding a persisted summary record or cross-request cache was rejected because current-release needs do not require a new durable truth source. +- **Release truth**: Current-release truth. The overlap already exists on shipped tenant-governance surfaces and is growing with ongoing governance work. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - One Tenant, One Governance Posture (Priority: P1) + +As a tenant operator, I want the dashboard, banner, and baseline-compare landing to agree on what needs action, so that I do not have to reconcile conflicting counts or messages before deciding what to do next. + +**Why this priority**: Trust in the product's governance posture depends on cross-surface consistency. Conflicting summary signals create operator hesitation and support debt. + +**Independent Test**: Can be fully tested by seeding one tenant with baseline-compare posture and governance-attention states, then rendering at least three covered surfaces and verifying that the same tenant state produces the same underlying counts, posture family, and next-action intent. + +**Acceptance Scenarios**: + +1. **Given** a tenant has no visible drift findings but has lapsed accepted-risk governance, **When** an authorized operator opens the dashboard and the baseline-compare landing, **Then** both surfaces present the tenant as needing attention rather than as an all-clear. +2. **Given** a tenant has overdue open findings and expiring governance, **When** multiple tenant-governance surfaces render in the same session, **Then** they expose consistent overdue and governance-attention meaning for that tenant. +3. **Given** a tenant has a trustworthy compare result with no findings and no governance-attention conditions, **When** the operator opens covered summary surfaces, **Then** those surfaces consistently present a healthy posture rather than mixed healthy and warning states. + +--- + +### User Story 2 - Consistent Next Action Across Summary Surfaces (Priority: P1) + +As a tenant operator, I want each governance summary surface to point me toward the same next step for the same tenant state, so that I can act quickly without guessing which surface is authoritative. + +**Why this priority**: A shared aggregate is only useful if it produces not just aligned counts, but aligned operator action. + +**Independent Test**: Can be fully tested by exercising compare-in-progress, failed-compare, overdue-findings, and governance-lapsed scenarios and verifying that each covered surface routes the operator toward the same class of next action for the same state. + +**Acceptance Scenarios**: + +1. **Given** a baseline compare is currently running, **When** the operator opens the dashboard card, banner, and landing page, **Then** each surface points to progress-aware follow-up instead of mixing stale completion guidance with active-run guidance. +2. **Given** a tenant needs attention because overdue or lapsed governance states remain, **When** the operator opens any aggregate-consuming summary surface, **Then** the primary follow-up action remains aligned with reviewing findings rather than presenting unrelated compare guidance. +3. **Given** a tenant lacks a usable baseline compare result because no assignment or no snapshot is available, **When** summary surfaces render, **Then** they consistently communicate availability or prerequisite guidance instead of one surface appearing healthy while another appears unavailable. + +--- + +### User Story 3 - Stable Multi-Card Tenant Summary (Priority: P2) + +As a tenant operator, I want a page that shows multiple governance cards to stay stable for my current tenant, so that repeated summary cards do not disagree or flicker between different tenant-governance interpretations. + +**Why this priority**: The immediate product problem is not only semantic drift over time but duplicate ownership inside one page render. + +**Independent Test**: Can be fully tested by rendering a tenant page that includes more than one aggregate-consuming governance surface and verifying that the same tenant request produces one stable posture family and one stable set of governance-attention counts. + +**Acceptance Scenarios**: + +1. **Given** a tenant dashboard renders both a governance card and a needs-attention summary, **When** the page loads, **Then** both surfaces reflect the same tenant posture and compatible next-step guidance. +2. **Given** the operator switches from one tenant to another, **When** the new tenant page renders, **Then** the summary state resets to the new tenant and does not reuse the previous tenant's governance posture. +3. **Given** no tenant is selected, **When** a tenant-governance surface is reached indirectly, **Then** it shows the appropriate empty or unavailable state and does not leak another tenant's summary. + +### Edge Cases + +- A tenant has zero open drift findings, but overdue findings, expiring governance, or lapsed governance still exist; all aggregate-consuming attention surfaces must continue to present action-needed posture. +- A tenant has baseline assignment but no consumable snapshot or no completed compare result yet; covered surfaces must stay consistent about availability and prerequisites. +- A compare run is queued or running while older compare-derived counts still exist; summary surfaces must present progress-aware posture instead of mixing active-run and completed-run claims. +- A single request renders multiple governance summary surfaces for one tenant; the request must not produce contradictory counts or posture labels because different surfaces recomputed the same attention family separately. +- A tenant switch or no-tenant context occurs between requests; request-local reuse must not carry governance summary state across tenant boundaries. +- A surface still needs local diagnostics beyond the shared aggregate; those diagnostics must remain secondary and must not redefine the shared attention semantics. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new long-running job type, and no new persisted artifact. It consolidates existing tenant-governance summary truth from current compare posture, findings attention, and governance validity into one shared derived contract. Existing compare operations and Monitoring observability remain unchanged. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one narrow abstraction: a shared tenant-governance aggregate contract. It does so because existing tenant surfaces already duplicate overlapping summary state and a narrower per-surface hardening would preserve split ownership. The contract remains derived, current-release, and intentionally avoids new persistence, new state families, or a cross-domain semantic framework. + +**Constitution alignment (OPS-UX):** No new `OperationRun` type is introduced. Existing baseline-compare runs remain the only operational execution path involved, and their queued, running, failed, or completed visibility remains governed by the existing Ops-UX contract. + +**Constitution alignment (RBAC-UX):** The feature stays entirely in the tenant/admin plane on tenant-context surfaces. It does not introduce new authorization behavior. Non-members remain deny-as-not-found, in-scope members without the required capability remain forbidden for existing compare-start or drill-down actions, and shared aggregate reuse must remain tenant-scoped so no cross-tenant summary leakage is possible. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication flow is changed. + +**Constitution alignment (BADGE-001):** Existing centralized badge and tone semantics for findings severity, governance validity, and compare posture remain the semantic source. This feature may make those semantics appear on more than one surface through the aggregate, but it must not introduce ad hoc per-surface status vocabularies. + +**Constitution alignment (UI-FIL-001):** The feature reuses native Filament widgets, sections, links, and badges already present on the dashboard, landing page, and banner surfaces. It should consolidate the shared semantic source rather than introducing page-local markup or a new local visual language for warning states. + +**Constitution alignment (UI-NAMING-001):** Operator-facing terms remain baseline governance, needs attention, baseline compare, compare now, and open findings. The feature must preserve the same domain vocabulary across dashboard cards, landing copy, banner messaging, and drill-down affordances so operators do not see one surface talk about attention while another describes the same state as healthy posture. + +**Constitution alignment (OPSURF-001):** This feature materially refactors operator-facing summary surfaces. Default-visible information must remain operator-first: posture, urgency, and next action appear first, while diagnostics remain secondary. Mutation scope remains unchanged: read-only summary surfaces stay read-only, and the existing `Compare now` action continues to communicate its existing compare execution scope. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** A shared aggregate is justified because direct per-surface mapping has already produced redundant truth and semantic drift. The aggregate must replace duplicate per-surface summary ownership rather than layering yet another presenter or wrapper on top of it. Tests must assert business truth across surfaces and request scope, not only thin adapter wiring. + +**Constitution alignment (Filament Action Surfaces):** This feature modifies Filament pages and widgets in the tenant panel. The Action Surface Contract remains satisfied because the summary surfaces are read-only and the only existing mutation surface involved, Baseline Compare landing, keeps explicit confirmation and capability gating for compare start. UI-FIL-001 is satisfied because no exception to native Filament summary or action primitives is required. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature does not add new create or edit screens. It changes summary and landing semantics on existing tenant surfaces. The landing page must keep operator posture and next-action messaging above diagnostics, and widgets or banners must keep attention content concise, explicit, and drill-down oriented rather than becoming diagnostic walls. + +### Functional Requirements + +- **FR-168-001**: The system MUST provide one tenant-scoped governance aggregate for the currently selected tenant that combines the summary state already shared across tenant governance cards, banners, and baseline-compare summary surfaces. +- **FR-168-002**: The tenant-governance aggregate MUST include, at minimum, compare availability or progress state, compare outcome or freshness posture, compare trust-limiting signals relevant to summary posture, visible drift findings count, overdue open findings count, expiring governance count, lapsed governance count, active non-new findings count, and high-severity active findings count. +- **FR-168-003**: At least three existing tenant-facing summary surfaces MUST consume the same tenant-governance aggregate contract. +- **FR-168-004**: Aggregate-consuming surfaces MUST treat the shared aggregate as the semantic owner for overdue findings, expiring governance, lapsed governance, and tenant-level baseline posture, and MUST NOT define conflicting local business rules for those states. +- **FR-168-005**: Aggregate-consuming surfaces MAY keep local layout, wording emphasis, and navigation affordances appropriate to their context, but they MUST preserve the same underlying posture family, counts, and next-action intent for the same tenant state. +- **FR-168-006**: A tenant with zero visible drift findings but any overdue open findings, expiring governance, or lapsed governance MUST still render as needing attention on every aggregate-consuming attention surface. +- **FR-168-007**: A tenant with no baseline assignment, no usable snapshot, no current compare result, an in-progress compare, a failed compare, a stale compare result, or a trustworthy all-clear result MUST resolve to one consistent tenant-governance posture across all aggregate-consuming surfaces. +- **FR-168-008**: Repeated reads of the same tenant-governance aggregate during one request MUST reuse the same derived result instead of recomputing aggregate-owned counts separately for each consuming surface. +- **FR-168-009**: Request-local aggregate reuse MUST remain scoped to the current tenant context and MUST NOT leak summary state across tenants, workspaces, or no-tenant states. +- **FR-168-010**: The initial implementation MUST remain derived-only and MUST NOT require a new persisted summary record, a new cross-request cache, or a new dashboard setup step. +- **FR-168-011**: Existing compare-start actions, findings drill-downs, and baseline-compare navigation destinations MUST remain available, and this feature MUST NOT broaden or weaken their existing RBAC and confirmation behavior. +- **FR-168-012**: If a covered surface needs diagnostics beyond the shared aggregate, those diagnostics MUST remain secondary and MUST NOT redefine the shared attention semantics already owned by the aggregate. +- **FR-168-013**: The feature MUST stay bounded to one tenant at a time and MUST NOT expand this aggregate into cross-tenant portfolio posture, new governance workflow semantics, or a full dashboard redesign. +- **FR-168-014**: Regression coverage MUST verify semantic consistency across aggregate-consuming surfaces for at least these tenant states: unavailable prerequisites, compare in progress, compare failed, open findings requiring action, overdue findings without new drift, lapsed governance without new drift, caution or stale compare posture, and trustworthy all-clear. +- **FR-168-015**: Regression coverage MUST verify that a single page render containing multiple aggregate-consuming surfaces does not re-own overdue, expiring, or lapsed counts in parallel and remains stable for the current tenant context. + +## 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 Compare landing | `app/Filament/Pages/BaselineCompareLanding.php` | Existing `Compare now` action remains in the header | Not a record list surface | None | None | Existing prerequisite and unavailable messaging remains | `Compare now` only | N/A | Existing compare start remains observable through `OperationRun`; no new audit surface added here | This spec changes posture ownership and copy consistency, not the action inventory | +| Tenant dashboard `Needs Attention` widget | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | None | N/A | None | None | Healthy state remains a read-only reassurance surface | N/A | N/A | No | Read-only summary widget; covered because it currently owns overlapping attention counts | +| Tenant dashboard `Baseline Governance` card | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | None | N/A | None | None | Existing no-assignment state remains a read-only empty state | N/A | N/A | No | Read-only summary card; next-action links remain navigation only | +| Tenant governance banner | `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` | None | N/A | None | None | Banner simply hides when not relevant | N/A | N/A | No | Read-only warning banner; included because it must consume the shared aggregate family | + +### Key Entities *(include if feature involves data)* + +- **Tenant governance aggregate**: A derived tenant-scoped summary that combines compare posture, governance-attention counts, and next-action intent for operator-facing summary surfaces. +- **Baseline compare posture**: The tenant's current compare availability, freshness, outcome, and trustworthiness summary. +- **Governance attention summary**: The tenant's actionable counts for overdue findings, expiring governance, lapsed governance, and other active finding pressure that should influence summary posture. +- **Aggregate-consuming surface**: Any tenant-facing card, widget, banner, or landing summary that reads the shared aggregate and applies only local presentation mapping. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-168-001**: In seeded regression coverage, at least three tenant-governance summary surfaces display identical overdue, expiring, and lapsed-governance counts for the same tenant in 100% of tested scenarios. +- **SC-168-002**: In seeded regression coverage, tenants with zero visible drift findings but overdue or unhealthy governance still render as action-needed on every tested aggregate-consuming attention surface. +- **SC-168-003**: In operator acceptance review, a tenant operator can determine the current governance posture and next action from either the dashboard or baseline-compare landing within 10 seconds. +- **SC-168-004**: In regression coverage, a single page that renders multiple aggregate-consuming surfaces for one tenant shows no contradictory posture family, tone, or next-action combination within the same load. +- **SC-168-005**: The feature ships without requiring any new setup step, scheduled refresh step, or durable summary artifact for operators to see the shared tenant-governance posture. + +## Assumptions + +- Existing findings workflow truth, finding-exception validity truth, and baseline-compare posture truth remain sufficient to derive the shared tenant-governance aggregate. +- Existing tenant dashboard and baseline-compare surfaces remain in place; this spec consolidates their summary contract rather than replacing the whole tenant-governance UI. +- Existing tenant-entitlement and compare-start authorization rules remain correct and do not need separate RBAC redesign in this slice. + +## Non-Goals + +- Building a cross-tenant portfolio governance aggregate or workspace-wide control tower summary for this data family +- Introducing a new persisted summary table, cache artifact, or reporting export just to support shared summary posture +- Redefining findings workflow semantics, exception validity semantics, or compare reason-code semantics +- Redesigning the entire tenant dashboard or baseline-compare landing beyond the shared-governance summary family + +## Dependencies + +- Existing baseline assignment, snapshot availability, and compare-run truth +- Existing findings and finding-exception governance-attention derivation +- Existing tenant dashboard widgets, baseline-compare landing surface, and tenant governance banner surfaces +- Existing tenant-context RBAC and drill-down destinations for findings and operation runs + +## Definition of Done + +Spec 168 is complete when: + +- one tenant-scoped governance aggregate owns the overlapping summary family used by at least three tenant-facing governance surfaces, +- dashboard, landing, and banner-style surfaces no longer contradict one another about overdue, expiring, lapsed, or compare-posture state for the same tenant, +- request-local reuse of the aggregate is testable and tenant-safe, +- no new persisted summary truth or cross-request cache is required, +- and the covered surfaces remain operator-first while keeping local presentation responsibility separate from shared business semantics. diff --git a/specs/168-tenant-governance-aggregate-contract/tasks.md b/specs/168-tenant-governance-aggregate-contract/tasks.md new file mode 100644 index 00000000..94369a15 --- /dev/null +++ b/specs/168-tenant-governance-aggregate-contract/tasks.md @@ -0,0 +1,217 @@ +# Tasks: Tenant Governance Aggregate Contract + +**Input**: Design documents from `/specs/168-tenant-governance-aggregate-contract/` +**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md` + +**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`, `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`. +**Operations**: This feature does not create a new `OperationRun` type or change run lifecycle ownership. Existing baseline-compare runs remain the only execution surface involved, and all work here is read-time summary consolidation. +**RBAC**: Existing tenant-context membership, entitlement, and 404 vs 403 semantics remain unchanged. Tasks must preserve current tenant-safe drill-down behavior and ensure request-local reuse never leaks summary state across tenants, workspaces, or no-tenant states. +**Operator Surfaces**: The tenant dashboard `Baseline Governance` card, tenant dashboard `Needs Attention` widget, tenant governance banner, and Baseline Compare landing must keep operator-first posture, urgency, and next-step guidance above diagnostics. +**Filament UI Action Surfaces**: No new action inventories are added. The existing Baseline Compare landing `Compare now` action remains confirmation-gated and capability-gated, and all other affected surfaces remain navigation-only summaries. +**Filament UI UX-001**: No new create, edit, or view layouts are introduced. Existing widget, banner, and landing layouts remain intact while shared summary ownership is refactored. +**Badges**: Existing compare-posture, severity, and governance-validity semantics must remain sourced from shared badge semantics; no new page-local mappings are introduced. + +**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment after the shared aggregate contract is in place. + +## Phase 1: Setup (Shared Aggregate Scaffolding) + +**Purpose**: Create the narrow runtime and test scaffolding required for the tenant-governance aggregate. + +- [X] T001 [P] Create the aggregate runtime scaffolding in `app/Support/Baselines/TenantGovernanceAggregate.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php` +- [X] T002 [P] Create the focused regression scaffolding in `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php` and `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php` + +--- + +## Phase 2: Foundational (Blocking Aggregate Contract) + +**Purpose**: Build the shared aggregate runtime and request-scoped family contract that all user stories depend on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T003 [P] Extend source-truth regression coverage for aggregate input counts and summary assessment mapping in `tests/Feature/Baselines/BaselineCompareStatsTest.php` and `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php` +- [X] T004 Implement the derived tenant summary contract fields in `app/Support/Baselines/TenantGovernanceAggregate.php` +- [X] T005 Implement aggregate resolution and request-scoped reuse from `BaselineCompareStats` and `BaselineCompareSummaryAssessment` in `app/Support/Baselines/TenantGovernanceAggregateResolver.php` and `app/Support/Baselines/BaselineCompareStats.php` +- [X] T006 [P] Declare the governance aggregate derived-state family, extend the first-slice supported consumer contract, and update the guard in `app/Support/Ui/DerivedState/DerivedStateFamily.php`, `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` + +**Checkpoint**: The tenant-governance aggregate exists as one derived request-local contract that later surface work can consume. + +--- + +## Phase 3: User Story 1 - One Tenant, One Governance Posture (Priority: P1) + +**Goal**: Make the dashboard governance card, tenant banner, and Baseline Compare landing agree on posture family and governance-attention counts for the same tenant. + +**Independent Test**: Seed one tenant with unavailable prerequisites, compare in progress, compare failed, open findings requiring action, overdue without new drift, lapsed governance without new drift, caution, stale results, and trustworthy all-clear states, then verify the dashboard governance card, banner, and landing summary resolve the same posture family and count family while keeping landing diagnostics secondary. + +### Tests for User Story 1 + +- [X] T007 [P] [US1] Add aggregate scenario coverage for unavailable, in-progress, failed, open-findings action-required, overdue-without-new-drift, lapsed-without-new-drift, caution, stale, and trustworthy-all-clear states in `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php` +- [X] T008 [P] [US1] Extend cross-surface posture and count parity assertions for the full FR-168-014 state matrix, landing diagnostics-secondary hierarchy, and unchanged compare-start authorization and confirmation semantics in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php` + +### Implementation for User Story 1 + +- [X] T009 [US1] Route the tenant dashboard `Baseline Governance` summary card through the shared aggregate in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` +- [X] T010 [US1] Drive the tenant governance banner from the shared aggregate in `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` +- [X] T011 [US1] Replace the Baseline Compare landing default-visible posture summary with the shared aggregate while keeping diagnostics on `BaselineCompareStats`, preserving existing `Compare now` confirmation and capability semantics, and keeping diagnostics secondary in `app/Filament/Pages/BaselineCompareLanding.php` +- [X] T012 [US1] Keep aggregate posture, tone, and supporting-message semantics aligned to existing compare-summary rules in `app/Support/Baselines/BaselineCompareSummaryAssessor.php` and `app/Support/Baselines/BaselineCompareSummaryAssessment.php` +- [X] T013 [US1] Run the focused posture parity pack in `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php` + +**Checkpoint**: Three tenant-facing summary surfaces now share one governance posture and count contract for the same tenant state. + +--- + +## Phase 4: User Story 2 - Consistent Next Action Across Summary Surfaces (Priority: P1) + +**Goal**: Make every covered summary surface point operators toward the same class of next step for the same tenant state. + +**Independent Test**: Seed running-compare, failed-compare, unavailable-prerequisite, open-findings requiring action, overdue-without-new-drift, lapsed-governance without new drift, caution, and stale scenarios, then verify the dashboard governance card, banner, and landing summary expose the same next-action intent and matching operator-facing labels. + +### Tests for User Story 2 + +- [X] T014 [P] [US2] Add next-action intent regressions for running, failed, unavailable, open-findings action-required, overdue-without-new-drift, lapsed-without-new-drift, caution, and stale scenarios in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php` +- [X] T015 [P] [US2] Add dashboard governance card and banner next-step assertions, including preserved findings drill-down and open-landing navigation availability, in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php` and `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php` + +### Implementation for User Story 2 + +- [X] T016 [US2] Add next-action label and target ownership to the aggregate contract in `app/Support/Baselines/TenantGovernanceAggregate.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php` +- [X] T017 [US2] Map shared next-action intent to findings, run, and landing destinations in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` +- [X] T018 [US2] Align banner and landing next-action presentation to the shared intent in `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` and `app/Filament/Pages/BaselineCompareLanding.php` +- [X] T019 [US2] Run the focused next-action pack in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php` + +**Checkpoint**: Covered summary surfaces no longer disagree about whether the operator should inspect findings, watch a run, open the landing page, or take no action. + +--- + +## Phase 5: User Story 3 - Stable Multi-Card Tenant Summary (Priority: P2) + +**Goal**: Keep multiple governance cards on the same tenant page stable by reusing one request-local aggregate and removing duplicate local findings-count ownership. + +**Independent Test**: Render the tenant dashboard with both `Needs Attention` and `Baseline Governance` visible, verify both surfaces stay consistent for the same tenant in one request, then switch tenants or remove tenant context and verify no stale summary leaks. + +### Tests for User Story 3 + +- [X] T020 [P] [US3] Add request-local reuse, tenant-switch, and no-tenant safety coverage in `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php` +- [X] T021 [P] [US3] Extend multi-card dashboard stability and no-local-requery coverage in `tests/Feature/Filament/NeedsAttentionWidgetTest.php` and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php` + +### Implementation for User Story 3 + +- [X] T022 [US3] Harden tenant-switch, workspace-scope, and no-tenant key behavior for the second dashboard summary consumer in `app/Support/Ui/DerivedState/DerivedStateFamily.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php` +- [X] T023 [US3] Replace local overdue, expiring, lapsed, and high-severity findings ownership with the shared aggregate in `app/Filament/Widgets/Dashboard/NeedsAttention.php` +- [X] T024 [US3] Extend governance aggregate consumer declarations and no-local-requery guard coverage to include `NeedsAttention` in `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml` and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` +- [X] T025 [US3] Run the focused memoization and guard pack in `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` + +**Checkpoint**: The tenant dashboard can render multiple governance summary surfaces in one request without contradictory counts, posture drift, or tenant leakage. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final copy alignment, formatting, and focused verification across all stories. + +- [X] T026 [P] Align operator-facing copy and next-step labels in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`, and `app/Filament/Pages/BaselineCompareLanding.php` +- [X] T027 Run formatting for touched implementation files with `vendor/bin/sail bin pint --dirty --format agent` using `specs/168-tenant-governance-aggregate-contract/quickstart.md` +- [X] T028 Run the final focused verification pack from `specs/168-tenant-governance-aggregate-contract/quickstart.md` against `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`, `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and creates the narrow aggregate runtime and test scaffolding. +- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the aggregate contract and request-scoped family are in place. +- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP shared-posture slice across three surfaces. +- **User Story 2 (Phase 4)**: Starts after User Story 1 because it refines the same summary surfaces with explicit next-action intent. +- **User Story 3 (Phase 5)**: Starts after User Story 2 because it adopts the second dashboard summary surface only after shared next-action semantics and first-slice parity are settled. +- **Polish (Phase 6)**: Starts after all desired user stories are complete and ends with focused verification. + +### User Story Dependencies + +- **User Story 1 (P1)**: Depends only on the aggregate contract and request-scoped family from Phase 2. +- **User Story 2 (P1)**: Depends on User Story 1 because the same summary surfaces must already read the shared aggregate before their next-action mapping can be aligned. +- **User Story 3 (P2)**: Depends on the aggregate contract from Phase 2 plus the settled shared posture and next-action semantics from User Stories 1 and 2 before the second dashboard consumer joins the shared contract. + +### Within Each User Story + +- Story tests should be written before or alongside the relevant implementation tasks and must fail before the story is considered complete. +- Aggregate contract changes should land before surface adoption in the same story. +- Surface refactors should land before the focused story-level regression run. + +### Parallel Opportunities + +- `T001` and `T002` can run in parallel during Setup. +- `T003` and `T006` can run in parallel during Foundational work. +- `T007` and `T008` can run in parallel for User Story 1. +- `T014` and `T015` can run in parallel for User Story 2. +- `T020` and `T021` can run in parallel for User Story 3. +- After `T022`, `T023` and `T024` can run in parallel because they change different files. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel: +Task: T007 tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php +Task: T008 tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php + +# User Story 1 surface adoption split after aggregate semantics are fixed: +Task: T009 app/Filament/Widgets/Dashboard/BaselineCompareNow.php +Task: T010 app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel: +Task: T014 tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php +Task: T015 tests/Feature/Filament/BaselineCompareNowWidgetTest.php + +# User Story 2 implementation split after next-action ownership lands: +Task: T017 app/Filament/Widgets/Dashboard/BaselineCompareNow.php +Task: T018 app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php and app/Filament/Pages/BaselineCompareLanding.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 tests in parallel: +Task: T020 tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php +Task: T021 tests/Feature/Filament/NeedsAttentionWidgetTest.php + +# User Story 3 implementation split after request-scoped keying is settled: +Task: T023 app/Filament/Widgets/Dashboard/NeedsAttention.php +Task: T024 tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php and specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml +``` + +--- + +## Implementation Strategy + +### MVP First + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate that the dashboard governance card, tenant banner, and landing summary now share one posture and count contract. + +### Incremental Delivery + +1. Add User Story 2 to align next-action guidance once shared posture parity is proven. +2. Add User Story 3 to bring the second dashboard card onto the same contract and lock in request-local stability. +3. Finish with copy alignment, formatting, and the focused verification pack. + +### Parallel Team Strategy + +1. One developer can complete Phase 1 and Phase 2 while another prepares the story tests. +2. After User Story 1 lands, one developer can handle User Story 2 next-action adoption while another prepares User Story 3 memoization and guard coverage. +3. Rejoin for Phase 6 to run Pint and the focused verification pack. + +--- + +## Notes + +- `[P]` tasks target different files with no direct dependency on unfinished code in the same phase. +- `[US1]`, `[US2]`, and `[US3]` labels map tasks directly to the feature specification user stories. +- The suggested MVP scope is Phase 1 through Phase 3 only. +- No task in this plan introduces new persistence, a new Graph contract, a new Filament panel/provider registration, or a new destructive action. -- 2.45.2 From f6dc5ed9472363e5cd287518e376449ec99ba6e5 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 29 Mar 2026 23:11:48 +0200 Subject: [PATCH 4/6] feat: add tenant governance aggregate contract --- app/Filament/Pages/BaselineCompareLanding.php | 21 +- .../Widgets/Dashboard/BaselineCompareNow.php | 31 +- .../Widgets/Dashboard/NeedsAttention.php | 67 ++-- .../Tenant/BaselineCompareCoverageBanner.php | 35 +- .../Baselines/BaselineCompareStats.php | 29 ++ .../Baselines/TenantGovernanceAggregate.php | 111 ++++++ .../TenantGovernanceAggregateResolver.php | 71 ++++ .../Ui/DerivedState/DerivedStateFamily.php | 6 +- ...-scoped-derived-state.logical.openapi.yaml | 81 +++++ .../Baselines/BaselineCompareStatsTest.php | 117 +++++++ .../BaselineCompareSummaryAssessmentTest.php | 82 +++++ .../TenantGovernanceAggregateResolverTest.php | 326 ++++++++++++++++++ .../BaselineCompareCoverageBannerTest.php | 41 +++ .../BaselineCompareSummaryConsistencyTest.php | 109 ++++++ .../Filament/NeedsAttentionWidgetTest.php | 62 ++++ ...nantGovernanceAggregateMemoizationTest.php | 122 +++++++ .../DerivedStateConsumerAdoptionGuardTest.php | 3 +- 17 files changed, 1241 insertions(+), 73 deletions(-) create mode 100644 app/Support/Baselines/TenantGovernanceAggregate.php create mode 100644 app/Support/Baselines/TenantGovernanceAggregateResolver.php create mode 100644 tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php create mode 100644 tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index 20b10324..1fbd8b28 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -15,6 +15,8 @@ use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCompareEvidenceGapDetails; use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -133,7 +135,11 @@ public function mount(): void public function refreshStats(): void { - $stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel()); + $tenant = static::resolveTenantContextForCurrentPanel(); + $stats = BaselineCompareStats::forTenant($tenant); + $aggregate = $tenant instanceof Tenant + ? $this->governanceAggregate($tenant, $stats) + : null; $this->state = $stats->state; $this->message = $stats->message; @@ -169,7 +175,7 @@ public function refreshStats(): void : null; $this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null; $this->operatorExplanation = $stats->operatorExplanation()->toArray(); - $this->summaryAssessment = $stats->summaryAssessment()->toArray(); + $this->summaryAssessment = $aggregate?->summaryAssessment->toArray(); } /** @@ -419,4 +425,15 @@ public function getRunUrl(): ?string return OperationRunLinks::view($this->operationRunId, $tenant); } + + private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->fromStats($tenant, $stats); + + return $aggregate; + } } diff --git a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php index 1e647744..f1bb999f 100644 --- a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php +++ b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php @@ -7,7 +7,8 @@ use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Resources\FindingResource; use App\Models\Tenant; -use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; use Filament\Facades\Filament; use Filament\Widgets\Widget; @@ -38,19 +39,18 @@ protected function getViewData(): array return $empty; } - $stats = BaselineCompareStats::forTenant($tenant); + $aggregate = $this->governanceAggregate($tenant); - if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) { + if ($aggregate->compareState === 'no_assignment') { return $empty; } $tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); - $runUrl = $stats->operationRunId !== null - ? OperationRunLinks::view($stats->operationRunId, $tenant) + $runUrl = $aggregate->stats->operationRunId !== null + ? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant) : null; $findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant); - $summaryAssessment = $stats->summaryAssessment(); - $nextActionUrl = match ($summaryAssessment->nextActionTarget()) { + $nextActionUrl = match ($aggregate->nextActionTarget) { 'run' => $runUrl, 'findings' => $findingsUrl, 'landing' => $tenantLandingUrl, @@ -59,13 +59,24 @@ protected function getViewData(): array return [ 'hasAssignment' => true, - 'profileName' => $stats->profileName, - 'lastComparedAt' => $stats->lastComparedHuman, + 'profileName' => $aggregate->profileName, + 'lastComparedAt' => $aggregate->lastComparedLabel, 'landingUrl' => $tenantLandingUrl, 'runUrl' => $runUrl, 'findingsUrl' => $findingsUrl, 'nextActionUrl' => $nextActionUrl, - 'summaryAssessment' => $summaryAssessment->toArray(), + 'summaryAssessment' => $aggregate->summaryAssessment->toArray(), ]; } + + private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->forTenant($tenant); + + return $aggregate; + } } diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php index aa0950f6..c6df00c2 100644 --- a/app/Filament/Widgets/Dashboard/NeedsAttention.php +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -4,9 +4,9 @@ namespace App\Filament\Widgets\Dashboard; -use App\Models\Finding; use App\Models\Tenant; -use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; use Filament\Widgets\Widget; @@ -31,51 +31,15 @@ protected function getViewData(): array } $tenantId = (int) $tenant->getKey(); - $compareStats = BaselineCompareStats::forTenant($tenant); - $compareAssessment = $compareStats->summaryAssessment(); + $aggregate = $this->governanceAggregate($tenant); + $compareAssessment = $aggregate->summaryAssessment; $items = []; - $overdueOpenCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->whereIn('status', Finding::openStatusesForQuery()) - ->whereNotNull('due_at') - ->where('due_at', '<', now()) - ->count(); - - $lapsedGovernanceCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->where('status', Finding::STATUS_RISK_ACCEPTED) - ->where(function ($query): void { - $query - ->whereDoesntHave('findingException') - ->orWhereHas('findingException', function ($exceptionQuery): void { - $exceptionQuery->whereIn('current_validity_state', [ - \App\Models\FindingException::VALIDITY_EXPIRED, - \App\Models\FindingException::VALIDITY_REVOKED, - \App\Models\FindingException::VALIDITY_REJECTED, - \App\Models\FindingException::VALIDITY_MISSING_SUPPORT, - ]); - }); - }) - ->count(); - - $expiringGovernanceCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->where('status', Finding::STATUS_RISK_ACCEPTED) - ->whereHas('findingException', function ($query): void { - $query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING); - }) - ->count(); - - $highSeverityCount = (int) Finding::query() - ->where('tenant_id', $tenantId) - ->whereIn('status', Finding::openStatusesForQuery()) - ->whereIn('severity', [ - Finding::SEVERITY_HIGH, - Finding::SEVERITY_CRITICAL, - ]) - ->count(); + $overdueOpenCount = $aggregate->overdueOpenFindingsCount; + $lapsedGovernanceCount = $aggregate->lapsedGovernanceCount; + $expiringGovernanceCount = $aggregate->expiringGovernanceCount; + $highSeverityCount = $aggregate->highSeverityActiveFindingsCount; if ($lapsedGovernanceCount > 0) { $items[] = [ @@ -120,7 +84,7 @@ protected function getViewData(): array 'supportingMessage' => $compareAssessment->supportingMessage, 'badge' => 'Baseline', 'badgeColor' => $compareAssessment->tone, - 'nextStep' => $compareAssessment->nextActionLabel(), + 'nextStep' => $aggregate->nextActionLabel, ]; } @@ -145,7 +109,7 @@ protected function getViewData(): array $healthyChecks = [ [ 'title' => 'Baseline compare looks trustworthy', - 'body' => $compareAssessment->headline, + 'body' => $aggregate->headline, ], [ 'title' => 'No overdue findings', @@ -172,4 +136,15 @@ protected function getViewData(): array 'healthyChecks' => $healthyChecks, ]; } + + private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->forTenant($tenant); + + return $aggregate; + } } diff --git a/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php b/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php index 9460d6f4..4046f85e 100644 --- a/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php +++ b/app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php @@ -6,7 +6,8 @@ use App\Filament\Pages\BaselineCompareLanding; use App\Models\Tenant; -use App\Support\Baselines\BaselineCompareStats; +use App\Support\Baselines\TenantGovernanceAggregate; +use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\OperationRunLinks; use Filament\Facades\Filament; use Filament\Widgets\Widget; @@ -30,31 +31,39 @@ protected function getViewData(): array ]; } - $stats = BaselineCompareStats::forTenant($tenant); - $summaryAssessment = $stats->summaryAssessment(); - $runUrl = null; - - if ($stats->operationRunId !== null) { - $runUrl = OperationRunLinks::view($stats->operationRunId, $tenant); - } + $aggregate = $this->governanceAggregate($tenant); + $runUrl = $aggregate->stats->operationRunId !== null + ? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant) + : null; $landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); - $nextActionUrl = match ($summaryAssessment->nextActionTarget()) { + $nextActionUrl = match ($aggregate->nextActionTarget) { 'run' => $runUrl, 'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant), 'landing' => $landingUrl, default => null, }; - $shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true) - || $summaryAssessment->stateFamily === 'action_required'; + $shouldShow = in_array($aggregate->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true) + || $aggregate->stateFamily === 'action_required'; return [ 'shouldShow' => $shouldShow, 'landingUrl' => $landingUrl, 'runUrl' => $runUrl, 'nextActionUrl' => $nextActionUrl, - 'summaryAssessment' => $summaryAssessment->toArray(), - 'state' => $stats->state, + 'summaryAssessment' => $aggregate->summaryAssessment->toArray(), + 'state' => $aggregate->compareState, ]; } + + private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + /** @var TenantGovernanceAggregateResolver $resolver */ + $resolver = app(TenantGovernanceAggregateResolver::class); + + /** @var TenantGovernanceAggregate $aggregate */ + $aggregate = $resolver->forTenant($tenant); + + return $aggregate; + } } diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index 0b802431..0138c851 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -825,6 +825,35 @@ public function summaryAssessment(): BaselineCompareSummaryAssessment return $assessor->assess($this); } + public function toTenantGovernanceAggregate(Tenant $tenant): TenantGovernanceAggregate + { + $summaryAssessment = $this->summaryAssessment(); + + return new TenantGovernanceAggregate( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + profileName: $this->profileName, + compareState: $this->state, + stateFamily: $summaryAssessment->stateFamily, + tone: $summaryAssessment->tone, + headline: $summaryAssessment->headline, + supportingMessage: $summaryAssessment->supportingMessage, + reasonCode: $summaryAssessment->reasonCode, + lastComparedLabel: $summaryAssessment->lastComparedLabel, + visibleDriftFindingsCount: $summaryAssessment->findingsVisibleCount, + overdueOpenFindingsCount: $this->overdueOpenFindingsCount, + expiringGovernanceCount: $this->expiringGovernanceCount, + lapsedGovernanceCount: $this->lapsedGovernanceCount, + activeNonNewFindingsCount: $this->activeNonNewFindingsCount, + highSeverityActiveFindingsCount: $this->highSeverityActiveFindingsCount, + nextActionLabel: $summaryAssessment->nextActionLabel(), + nextActionTarget: $summaryAssessment->nextActionTarget(), + positiveClaimAllowed: $summaryAssessment->positiveClaimAllowed, + stats: $this, + summaryAssessment: $summaryAssessment, + ); + } + /** * @return arraytenantId <= 0) { + throw new InvalidArgumentException('Tenant governance aggregates require a positive tenant id.'); + } + + if ($this->workspaceId <= 0) { + throw new InvalidArgumentException('Tenant governance aggregates require a positive workspace id.'); + } + + if (trim($this->compareState) === '') { + throw new InvalidArgumentException('Tenant governance aggregates require a compare state.'); + } + + if (trim($this->headline) === '') { + throw new InvalidArgumentException('Tenant governance aggregates require a headline.'); + } + + if (trim($this->nextActionLabel) === '') { + throw new InvalidArgumentException('Tenant governance aggregates require a next-action label.'); + } + + if (! in_array($this->nextActionTarget, [ + BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS, + BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING, + BaselineCompareSummaryAssessment::NEXT_TARGET_RUN, + BaselineCompareSummaryAssessment::NEXT_TARGET_NONE, + ], true)) { + throw new InvalidArgumentException('Tenant governance aggregates require a supported next-action target.'); + } + } + + /** + * @return array{ + * tenantId: int, + * workspaceId: int, + * profileName: ?string, + * compareState: string, + * stateFamily: string, + * tone: string, + * headline: string, + * supportingMessage: ?string, + * reasonCode: ?string, + * lastComparedLabel: ?string, + * visibleDriftFindingsCount: int, + * overdueOpenFindingsCount: int, + * expiringGovernanceCount: int, + * lapsedGovernanceCount: int, + * activeNonNewFindingsCount: int, + * highSeverityActiveFindingsCount: int, + * nextActionLabel: string, + * nextActionTarget: string, + * positiveClaimAllowed: bool + * } + */ + public function toArray(): array + { + return [ + 'tenantId' => $this->tenantId, + 'workspaceId' => $this->workspaceId, + 'profileName' => $this->profileName, + 'compareState' => $this->compareState, + 'stateFamily' => $this->stateFamily, + 'tone' => $this->tone, + 'headline' => $this->headline, + 'supportingMessage' => $this->supportingMessage, + 'reasonCode' => $this->reasonCode, + 'lastComparedLabel' => $this->lastComparedLabel, + 'visibleDriftFindingsCount' => $this->visibleDriftFindingsCount, + 'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount, + 'expiringGovernanceCount' => $this->expiringGovernanceCount, + 'lapsedGovernanceCount' => $this->lapsedGovernanceCount, + 'activeNonNewFindingsCount' => $this->activeNonNewFindingsCount, + 'highSeverityActiveFindingsCount' => $this->highSeverityActiveFindingsCount, + 'nextActionLabel' => $this->nextActionLabel, + 'nextActionTarget' => $this->nextActionTarget, + 'positiveClaimAllowed' => $this->positiveClaimAllowed, + ]; + } +} diff --git a/app/Support/Baselines/TenantGovernanceAggregateResolver.php b/app/Support/Baselines/TenantGovernanceAggregateResolver.php new file mode 100644 index 00000000..80265e0d --- /dev/null +++ b/app/Support/Baselines/TenantGovernanceAggregateResolver.php @@ -0,0 +1,71 @@ +resolveAggregate( + tenant: $tenant, + resolver: fn (): TenantGovernanceAggregate => BaselineCompareStats::forTenant($tenant) + ->toTenantGovernanceAggregate($tenant), + fresh: $fresh, + ); + } + + public function fromStats(?Tenant $tenant, BaselineCompareStats $stats, bool $fresh = false): ?TenantGovernanceAggregate + { + if (! $tenant instanceof Tenant) { + return null; + } + + return $this->resolveAggregate( + tenant: $tenant, + resolver: fn (): TenantGovernanceAggregate => $stats->toTenantGovernanceAggregate($tenant), + fresh: $fresh, + ); + } + + private function resolveAggregate(Tenant $tenant, callable $resolver, bool $fresh = false): TenantGovernanceAggregate + { + $key = DerivedStateKey::fromModel( + DerivedStateFamily::TenantGovernanceAggregate, + $tenant, + self::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ); + + $value = $fresh + ? $this->derivedStateStore->resolveFresh( + $key, + $resolver, + DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(), + DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(), + ) + : $this->derivedStateStore->resolve( + $key, + $resolver, + DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(), + DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(), + ); + + return $value; + } +} diff --git a/app/Support/Ui/DerivedState/DerivedStateFamily.php b/app/Support/Ui/DerivedState/DerivedStateFamily.php index baddc2ba..357853a8 100644 --- a/app/Support/Ui/DerivedState/DerivedStateFamily.php +++ b/app/Support/Ui/DerivedState/DerivedStateFamily.php @@ -12,10 +12,14 @@ enum DerivedStateFamily: string case RelatedNavigationPrimary = 'related_navigation_primary'; case RelatedNavigationDetail = 'related_navigation_detail'; case RelatedNavigationHeader = 'related_navigation_header'; + case TenantGovernanceAggregate = 'tenant_governance_aggregate'; public function allowsNegativeResultCache(): bool { - return true; + return match ($this) { + self::TenantGovernanceAggregate => false, + default => true, + }; } public function defaultFreshnessPolicy(): string diff --git a/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml b/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml index a46e61fb..19e27e2c 100644 --- a/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml +++ b/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml @@ -296,6 +296,86 @@ x-derived-state-consumers: max: 1 - needle: '->operationLinksFresh($this->run, $this->relatedLinksTenant())' max: 1 + - surface: tenant.dashboard.baseline_governance + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: widget_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Widgets/Dashboard/BaselineCompareNow.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant)' + - 'summaryAssessment' + maxOccurrences: + - needle: 'BaselineCompareStats::forTenant(' + max: 0 + - surface: tenant.banner.baseline_compare_coverage + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: widget_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant)' + - 'nextActionUrl' + maxOccurrences: + - needle: 'BaselineCompareStats::forTenant(' + max: 0 + - surface: tenant.page.baseline_compare_landing + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: page_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Pages/BaselineCompareLanding.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant, $stats)' + - 'Compare now' + maxOccurrences: + - needle: 'BaselineCompareStats::forTenant(' + max: 1 + - needle: '$stats->summaryAssessment()' + max: 0 + - surface: tenant.dashboard.needs_attention + family: tenant_governance_aggregate + variant: tenant_governance_summary + accessPattern: widget_safe + scopeInputs: + - record_class + - record_key + - workspace_id + - tenant_id + freshnessPolicy: invalidate_after_mutation + guardScope: + - app/Filament/Widgets/Dashboard/NeedsAttention.php + requiredMarkers: + - 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate' + - '$this->governanceAggregate($tenant)' + - 'Baseline compare posture' + maxOccurrences: + - needle: 'Finding::query()' + max: 0 + - needle: 'BaselineCompareStats::forTenant(' + max: 0 paths: /contracts/derived-state/resolve: post: @@ -514,6 +594,7 @@ components: - row_safe - page_safe - direct_once + - widget_safe scopeInputs: type: array description: Scope or capability inputs that affect the result for this consumer. diff --git a/tests/Feature/Baselines/BaselineCompareStatsTest.php b/tests/Feature/Baselines/BaselineCompareStatsTest.php index 25a59695..52fdb9c4 100644 --- a/tests/Feature/Baselines/BaselineCompareStatsTest.php +++ b/tests/Feature/Baselines/BaselineCompareStatsTest.php @@ -6,6 +6,7 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareStats; @@ -335,3 +336,119 @@ expect($stats->findingsCount)->toBe(1) ->and($stats->severityCounts['high'])->toBe(1); }); + +it('returns governance attention counts from current findings truth', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $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(), + ]); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + Finding::factory()->triaged()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => now()->subDay(), + ]); + + $expiringFinding = Finding::factory()->riskAccepted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $expiringFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'approved_by_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_EXPIRING, + 'current_validity_state' => FindingException::VALIDITY_EXPIRING, + 'request_reason' => 'Expiring governance coverage', + 'approval_reason' => 'Approved for coverage', + 'requested_at' => now()->subDays(2), + 'approved_at' => now()->subDay(), + 'effective_from' => now()->subDay(), + 'expires_at' => now()->addDays(2), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $lapsedFinding = Finding::factory()->riskAccepted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $lapsedFinding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'approved_by_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_EXPIRED, + 'current_validity_state' => FindingException::VALIDITY_EXPIRED, + 'request_reason' => 'Expired governance coverage', + 'approval_reason' => 'Approved for coverage', + 'requested_at' => now()->subDays(3), + 'approved_at' => now()->subDays(2), + 'effective_from' => now()->subDays(2), + 'expires_at' => now()->subDay(), + 'review_due_at' => now()->subDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + Finding::factory()->inProgress()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + Finding::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_NEW, + ]); + + $stats = BaselineCompareStats::forTenant($tenant); + + expect($stats->overdueOpenFindingsCount)->toBe(1) + ->and($stats->expiringGovernanceCount)->toBe(1) + ->and($stats->lapsedGovernanceCount)->toBe(1) + ->and($stats->activeNonNewFindingsCount)->toBe(2) + ->and($stats->highSeverityActiveFindingsCount)->toBe(1); +}); diff --git a/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php b/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php index 4ad1aa06..a86f46b0 100644 --- a/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php +++ b/tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php @@ -6,6 +6,7 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareStats; @@ -246,3 +247,84 @@ function createAssignedBaselineTenant(): array ->and($assessment->headline)->toContain('Accepted-risk governance has lapsed') ->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS); }); + +it('maps unavailable compare prerequisites to baseline prerequisite guidance', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'active_snapshot_id' => null, + ]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment(); + + expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_UNAVAILABLE) + ->and($assessment->headline)->toBe('The current baseline snapshot is not available for compare.') + ->and($assessment->nextActionLabel())->toBe('Review baseline prerequisites') + ->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING); +}); + +it('treats expiring governance as action required even with zero compare findings', function (): void { + [$tenant, $profile, $snapshot] = createAssignedBaselineTenant(); + [$user] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + $finding = Finding::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'status' => Finding::STATUS_RISK_ACCEPTED, + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'approved_by_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_EXPIRING, + 'current_validity_state' => FindingException::VALIDITY_EXPIRING, + 'request_reason' => 'Expiring governance coverage', + 'approval_reason' => 'Approved for coverage', + 'requested_at' => now()->subDays(2), + 'approved_at' => now()->subDay(), + 'effective_from' => now()->subDay(), + 'expires_at' => now()->addDays(2), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment(); + + expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($assessment->expiringGovernanceCount)->toBe(1) + ->and($assessment->headline)->toContain('Accepted-risk governance is nearing expiry') + ->and($assessment->nextActionLabel())->toBe('Open findings'); +}); diff --git a/tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php b/tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php new file mode 100644 index 00000000..b07afe3b --- /dev/null +++ b/tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php @@ -0,0 +1,326 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Aggregate Baseline', + ]); + + $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(), + ]); + + return [$user, $tenant, $profile, $snapshot]; +} + +/** + * @param array $attributes + * @param array $compareContext + */ +function seedTenantGovernanceAggregateRun(Tenant $tenant, BaselineProfile $profile, BaselineSnapshot $snapshot, array $attributes = [], array $compareContext = []): OperationRun +{ + return OperationRun::factory()->create(array_replace_recursive([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => array_replace_recursive([ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], $compareContext), + ], + ], $attributes)); +} + +function createTenantGovernanceException(Tenant $tenant, Finding $finding, User $user, string $status, string $validityState, ?\Carbon\CarbonInterface $expiresAt = null): void +{ + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'approved_by_user_id' => (int) $user->getKey(), + 'status' => $status, + 'current_validity_state' => $validityState, + 'request_reason' => 'Exception created for tenant governance aggregate coverage', + 'approval_reason' => 'Approved for test coverage', + 'requested_at' => now()->subDays(2), + 'approved_at' => now()->subDay(), + 'effective_from' => now()->subDay(), + 'expires_at' => $expiresAt, + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); +} + +it('resolves an unavailable governance aggregate when the assigned baseline has no snapshot', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'active_snapshot_id' => null, + ]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->compareState)->toBe('no_snapshot') + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_UNAVAILABLE) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING) + ->and($aggregate?->headline)->toBe('The current baseline snapshot is not available for compare.'); +}); + +it('resolves an in-progress governance aggregate for queued compare runs', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'completed_at' => null, + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->compareState)->toBe('comparing') + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_IN_PROGRESS) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN) + ->and($aggregate?->headline)->toBe('Baseline compare is in progress.'); +}); + +it('resolves a failed governance aggregate with run follow-up', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'outcome' => OperationRunOutcome::Failed->value, + 'failure_summary' => ['message' => 'Graph API timeout'], + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN) + ->and($aggregate?->nextActionLabel)->toBe('Review the failed run') + ->and($aggregate?->headline)->toBe('The latest baseline compare failed before it produced a usable result.'); +}); + +it('resolves an action-required aggregate when open drift findings remain', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + Finding::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'source' => 'baseline.compare', + 'scope_key' => 'baseline_profile:'.$profile->getKey(), + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_NEW, + ]); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->visibleDriftFindingsCount)->toBe(1) + ->and($aggregate?->highSeverityActiveFindingsCount)->toBe(1) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS); +}); + +it('resolves overdue workflow pressure as action required even with zero visible drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + Finding::factory()->triaged()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => now()->subDay(), + ]); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->overdueOpenFindingsCount)->toBe(1) + ->and($aggregate?->visibleDriftFindingsCount)->toBe(0) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS) + ->and($aggregate?->headline)->toContain('overdue finding'); +}); + +it('resolves lapsed governance as action required even with zero visible drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + $finding = Finding::factory()->riskAccepted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + createTenantGovernanceException( + tenant: $tenant, + finding: $finding, + user: $user, + status: FindingException::STATUS_EXPIRED, + validityState: FindingException::VALIDITY_EXPIRED, + expiresAt: now()->subDay(), + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->lapsedGovernanceCount)->toBe(1) + ->and($aggregate?->visibleDriftFindingsCount)->toBe(0) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS) + ->and($aggregate?->headline)->toContain('Accepted-risk governance has lapsed'); +}); + +it('resolves expiring governance into the shared action-required contract', function (): void { + [$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + $finding = Finding::factory()->riskAccepted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + createTenantGovernanceException( + tenant: $tenant, + finding: $finding, + user: $user, + status: FindingException::STATUS_EXPIRING, + validityState: FindingException::VALIDITY_EXPIRING, + expiresAt: now()->addDays(2), + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED) + ->and($aggregate?->expiringGovernanceCount)->toBe(1) + ->and($aggregate?->nextActionLabel)->toBe('Open findings'); +}); + +it('resolves limited-confidence zero findings into a caution aggregate', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'outcome' => OperationRunOutcome::PartiallySucceeded->value, + ], + compareContext: [ + 'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => ['deviceCompliancePolicy'], + 'proof' => false, + ], + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_CAUTION) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN) + ->and($aggregate?->headline)->toBe('The last compare finished, but normal result output was suppressed.'); +}); + +it('resolves stale no-drift compare results into a stale aggregate', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + attributes: [ + 'completed_at' => now()->subDays(10), + ], + ); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_STALE) + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING) + ->and($aggregate?->headline)->toBe('The latest baseline compare result is stale.'); +}); + +it('resolves trustworthy no-drift results into a positive all-clear aggregate', function (): void { + [, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant(); + + seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot); + + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant); + + expect($aggregate)->not->toBeNull() + ->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_POSITIVE) + ->and($aggregate?->positiveClaimAllowed)->toBeTrue() + ->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_NONE) + ->and($aggregate?->headline)->toBe('No confirmed drift in the latest baseline compare.'); +}); diff --git a/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php b/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php index af1304d7..5184689c 100644 --- a/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php +++ b/tests/Feature/Filament/BaselineCompareCoverageBannerTest.php @@ -6,6 +6,7 @@ use App\Models\BaselineProfile; use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; +use App\Models\Finding; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\OperationRunOutcome; @@ -128,3 +129,43 @@ function createCoverageBannerTenant(): array ->assertDontSee('No confirmed drift in the latest baseline compare.') ->assertDontSee('Review compare detail'); }); + +it('shows an action banner when overdue findings remain without new drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createCoverageBannerTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + Finding::factory()->triaged()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => now()->subDay(), + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::test(BaselineCompareCoverageBanner::class) + ->assertSee('overdue finding') + ->assertSee('Open findings'); +}); diff --git a/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php b/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php index 7fada607..d68739e3 100644 --- a/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php +++ b/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php @@ -8,6 +8,7 @@ use App\Models\BaselineProfile; use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; +use App\Models\Finding; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\OperationRunOutcome; @@ -16,6 +17,30 @@ use Filament\Facades\Filament; use Livewire\Livewire; +function createBaselineCompareSummaryConsistencyTenant(): array +{ + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $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(), + ]); + + return [$user, $tenant, $profile, $snapshot]; +} + it('keeps widget, landing, and banner equally cautious for the same limited-confidence compare result', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); @@ -77,3 +102,87 @@ ->assertSee('The last compare finished, but normal result output was suppressed.') ->assertSee('Review compare detail'); }); + +it('keeps widget, landing, and banner aligned when overdue workflow remains without new drift', function (): void { + [$user, $tenant, $profile, $snapshot] = createBaselineCompareSummaryConsistencyTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + Finding::factory()->triaged()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'due_at' => now()->subDay(), + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::test(BaselineCompareNow::class) + ->assertSee('Action required') + ->assertSee('overdue finding') + ->assertSee('Open findings'); + + Livewire::test(BaselineCompareLanding::class) + ->assertSee('Action required') + ->assertSee('overdue finding') + ->assertSee('Open findings'); + + Livewire::test(BaselineCompareCoverageBanner::class) + ->assertSee('overdue finding') + ->assertSee('Open findings'); +}); + +it('keeps widget, landing, and banner aligned while compare is still running', function (): void { + [$user, $tenant, $profile, $snapshot] = createBaselineCompareSummaryConsistencyTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + ], + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::test(BaselineCompareNow::class) + ->assertSee('In progress') + ->assertSee('Baseline compare is in progress.') + ->assertSee('View run'); + + Livewire::test(BaselineCompareLanding::class) + ->assertSee('In progress') + ->assertSee('Baseline compare is in progress.') + ->assertSee('View run'); + + Livewire::test(BaselineCompareCoverageBanner::class) + ->assertSee('Baseline compare is in progress.') + ->assertSee('View run'); +}); diff --git a/tests/Feature/Filament/NeedsAttentionWidgetTest.php b/tests/Feature/Filament/NeedsAttentionWidgetTest.php index 681532ea..7f625376 100644 --- a/tests/Feature/Filament/NeedsAttentionWidgetTest.php +++ b/tests/Feature/Filament/NeedsAttentionWidgetTest.php @@ -7,6 +7,7 @@ use App\Models\BaselineSnapshot; use App\Models\BaselineTenantAssignment; use App\Models\Finding; +use App\Models\FindingException; use App\Models\OperationRun; use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\OperationRunOutcome; @@ -193,3 +194,64 @@ function createNeedsAttentionTenant(): array ->assertSee('Lapsed accepted-risk governance') ->assertDontSee('Current dashboard signals look trustworthy.'); }); + +it('surfaces expiring governance from the shared aggregate without adding navigation links', function (): void { + [$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant(); + $this->actingAs($user); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + $finding = Finding::factory()->riskAccepted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'approved_by_user_id' => (int) $user->getKey(), + 'status' => FindingException::STATUS_EXPIRING, + 'current_validity_state' => FindingException::VALIDITY_EXPIRING, + 'request_reason' => 'Expiring governance coverage', + 'approval_reason' => 'Approved for coverage', + 'requested_at' => now()->subDays(2), + 'approved_at' => now()->subDay(), + 'effective_from' => now()->subDay(), + 'expires_at' => now()->addDays(2), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + $component = Livewire::test(NeedsAttention::class) + ->assertSee('Expiring accepted-risk governance') + ->assertSee('Open findings') + ->assertDontSee('Current dashboard signals look trustworthy.'); + + expect($component->html())->not->toContain('href='); +}); diff --git a/tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php b/tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php new file mode 100644 index 00000000..61bf6bd8 --- /dev/null +++ b/tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php @@ -0,0 +1,122 @@ +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(), + ]); + + \App\Models\OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => OperationRunType::BaselineCompare->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => [], + 'proof' => true, + ], + ], + ], + ]); + + return [$user, $tenant]; +} + +it('reuses one tenant-governance aggregate across the tenant dashboard summary widgets', function (): void { + [$user, $tenant] = createTenantGovernanceMemoizationTenant(); + + $this->actingAs($user); + Filament::setCurrentPanel(Filament::getPanel('tenant')); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user)->test(NeedsAttention::class); + Livewire::actingAs($user)->test(BaselineCompareNow::class); + + expect(app(RequestScopedDerivedStateStore::class)->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + Tenant::class, + (string) $tenant->getKey(), + TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ))->toBe(1); +}); + +it('keeps tenant switches from reusing another tenant aggregate in the same request scope', function (): void { + [, $tenantA] = createTenantGovernanceMemoizationTenant(); + [, $tenantB] = createTenantGovernanceMemoizationTenant(); + + $resolver = app(TenantGovernanceAggregateResolver::class); + + $aggregateA = $resolver->forTenant($tenantA); + $aggregateB = $resolver->forTenant($tenantB); + + $store = app(RequestScopedDerivedStateStore::class); + + expect($aggregateA)->not->toBeNull() + ->and($aggregateB)->not->toBeNull() + ->and($aggregateA?->tenantId)->toBe((int) $tenantA->getKey()) + ->and($aggregateB?->tenantId)->toBe((int) $tenantB->getKey()) + ->and($aggregateA?->tenantId)->not->toBe($aggregateB?->tenantId) + ->and($store->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + Tenant::class, + (string) $tenantA->getKey(), + TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ))->toBe(1) + ->and($store->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + Tenant::class, + (string) $tenantB->getKey(), + TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY, + ))->toBe(1); +}); + +it('returns no aggregate and stores nothing when no tenant context exists', function (): void { + $aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant(null); + + expect($aggregate)->toBeNull() + ->and(app(RequestScopedDerivedStateStore::class)->countStored( + DerivedStateFamily::TenantGovernanceAggregate, + ))->toBe(0); +}); diff --git a/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php b/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php index e003e28f..257c7e71 100644 --- a/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php +++ b/tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php @@ -22,8 +22,9 @@ 'related_navigation_primary', 'related_navigation_detail', 'related_navigation_header', + 'tenant_governance_aggregate', ]; - $allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once']; + $allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once', 'widget_safe']; $allowedFreshnessPolicies = ['request_stable', 'invalidate_after_mutation', 'no_reuse']; $cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i'; $violations = []; -- 2.45.2 From d39d6f098246eca2934f08d7a80ac39cbce7e9d3 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 29 Mar 2026 23:12:24 +0200 Subject: [PATCH 5/6] refactor: normalize filament action surfaces --- .../Resources/AlertDeliveryResource.php | 5 +- .../Resources/AlertDestinationResource.php | 8 - app/Filament/Resources/AlertRuleResource.php | 8 - .../Resources/BackupScheduleResource.php | 8 +- ...upScheduleOperationRunsRelationManager.php | 37 +- .../BackupItemsRelationManager.php | 69 +- .../Resources/BaselineProfileResource.php | 14 +- .../Resources/EvidenceSnapshotResource.php | 49 +- .../Resources/OperationRunResource.php | 10 +- app/Filament/Resources/PolicyResource.php | 4 +- .../VersionsRelationManager.php | 9 +- .../Resources/ProviderConnectionResource.php | 2 +- app/Filament/Resources/RestoreRunResource.php | 23 +- app/Filament/Resources/ReviewPackResource.php | 4 +- app/Filament/Resources/TenantResource.php | 79 +- .../TenantMembershipsRelationManager.php | 42 + .../Resources/TenantReviewResource.php | 5 +- .../WorkspaceMembershipsRelationManager.php | 85 +- .../Workspaces/WorkspaceResource.php | 6 +- .../System/Pages/Directory/Tenants.php | 14 + .../System/Pages/Directory/Workspaces.php | 14 + app/Filament/System/Pages/Ops/Failures.php | 14 + app/Filament/System/Pages/Ops/Runs.php | 14 + app/Filament/System/Pages/Ops/Stuck.php | 14 + .../System/Pages/Security/AccessLogs.php | 13 + .../Tenants/TenantActionPolicySurface.php | 2 +- .../ActionSurface/ActionSurfaceExemptions.php | 3 - .../TenantOwnedModelFamilies.php | 4 +- tests/Browser/TenantMembershipsPageTest.php | 52 + ...ckupScheduleLifecycleAuthorizationTest.php | 41 + ...heduleOperationRunsRelationManagerTest.php | 4 +- .../Evidence/EvidenceSnapshotResourceTest.php | 12 +- .../Guards/ActionSurfaceContractTest.php | 1321 ++++++++++++++++- ...ackupItemsRelationManagerSemanticsTest.php | 15 +- .../TenantActionSurfaceConsistencyTest.php | 30 +- .../TenantLifecycleActionVisibilityTest.php | 1 - .../TenantReviewUiContractTest.php | 23 + .../Tenants/TenantActionPolicySurfaceTest.php | 13 +- 38 files changed, 1811 insertions(+), 260 deletions(-) create mode 100644 tests/Browser/TenantMembershipsPageTest.php diff --git a/app/Filament/Resources/AlertDeliveryResource.php b/app/Filament/Resources/AlertDeliveryResource.php index 066e59f1..de289651 100644 --- a/app/Filament/Resources/AlertDeliveryResource.php +++ b/app/Filament/Resources/AlertDeliveryResource.php @@ -22,7 +22,6 @@ use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions\Action; -use Filament\Actions\ViewAction; use Filament\Facades\Filament; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; @@ -322,9 +321,7 @@ public static function table(Table $table): Table }), FilterPresets::dateRange('created_at', 'Created', 'created_at'), ]) - ->actions([ - ViewAction::make()->label('View'), - ]) + ->actions([]) ->bulkActions([]) ->emptyStateHeading('No alert deliveries') ->emptyStateDescription('Deliveries appear automatically when alert rules fire.') diff --git a/app/Filament/Resources/AlertDestinationResource.php b/app/Filament/Resources/AlertDestinationResource.php index b9059d95..80206d7b 100644 --- a/app/Filament/Resources/AlertDestinationResource.php +++ b/app/Filament/Resources/AlertDestinationResource.php @@ -18,8 +18,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\BulkActionGroup; -use Filament\Actions\EditAction; use Filament\Facades\Filament; use Filament\Forms\Components\Select; use Filament\Forms\Components\TagsInput; @@ -191,9 +189,6 @@ public static function table(Table $table): Table ->since(), ]) ->actions([ - EditAction::make() - ->label('Edit') - ->visible(fn (AlertDestination $record): bool => static::canEdit($record)), ActionGroup::make([ Action::make('toggle_enabled') ->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable') @@ -253,9 +248,6 @@ public static function table(Table $table): Table }), ])->label('More'), ]) - ->bulkActions([ - BulkActionGroup::make([])->label('More'), - ]) ->emptyStateActions([ \Filament\Actions\CreateAction::make() ->label('Create target') diff --git a/app/Filament/Resources/AlertRuleResource.php b/app/Filament/Resources/AlertRuleResource.php index b22971b2..f17ab090 100644 --- a/app/Filament/Resources/AlertRuleResource.php +++ b/app/Filament/Resources/AlertRuleResource.php @@ -20,8 +20,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\BulkActionGroup; -use Filament\Actions\EditAction; use Filament\Facades\Filament; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; @@ -248,9 +246,6 @@ public static function table(Table $table): Table ->color(fn (bool $state): string => $state ? 'success' : 'gray'), ]) ->actions([ - EditAction::make() - ->label('Edit') - ->visible(fn (AlertRule $record): bool => static::canEdit($record)), ActionGroup::make([ Action::make('toggle_enabled') ->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable') @@ -311,9 +306,6 @@ public static function table(Table $table): Table }), ])->label('More'), ]) - ->bulkActions([ - BulkActionGroup::make([])->label('More'), - ]) ->emptyStateHeading('No alert rules') ->emptyStateDescription('Create a rule to route notifications when monitored events fire.') ->emptyStateIcon('heroicon-o-bell'); diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index d8460c98..7bc5b285 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -39,7 +39,6 @@ use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\EditAction; use Filament\Facades\Filament; use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Select; @@ -571,16 +570,12 @@ public static function table(Table $table): Table ->preserveVisibility() ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->apply(), - UiEnforcement::forAction( - EditAction::make() - ) - ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE) - ->apply(), UiEnforcement::forAction( Action::make('archive') ->label('Archive') ->icon('heroicon-o-archive-box-x-mark') ->color('danger') + ->requiresConfirmation() ->visible(fn (BackupSchedule $record): bool => ! $record->trashed()) ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { $record = static::resolveProtectedScheduleRecordOrFail($record); @@ -666,6 +661,7 @@ public static function table(Table $table): Table ->label('Force delete') ->icon('heroicon-o-trash') ->color('danger') + ->requiresConfirmation() ->visible(fn (BackupSchedule $record): bool => $record->trashed()) ->action(function (BackupSchedule $record, AuditLogger $auditLogger): void { $record = static::resolveProtectedScheduleRecordOrFail($record); diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php index 5217becb..44dbec94 100644 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php @@ -13,7 +13,6 @@ use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Closure; -use Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -25,25 +24,12 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager protected static ?string $title = 'Executions'; - /** - * @param array $arguments - * @param array $context - */ - public function mountAction(string $name, array $arguments = [], array $context = []): mixed - { - if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) { - $this->resolveOwnerScopedOperationRun($context['recordKey']); - } - - return parent::mountAction($name, $arguments, $context); - } - public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) ->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary because this relation manager has no secondary row actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.'); } @@ -54,6 +40,12 @@ public function table(Table $table): Table ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())) ->defaultSort('created_at', 'desc') ->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager()) + ->recordUrl(function (OperationRun $record): string { + $record = $this->resolveOwnerScopedOperationRun($record); + $tenant = Tenant::currentOrFail(); + + return OperationRunLinks::view($record, $tenant); + }) ->columns([ Tables\Columns\TextColumn::make('created_at') ->label('Enqueued') @@ -96,18 +88,7 @@ public function table(Table $table): Table ]) ->filters([]) ->headerActions([]) - ->actions([ - Actions\Action::make('view') - ->label('View') - ->icon('heroicon-o-eye') - ->url(function (OperationRun $record): string { - $record = $this->resolveOwnerScopedOperationRun($record); - $tenant = Tenant::currentOrFail(); - - return OperationRunLinks::view($record, $tenant); - }) - ->openUrlInNewTab(true), - ]) + ->actions([]) ->bulkActions([]) ->emptyStateHeading('No schedule runs yet') ->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.'); diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 6ba9c74f..256bd6e9 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -6,6 +6,7 @@ use App\Filament\Resources\PolicyVersionResource; use App\Jobs\RemovePoliciesFromBackupSetJob; use App\Models\BackupItem; +use App\Models\BackupSet; use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; @@ -21,6 +22,10 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -64,6 +69,16 @@ public function mountAction(string $name, array $arguments = [], array $context return parent::mountAction($name, $arguments, $context); } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Refresh and Add Policies actions are available in the relation header.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Remove remains grouped under More.') + ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk remove remains grouped under More.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the Add Policies CTA.'); + } + public function table(Table $table): Table { $refreshTable = Actions\Action::make('refreshTable') @@ -257,6 +272,7 @@ public function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (BackupItem $record): ?string => $this->backupItemInspectUrl($record)) ->columns([ Tables\Columns\TextColumn::make('policy.display_name') ->label('Item') @@ -358,23 +374,6 @@ public function table(Table $table): Table ]) ->actions([ Actions\ActionGroup::make([ - Actions\ViewAction::make() - ->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy') - ->url(function (BackupItem $record): ?string { - $tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current(); - - if ($record->policy_version_id) { - return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant); - } - - if (! $record->policy_id) { - return null; - } - - return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant); - }) - ->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id) - ->openUrlInNewTab(true), $removeItem, ]) ->label('More') @@ -449,7 +448,39 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu return $query->whereIn('policy_type', $types); } - private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int + private function backupItemInspectUrl(BackupItem $record): ?string + { + $backupSet = $this->getOwnerRecord(); + $resolvedId = $this->resolveOwnerScopedBackupItemId($backupSet, $record); + + $resolvedRecord = $backupSet->items() + ->with(['policy', 'policyVersion', 'policyVersion.policy']) + ->where('tenant_id', (int) $backupSet->tenant_id) + ->whereKey($resolvedId) + ->first(); + + if (! $resolvedRecord instanceof BackupItem) { + abort(404); + } + + $tenant = $backupSet->tenant ?? Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + if ($resolvedRecord->policy_version_id) { + return PolicyVersionResource::getUrl('view', ['record' => $resolvedRecord->policy_version_id], tenant: $tenant); + } + + if (! $resolvedRecord->policy_id) { + return null; + } + + return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant); + } + + private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int { $recordId = $this->normalizeBackupItemKey($record); @@ -472,7 +503,7 @@ private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet /** * @return array */ - private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array + private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, array $recordKeys): array { $requestedIds = collect($recordKeys) ->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record)) diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php index 8ef586df..6b4e2844 100644 --- a/app/Filament/Resources/BaselineProfileResource.php +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -36,7 +36,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Select; @@ -131,8 +130,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while edit and archive remain grouped under "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.'); @@ -340,6 +339,7 @@ public static function table(Table $table): Table return $table ->defaultSort('name') ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->recordUrl(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record])) ->columns([ TextColumn::make('name') ->searchable() @@ -412,10 +412,6 @@ public static function table(Table $table): Table ->options(FilterOptionCatalog::baselineProfileStatuses()), ]) ->actions([ - Action::make('view') - ->label('View') - ->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record])) - ->icon('heroicon-o-eye'), ActionGroup::make([ Action::make('edit') ->label('Edit') @@ -425,9 +421,7 @@ public static function table(Table $table): Table self::archiveTableAction($workspace), ])->label('More'), ]) - ->bulkActions([ - BulkActionGroup::make([])->label('More'), - ]) + ->bulkActions([]) ->emptyStateHeading('No baseline profiles') ->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.') ->emptyStateActions([ diff --git a/app/Filament/Resources/EvidenceSnapshotResource.php b/app/Filament/Resources/EvidenceSnapshotResource.php index e7765d4d..81bac1ef 100644 --- a/app/Filament/Resources/EvidenceSnapshotResource.php +++ b/app/Filament/Resources/EvidenceSnapshotResource.php @@ -112,7 +112,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.') - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.') + ->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.'); } @@ -257,33 +257,32 @@ public static function table(Table $table): Table ->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())), ]) ->actions([ - Actions\Action::make('view_snapshot') - ->label('View snapshot') - ->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])), - UiEnforcement::forTableAction( - Actions\Action::make('expire') - ->label('Expire snapshot') - ->color('danger') - ->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record)) - ->requiresConfirmation() - ->action(function (EvidenceSnapshot $record): void { - $user = auth()->user(); + Actions\ActionGroup::make([ + UiEnforcement::forTableAction( + Actions\Action::make('expire') + ->label('Expire snapshot') + ->color('danger') + ->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record)) + ->requiresConfirmation() + ->action(function (EvidenceSnapshot $record): void { + $user = auth()->user(); - if (! $user instanceof User) { - abort(403); - } + if (! $user instanceof User) { + abort(403); + } - app(EvidenceSnapshotService::class)->expire($record, $user); - static::truthEnvelope($record->refresh(), fresh: true); + app(EvidenceSnapshotService::class)->expire($record, $user); + static::truthEnvelope($record->refresh(), fresh: true); - Notification::make()->success()->title('Snapshot expired')->send(); - }), - fn (EvidenceSnapshot $record): EvidenceSnapshot => $record, - ) - ->preserveVisibility() - ->requireCapability(Capabilities::EVIDENCE_MANAGE) - ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) - ->apply(), + Notification::make()->success()->title('Snapshot expired')->send(); + }), + fn (EvidenceSnapshot $record): EvidenceSnapshot => $record, + ) + ->preserveVisibility() + ->requireCapability(Capabilities::EVIDENCE_MANAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply(), + ])->label('More'), ]) ->bulkActions([]) ->emptyStateHeading('No evidence snapshots yet') diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 71fb2579..770912ca 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -38,7 +38,6 @@ use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; -use Filament\Actions; use Filament\Facades\Filament; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -77,7 +76,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ActionSurfaceSlot::ListHeader, 'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.', ) - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->exempt( ActionSurfaceSlot::ListBulkMoreGroup, 'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.', @@ -128,6 +127,7 @@ public static function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)) ->columns([ Tables\Columns\TextColumn::make('status') ->badge() @@ -242,11 +242,7 @@ public static function table(Table $table): Table 'until' => now()->toDateString(), ]), ]) - ->actions([ - Actions\ViewAction::make() - ->label('View run') - ->url(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)), - ]) + ->actions([]) ->bulkActions([]) ->emptyStateHeading('No operation runs found') ->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 8d7337cd..a84c7074 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -99,7 +99,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.') @@ -365,6 +365,7 @@ public static function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\Columns\TextColumn::make('display_name') ->label('Policy') @@ -490,7 +491,6 @@ public static function table(Table $table): Table ->all()), ]) ->actions([ - Actions\ViewAction::make(), ActionGroup::make([ UiEnforcement::forTableAction( Actions\Action::make('ignore') diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index bbec6ee7..fc6504eb 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\PolicyResource\RelationManagers; use App\Filament\Concerns\ResolvesPanelTenantContext; +use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\RestoreRunResource; use App\Models\Policy; use App\Models\PolicyVersion; @@ -49,8 +50,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) ->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while restore remains the only inline row shortcut.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.'); } @@ -181,13 +182,11 @@ public function table(Table $table): Table ]) ->defaultSort('version_number', 'desc') ->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager()) + ->recordUrl(fn (PolicyVersion $record): string => PolicyVersionResource::getUrl('view', ['record' => $record])) ->filters([]) ->headerActions([]) ->actions([ $restoreToIntune, - Actions\ViewAction::make() - ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) - ->openUrlInNewTab(false), ]) ->bulkActions([]) ->emptyStateHeading('No versions captured') diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index fa42a96f..b434ed28 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -82,7 +82,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) - ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Edit and provider operations are grouped under "More" while clickable-row view remains primary.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.') ->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.'); diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 0b784776..741e6b28 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -37,6 +37,10 @@ use App\Support\Rbac\UiEnforcement; use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunStatus; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -103,6 +107,17 @@ public static function canCreate(): bool && $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); } + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Create restore run is available from the list header whenever records already exist.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while rerun and archive lifecycle actions stay grouped under More.') + ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk restore-run maintenance actions are grouped under More.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the New restore run CTA.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header actions.'); + } + public static function form(Schema $schema): Schema { return $schema @@ -862,8 +877,8 @@ public static function table(Table $table): Table FilterPresets::dateRange('started_at', 'Started', 'started_at'), FilterPresets::archived(), ]) + ->recordUrl(fn (RestoreRun $record): string => static::getUrl('view', ['record' => $record])) ->actions([ - Actions\ViewAction::make(), ActionGroup::make([ static::rerunActionWithGate(), UiEnforcement::forTableAction( @@ -975,7 +990,9 @@ public static function table(Table $table): Table ->requireCapability(Capabilities::TENANT_DELETE) ->preserveVisibility() ->apply(), - ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->label('More') + ->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ BulkActionGroup::make([ @@ -1232,7 +1249,7 @@ public static function table(Table $table): Table ) ->requireCapability(Capabilities::TENANT_DELETE) ->apply(), - ]), + ])->label('More'), ]) ->emptyStateHeading('No restore runs') ->emptyStateDescription('Start a restoration from a backup set.') diff --git a/app/Filament/Resources/ReviewPackResource.php b/app/Filament/Resources/ReviewPackResource.php index a446df3e..c06f461e 100644 --- a/app/Filament/Resources/ReviewPackResource.php +++ b/app/Filament/Resources/ReviewPackResource.php @@ -99,9 +99,9 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.') - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download and Expire remain direct row shortcuts.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.'); } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 6497e7a4..1e82df61 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -146,10 +146,10 @@ public static function canDeleteAny(): bool public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) - ->withListRowPrimaryActionLimit(2) + ->withListRowPrimaryActionLimit(1) ->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; lifecycle-adjacent and destructive actions move under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.'); @@ -245,6 +245,7 @@ public static function table(Table $table): Table ->persistFiltersInSession() ->persistSearchInSession() ->persistSortInSession() + ->recordUrl(fn (Tenant $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() @@ -317,49 +318,11 @@ public static function table(Table $table): Table ]), ]) ->actions([ - Actions\Action::make('view') - ->label('View') - ->icon('heroicon-o-eye') - ->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])), Actions\Action::make('related_onboarding') ->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding') ->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path') ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'), - UiEnforcement::forAction( - Actions\Action::make('restore') - ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore') - ->color('success') - ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left') - ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored') - ->requiresConfirmation() - ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant') - ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.') - ->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore') - ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { - static::restoreTenant($record, $auditLogger); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_DELETE) - ->apply(), - UiEnforcement::forAction( - Actions\Action::make('archive') - ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive') - ->color('danger') - ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark') - ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived') - ->requiresConfirmation() - ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant') - ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.') - ->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive') - ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { - static::archiveTenant($record, $auditLogger); - }) - ) - ->preserveVisibility() - ->requireCapability(Capabilities::TENANT_DELETE) - ->apply(), ActionGroup::make([ Actions\Action::make('related_onboarding_overflow') ->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding') @@ -367,6 +330,40 @@ public static function table(Table $table): Table ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), + UiEnforcement::forAction( + Actions\Action::make('restore') + ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore') + ->color('success') + ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left') + ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored') + ->requiresConfirmation() + ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant') + ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.') + ->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore') + ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { + static::restoreTenant($record, $auditLogger); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), + UiEnforcement::forAction( + Actions\Action::make('archive') + ->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive') + ->color('danger') + ->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark') + ->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived') + ->requiresConfirmation() + ->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant') + ->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.') + ->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive') + ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { + static::archiveTenant($record, $auditLogger); + }) + ) + ->preserveVisibility() + ->requireCapability(Capabilities::TENANT_DELETE) + ->apply(), UiEnforcement::forAction( Actions\Action::make('syncTenant') ->label('Sync') diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php index e3eb325d..f85d2446 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -2,12 +2,17 @@ namespace App\Filament\Resources\TenantResource\RelationManagers; +use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships; use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Auth\TenantMembershipManager; use App\Support\Auth\Capabilities; use App\Support\Rbac\UiEnforcement; +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; use Filament\Notifications\Notification; @@ -15,11 +20,48 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; class TenantMembershipsRelationManager extends RelationManager { protected static string $relationship = 'memberships'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant membership rows are managed inline and have no separate inspect destination.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Change role and remove stay direct for focused inline membership management.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.'); + } + + public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool + { + if (! $ownerRecord instanceof Tenant) { + return false; + } + + if ($pageClass !== ManageTenantMemberships::class) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + if (! $user->canAccessTenant($ownerRecord)) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return $resolver->can($user, $ownerRecord, Capabilities::TENANT_MEMBERSHIP_VIEW); + } + public function table(Table $table): Table { return $table diff --git a/app/Filament/Resources/TenantReviewResource.php b/app/Filament/Resources/TenantReviewResource.php index 2351fcf0..481c02b3 100644 --- a/app/Filament/Resources/TenantReviewResource.php +++ b/app/Filament/Resources/TenantReviewResource.php @@ -120,7 +120,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->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, 'Primary row actions stay limited to View review and Export executive pack.') + ->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.'); } @@ -311,9 +311,6 @@ public static function table(Table $table): Table \App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), ]) ->actions([ - Actions\Action::make('view_review') - ->label('View review') - ->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)), UiEnforcement::forTableAction( Actions\Action::make('export_executive_pack') ->label('Export executive pack') diff --git a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php index 82f3cf50..de961e14 100644 --- a/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php +++ b/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php @@ -9,7 +9,11 @@ use App\Support\Auth\Capabilities; use App\Support\Auth\WorkspaceRole; 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\Actions\ActionGroup; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -21,6 +25,16 @@ class WorkspaceMembershipsRelationManager extends RelationManager { protected static string $relationship = 'memberships'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace membership rows are managed inline and have no separate inspect destination.') + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Change role stays inline while destructive removal is grouped under More.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.'); + } + public function table(Table $table): Table { return $table @@ -177,46 +191,47 @@ public function table(Table $table): Table ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) ->tooltip('You do not have permission to manage workspace memberships.') ->apply(), + ActionGroup::make([ + WorkspaceUiEnforcement::forTableAction( + Action::make('remove') + ->label(__('Remove')) + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void { + $workspace = $this->getOwnerRecord(); - WorkspaceUiEnforcement::forTableAction( - Action::make('remove') - ->label(__('Remove')) - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void { - $workspace = $this->getOwnerRecord(); + if (! $workspace instanceof Workspace) { + abort(404); + } - if (! $workspace instanceof Workspace) { - abort(404); - } + $actor = auth()->user(); + if (! $actor instanceof User) { + abort(403); + } - $actor = auth()->user(); - if (! $actor instanceof User) { - abort(403); - } + try { + $manager->removeMember(workspace: $workspace, actor: $actor, membership: $record); + } catch (\Throwable $throwable) { + Notification::make() + ->title(__('Failed to remove member')) + ->body($throwable->getMessage()) + ->danger() + ->send(); - try { - $manager->removeMember(workspace: $workspace, actor: $actor, membership: $record); - } catch (\Throwable $throwable) { - Notification::make() - ->title(__('Failed to remove member')) - ->body($throwable->getMessage()) - ->danger() - ->send(); + return; + } - return; - } - - Notification::make()->title(__('Member removed'))->success()->send(); - $this->resetTable(); - }), - fn () => $this->getOwnerRecord(), - ) - ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) - ->tooltip('You do not have permission to manage workspace memberships.') - ->destructive() - ->apply(), + Notification::make()->title(__('Member removed'))->success()->send(); + $this->resetTable(); + }), + fn () => $this->getOwnerRecord(), + ) + ->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE) + ->tooltip('You do not have permission to manage workspace memberships.') + ->destructive() + ->apply(), + ])->label('More'), ]) ->bulkActions([]) ->emptyStateHeading(__('No workspace members')) diff --git a/app/Filament/Resources/Workspaces/WorkspaceResource.php b/app/Filament/Resources/Workspaces/WorkspaceResource.php index a11f6099..6f066bea 100644 --- a/app/Filament/Resources/Workspaces/WorkspaceResource.php +++ b/app/Filament/Resources/Workspaces/WorkspaceResource.php @@ -96,8 +96,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.') - ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) - ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only primary View/Edit row actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only row-click inspection plus a primary Edit action.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.'); @@ -151,6 +151,7 @@ public static function table(Table $table): Table return $table ->defaultSort('name') ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->recordUrl(fn (Workspace $record): string => static::getUrl('view', ['record' => $record])) ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() @@ -160,7 +161,6 @@ public static function table(Table $table): Table ->sortable(), ]) ->actions([ - Actions\ViewAction::make(), WorkspaceUiEnforcement::forTableAction( Actions\EditAction::make(), fn (): ?Workspace => null, diff --git a/app/Filament/System/Pages/Directory/Tenants.php b/app/Filament/System/Pages/Directory/Tenants.php index 9b2414eb..18e9e6ee 100644 --- a/app/Filament/System/Pages/Directory/Tenants.php +++ b/app/Filament/System/Pages/Directory/Tenants.php @@ -10,6 +10,10 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\System\SystemDirectoryLinks; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Pages\Page; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -31,6 +35,16 @@ class Tenants extends Page implements HasTable protected string $view = 'filament.system.pages.directory.tenants'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->exempt(ActionSurfaceSlot::ListHeader, 'System tenant directory stays scan-first and does not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Tenant directory rows navigate directly to the detail page and have no secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System tenant directory does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that tenants appear here after onboarding and inventory sync.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Filament/System/Pages/Directory/Workspaces.php b/app/Filament/System/Pages/Directory/Workspaces.php index c2bd720c..9b2bbcbf 100644 --- a/app/Filament/System/Pages/Directory/Workspaces.php +++ b/app/Filament/System/Pages/Directory/Workspaces.php @@ -14,6 +14,10 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\System\SystemDirectoryLinks; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Pages\Page; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -35,6 +39,16 @@ class Workspaces extends Page implements HasTable protected string $view = 'filament.system.pages.directory.workspaces'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->exempt(ActionSurfaceSlot::ListHeader, 'System workspace directory stays scan-first and does not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace directory rows navigate directly to the detail page and have no secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System workspace directory does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that workspaces appear here once the platform inventory is seeded.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Filament/System/Pages/Ops/Failures.php b/app/Filament/System/Pages/Ops/Failures.php index 17e351ec..e110ab6e 100644 --- a/app/Filament/System/Pages/Ops/Failures.php +++ b/app/Filament/System/Pages/Ops/Failures.php @@ -15,6 +15,10 @@ use App\Support\OperationRunStatus; use App\Support\OpsUx\OperationUxPresenter; use App\Support\System\SystemOperationRunLinks; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\Action; use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; @@ -39,6 +43,16 @@ class Failures extends Page implements HasTable protected string $view = 'filament.system.pages.ops.failures'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'System failures stay scan-first and rely on row triage rather than page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed-run triage stays per run and intentionally omits bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed runs to triage.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.'); + } + public static function getNavigationBadge(): ?string { $count = OperationRun::query() diff --git a/app/Filament/System/Pages/Ops/Runs.php b/app/Filament/System/Pages/Ops/Runs.php index 09da7644..8420da19 100644 --- a/app/Filament/System/Pages/Ops/Runs.php +++ b/app/Filament/System/Pages/Ops/Runs.php @@ -13,6 +13,10 @@ use App\Support\OperationCatalog; use App\Support\OpsUx\OperationUxPresenter; use App\Support\System\SystemOperationRunLinks; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\Action; use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; @@ -37,6 +41,16 @@ class Runs extends Page implements HasTable protected string $view = 'filament.system.pages.ops.runs'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'System ops runs rely on inline row triage and do not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System ops triage stays per run and intentionally omits bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no system runs have been queued yet.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Filament/System/Pages/Ops/Stuck.php b/app/Filament/System/Pages/Ops/Stuck.php index 4bff90b9..5864ea22 100644 --- a/app/Filament/System/Pages/Ops/Stuck.php +++ b/app/Filament/System/Pages/Ops/Stuck.php @@ -15,6 +15,10 @@ use App\Support\OpsUx\OperationUxPresenter; use App\Support\System\SystemOperationRunLinks; use App\Support\SystemConsole\StuckRunClassifier; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\Action; use Filament\Forms\Components\Textarea; use Filament\Notifications\Notification; @@ -39,6 +43,16 @@ class Stuck extends Page implements HasTable protected string $view = 'filament.system.pages.ops.stuck'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'System stuck-run triage relies on row actions and does not expose page header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stuck-run triage stays per run and intentionally omits bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no queued or running runs cross the stuck thresholds.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.'); + } + public static function getNavigationBadge(): ?string { $count = app(StuckRunClassifier::class) diff --git a/app/Filament/System/Pages/Security/AccessLogs.php b/app/Filament/System/Pages/Security/AccessLogs.php index 379fdad2..9a2bb3df 100644 --- a/app/Filament/System/Pages/Security/AccessLogs.php +++ b/app/Filament/System/Pages/Security/AccessLogs.php @@ -7,6 +7,9 @@ use App\Models\AuditLog; use App\Models\PlatformUser; use App\Support\Auth\PlatformCapabilities; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Pages\Page; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -28,6 +31,16 @@ class AccessLogs extends Page implements HasTable protected string $view = 'filament.system.pages.security.access-logs'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->exempt(ActionSurfaceSlot::ListHeader, 'Access logs remain scan-first and do not expose page header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Access logs intentionally keep auth and break-glass events inline without a separate inspect view.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Access logs are immutable and intentionally omit bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no platform auth or break-glass events match the current log scope.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'The access log page does not open per-record detail headers; review stays inline in the table.'); + } + public static function canAccess(): bool { $user = auth('platform')->user(); diff --git a/app/Services/Tenants/TenantActionPolicySurface.php b/app/Services/Tenants/TenantActionPolicySurface.php index 3a4f4ecd..a7cd7ee0 100644 --- a/app/Services/Tenants/TenantActionPolicySurface.php +++ b/app/Services/Tenants/TenantActionPolicySurface.php @@ -153,7 +153,7 @@ private function viewAction(): TenantActionDescriptor family: TenantActionFamily::Neutral, label: 'View', icon: 'heroicon-o-eye', - group: 'primary', + group: 'inspect', ); } diff --git a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index b45d2834..a2268701 100644 --- a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -28,9 +28,6 @@ public static function baseline(): self 'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.', - 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', - 'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.', - 'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.', ], TenantOwnedModelFamilies::actionSurfaceBaselineExemptions())); } diff --git a/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php b/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php index e62706f1..c00edb91 100644 --- a/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php +++ b/app/Support/WorkspaceIsolation/TenantOwnedModelFamilies.php @@ -90,8 +90,8 @@ public static function firstSlice(): array 'resource' => RestoreRunResource::class, 'tenant_relationship' => 'tenant', 'search_posture' => 'not_applicable', - 'action_surface' => 'baseline_exemption', - 'action_surface_reason' => 'Restore run resource retrofit is deferred to the restore track and remains explicitly exempt in the action-surface baseline.', + 'action_surface' => 'declared', + 'action_surface_reason' => 'RestoreRunResource declares its action surface contract directly.', 'notes' => 'Restore runs are not part of global search.', ], 'Finding' => [ diff --git a/tests/Browser/TenantMembershipsPageTest.php b/tests/Browser/TenantMembershipsPageTest.php new file mode 100644 index 00000000..e1f46b57 --- /dev/null +++ b/tests/Browser/TenantMembershipsPageTest.php @@ -0,0 +1,52 @@ +browser()->timeout(15_000); + +it('renders tenant memberships only on the dedicated memberships page after scroll hydration', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + + $member = User::factory()->create([ + 'email' => 'browser-tenant-member@example.test', + ]); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $this->actingAs($owner)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ]); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $viewPage = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')); + + $viewPage + ->assertNoJavaScriptErrors() + ->assertSee((string) $tenant->name) + ->assertScript("document.body.innerText.includes('Add member')", false) + ->assertScript("document.body.innerText.includes('browser-tenant-member@example.test')", false); + + $membershipsPage = visit(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin')); + + $membershipsPage + ->assertNoJavaScriptErrors() + ->assertSee('Tenant memberships'); + + $membershipsPage->script(<<<'JS' +window.scrollTo(0, document.body.scrollHeight); +JS); + + $membershipsPage + ->waitForText('Add member') + ->assertNoJavaScriptErrors() + ->assertSee('Memberships') + ->assertSee('Add member') + ->assertSee('browser-tenant-member@example.test') + ->assertSee('Change role') + ->assertSee('Remove'); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php b/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php index a21ff0f8..602ba414 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php @@ -74,6 +74,47 @@ function getBackupScheduleEmptyStateAction(Testable $component, string $name): ? })->toThrow(AuthorizationException::class); }); +it('requires confirmation for destructive backup schedule lifecycle actions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $activeSchedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Lifecycle confirmation active', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $archivedSchedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Lifecycle confirmation archived', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + $archivedSchedule->delete(); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->assertTableActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired(), $activeSchedule); + + Livewire::test(ListBackupSchedules::class) + ->filterTable(TrashedFilter::class, false) + ->assertTableActionExists('forceDelete', fn (Action $action): bool => $action->isConfirmationRequired(), BackupSchedule::withTrashed()->findOrFail($archivedSchedule->id)); +}); + it('disables backup schedule create in the empty state for members without manage capability', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); diff --git a/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php b/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php index d89462e8..0ca79620 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleOperationRunsRelationManagerTest.php @@ -76,6 +76,8 @@ function makeBackupScheduleForTenant(\App\Models\Tenant $tenant, string $name): 'pageClass' => EditBackupSchedule::class, ]); - expect(fn () => $component->instance()->mountTableAction('view', (string) $foreignRun->getKey())) + $table = $component->instance()->getTable(); + + expect(fn () => $table->getRecordUrl($foreignRun)) ->toThrow(NotFoundHttpException::class); }); diff --git a/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php b/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php index b6a09d8c..554aa608 100644 --- a/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php +++ b/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php @@ -364,7 +364,17 @@ function seedEvidenceDomain(Tenant $tenant): void ->values() ->all(); - expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire']) + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['expire']) ->and($table->getBulkActions())->toBeEmpty() ->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot])); }); diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index b6e3fc72..d46c3735 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -2,9 +2,26 @@ declare(strict_types=1); +use App\Filament\Pages\InventoryCoverage; +use App\Filament\Pages\Monitoring\Alerts; +use App\Filament\Pages\Monitoring\Operations; +use App\Filament\Pages\NoAccess; +use App\Filament\Pages\Operations\TenantlessOperationRunViewer; +use App\Filament\Pages\TenantDiagnostics; +use App\Filament\Pages\TenantRequiredPermissions; use App\Filament\Resources\AlertDeliveryResource; +use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries; +use App\Filament\Resources\AlertDestinationResource; +use App\Filament\Resources\AlertDestinationResource\Pages\ListAlertDestinations; +use App\Filament\Resources\AlertRuleResource; +use App\Filament\Resources\AlertRuleResource\Pages\ListAlertRules; use App\Filament\Resources\BackupScheduleResource; +use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule; +use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; +use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager; use App\Filament\Resources\BackupSetResource; +use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet; +use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager; use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles; use App\Filament\Resources\BaselineSnapshotResource; @@ -13,28 +30,77 @@ use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots; use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions; +use App\Filament\Resources\FindingResource; +use App\Filament\Resources\FindingResource\Pages\ListFindings; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource\Pages\ListPolicies; +use App\Filament\Resources\PolicyResource\Pages\ViewPolicy; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; +use App\Filament\Resources\PolicyVersionResource; +use App\Filament\Resources\ProviderConnectionResource; +use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; +use App\Filament\Resources\RestoreRunResource; +use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns; +use App\Filament\Resources\ReviewPackResource; +use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks; +use App\Filament\Resources\TenantResource; +use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships; +use App\Filament\Resources\TenantResource\Pages\ViewTenant; +use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager; +use App\Filament\Resources\TenantReviewResource; +use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews; +use App\Filament\Resources\Workspaces\Pages\ListWorkspaces; +use App\Filament\Resources\Workspaces\Pages\ViewWorkspace; +use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager; use App\Filament\Resources\Workspaces\WorkspaceResource; +use App\Filament\System\Pages\Directory\Tenants as SystemDirectoryTenantsPage; +use App\Filament\System\Pages\Directory\Workspaces as SystemDirectoryWorkspacesPage; +use App\Filament\System\Pages\Ops\Failures as SystemFailuresPage; +use App\Filament\System\Pages\Ops\Runs as SystemRunsPage; +use App\Filament\System\Pages\Ops\Stuck as SystemStuckPage; +use App\Filament\System\Pages\Security\AccessLogs as SystemAccessLogsPage; use App\Jobs\SyncPoliciesJob; +use App\Models\AlertDelivery; +use App\Models\AlertDestination; +use App\Models\AlertRule; +use App\Models\AuditLog; +use App\Models\BackupItem; +use App\Models\BackupSchedule; +use App\Models\BackupSet; use App\Models\BaselineProfile; use App\Models\EvidenceSnapshot; +use App\Models\Finding; use App\Models\InventoryItem; use App\Models\OperationRun; +use App\Models\PlatformUser; +use App\Models\Policy; +use App\Models\PolicyVersion; +use App\Models\ProviderConnection; +use App\Models\RestoreRun; +use App\Models\ReviewPack; use App\Models\Tenant; +use App\Models\TenantMembership; +use App\Models\User; +use App\Models\Workspace; +use App\Models\WorkspaceMembership; +use App\Support\Auth\PlatformCapabilities; use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\OperationRunLinks; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\System\SystemDirectoryLinks; +use App\Support\System\SystemOperationRunLinks; use App\Support\Ui\ActionSurface\ActionSurfaceExemptions; use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition; use App\Support\Ui\ActionSurface\ActionSurfaceValidator; use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; +use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Facades\Filament; @@ -44,6 +110,24 @@ uses(RefreshDatabase::class); +function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser +{ + Filament::setCurrentPanel('system'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => array_values(array_unique(array_merge([ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + ], $capabilities))), + 'is_active' => true, + ]); + + test()->actingAs($platformUser, 'platform'); + + return $platformUser; +} + it('passes the action surface contract guard for current repository state', function (): void { $result = ActionSurfaceValidator::withBaselineExemptions()->validate(); @@ -142,8 +226,8 @@ ->values() ->all(); - expect($primaryRowActionNames)->toContain('view'); - expect($primaryRowActionNames)->not->toContain('archive'); + expect($primaryRowActionNames)->toBe([]) + ->and($table->getRecordUrl($profile))->toBe(BaselineProfileResource::getUrl('view', ['record' => $profile])); $primaryRowActionCount = count($primaryRowActionNames); expect($primaryRowActionCount)->toBeLessThanOrEqual(2); @@ -155,30 +239,430 @@ ->all(); expect($moreActionNames)->toContain('archive'); + expect($table->getBulkActions())->toBeEmpty(); +}); + +it('keeps backup schedules on clickable-row edit without duplicate Edit actions in More', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Nightly backup', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListBackupSchedules::class) + ->assertCanSeeTableRecords([$schedule]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + + expect($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More'); + + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($table->getRecordUrl($schedule))->toBe(BackupScheduleResource::getUrl('edit', ['record' => $schedule])); + + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($moreActionNames)->toContain('runNow', 'retry', 'archive') + ->and($moreActionNames)->not->toContain('edit'); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + + expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More'); + + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($bulkActionNames)->toEqualCanonicalizing(['bulk_run_now', 'bulk_retry']); +}); + +it('uses clickable rows without extra row actions on backup schedule executions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Nightly backup', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $run = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'backup_schedule_run', + 'context' => ['backup_schedule_id' => (int) $schedule->getKey()], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(BackupScheduleOperationRunsRelationManager::class, [ + 'ownerRecord' => $schedule, + 'pageClass' => EditBackupSchedule::class, + ]) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(OperationRunLinks::view($run, $tenant)); +}); + +it('uses clickable rows while keeping remove grouped under More on backup items', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'policy_version_id' => (int) $version->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => EditBackupSet::class, + ]) + ->assertCanSeeTableRecords([$backupItem]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['remove']) + ->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More') + ->and($bulkActionNames)->toEqualCanonicalizing(['bulk_remove']) + ->and($table->getRecordUrl($backupItem))->toBe(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); +}); + +it('keeps tenant memberships inline without a separate inspect affordance', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $member = User::factory()->create(); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $membership = TenantMembership::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('user_id', (int) $member->getKey()) + ->firstOrFail(); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->assertCanSeeTableRecords([$membership]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['change_role', 'remove']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($membership))->toBeNull(); +}); + +it('keeps workspace memberships inline without a separate inspect affordance', function (): void { + $workspace = Workspace::factory()->create(); + $owner = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $owner->getKey(), + 'role' => 'owner', + ]); + + $member = User::factory()->create(); + + $membership = WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($owner); + + $livewire = Livewire::test(WorkspaceMembershipsRelationManager::class, [ + 'ownerRecord' => $workspace, + 'pageClass' => ViewWorkspace::class, + ]) + ->assertCanSeeTableRecords([$membership]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($table->getActions()) + ->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions() ?? []) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['change_role']) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['remove']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($membership))->toBeNull(); +}); + +it('renders the policy versions relation manager on the policy detail page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'created_by' => 'versions-surface@example.test', + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)) + ->assertOk() + ->assertSee('Versions'); +}); + +it('renders tenant memberships only on the dedicated memberships page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $member = User::factory()->create([ + 'email' => 'tenant-members-surface@example.test', + ]); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')) + ->assertOk() + ->assertDontSeeLivewire(TenantMembershipsRelationManager::class); + + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + $membershipsPage = Livewire::actingAs($user) + ->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()]); + + expect($membershipsPage->instance()->getRelationManagers()) + ->toContain(TenantMembershipsRelationManager::class); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin')) + ->assertOk() + ->assertSeeLivewire(TenantMembershipsRelationManager::class); +}); + +it('renders the backup items relation manager on the backup set detail page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Backup Items Surface Policy', + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + BackupItem::factory()->for($backupSet)->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'policy_version_id' => null, + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) + ->assertOk() + ->assertSee('Items'); +}); + +it('renders the workspace memberships relation manager on the workspace detail page', function (): void { + $workspace = Workspace::factory()->create(); + $owner = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $owner->getKey(), + 'role' => 'owner', + ]); + + $member = User::factory()->create([ + 'email' => 'workspace-members-surface@example.test', + ]); + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $member->getKey(), + 'role' => 'readonly', + ]); + + $this->actingAs($owner); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(WorkspaceResource::getUrl('view', ['record' => $workspace])) + ->assertOk() + ->assertSee('Memberships'); +}); + +it('keeps inventory coverage as derived metadata without inspect or row action affordances', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $conditionalAccessKey = 'policy:conditionalAccessPolicy'; + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(InventoryCoverage::class) + ->assertCanSeeTableRecords([$conditionalAccessKey]) + ->assertTableEmptyStateActionsExistInOrder(['clear_filters']); + + $table = $livewire->instance()->getTable(); + $declaration = InventoryCoverage::actionSurfaceDeclaration(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getEmptyStateActions())->toHaveCount(1) + ->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('runtime-derived metadata'); }); it('ensures representative declarations satisfy required slots', function (): void { $profiles = new ActionSurfaceProfileDefinition; $declarations = [ - \App\Filament\Pages\InventoryCoverage::class => \App\Filament\Pages\InventoryCoverage::actionSurfaceDeclaration(), - \App\Filament\Pages\Monitoring\Operations::class => \App\Filament\Pages\Monitoring\Operations::actionSurfaceDeclaration(), - \App\Filament\Pages\NoAccess::class => \App\Filament\Pages\NoAccess::actionSurfaceDeclaration(), - \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class => \App\Filament\Pages\Operations\TenantlessOperationRunViewer::actionSurfaceDeclaration(), - \App\Filament\Pages\TenantDiagnostics::class => \App\Filament\Pages\TenantDiagnostics::actionSurfaceDeclaration(), - \App\Filament\Pages\TenantRequiredPermissions::class => \App\Filament\Pages\TenantRequiredPermissions::actionSurfaceDeclaration(), + InventoryCoverage::class => InventoryCoverage::actionSurfaceDeclaration(), + NoAccess::class => NoAccess::actionSurfaceDeclaration(), + TenantlessOperationRunViewer::class => TenantlessOperationRunViewer::actionSurfaceDeclaration(), + TenantDiagnostics::class => TenantDiagnostics::actionSurfaceDeclaration(), + TenantRequiredPermissions::class => TenantRequiredPermissions::actionSurfaceDeclaration(), AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(), BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(), + BackupScheduleOperationRunsRelationManager::class => BackupScheduleOperationRunsRelationManager::actionSurfaceDeclaration(), BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(), + BackupItemsRelationManager::class => BackupItemsRelationManager::actionSurfaceDeclaration(), BaselineSnapshotResource::class => BaselineSnapshotResource::actionSurfaceDeclaration(), EntraGroupResource::class => EntraGroupResource::actionSurfaceDeclaration(), EvidenceSnapshotResource::class => EvidenceSnapshotResource::actionSurfaceDeclaration(), FindingExceptionResource::class => FindingExceptionResource::actionSurfaceDeclaration(), + Operations::class => Operations::actionSurfaceDeclaration(), PolicyResource::class => PolicyResource::actionSurfaceDeclaration(), OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(), + ReviewPackResource::class => ReviewPackResource::actionSurfaceDeclaration(), + RestoreRunResource::class => RestoreRunResource::actionSurfaceDeclaration(), + TenantMembershipsRelationManager::class => TenantMembershipsRelationManager::actionSurfaceDeclaration(), VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(), BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(), + WorkspaceMembershipsRelationManager::class => WorkspaceMembershipsRelationManager::actionSurfaceDeclaration(), WorkspaceResource::class => WorkspaceResource::actionSurfaceDeclaration(), + SystemRunsPage::class => SystemRunsPage::actionSurfaceDeclaration(), + SystemFailuresPage::class => SystemFailuresPage::actionSurfaceDeclaration(), + SystemStuckPage::class => SystemStuckPage::actionSurfaceDeclaration(), + SystemDirectoryTenantsPage::class => SystemDirectoryTenantsPage::actionSurfaceDeclaration(), + SystemDirectoryWorkspacesPage::class => SystemDirectoryWorkspacesPage::actionSurfaceDeclaration(), + SystemAccessLogsPage::class => SystemAccessLogsPage::actionSurfaceDeclaration(), ]; foreach ($declarations as $className => $declaration) { @@ -221,8 +705,15 @@ it('keeps first-slice tenant-owned action-surface exemptions registry-backed and explicit', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); + $registeredExemptions = TenantOwnedModelFamilies::actionSurfaceBaselineExemptions(); + $declaredExemptions = collect(TenantOwnedModelFamilies::firstSlice()) + ->filter(static fn (array $family): bool => $family['action_surface'] === 'baseline_exemption') + ->mapWithKeys(static fn (array $family): array => [$family['resource'] => $family['action_surface_reason']]) + ->all(); - foreach (TenantOwnedModelFamilies::actionSurfaceBaselineExemptions() as $className => $reason) { + expect($registeredExemptions)->toBe($declaredExemptions); + + foreach ($registeredExemptions as $className => $reason) { expect($baselineExemptions->reasonForClass($className)) ->toBe($reason); } @@ -240,12 +731,12 @@ it('keeps first-slice trusted-state page action-surface status explicit', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); - expect(method_exists(\App\Filament\Pages\TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue() - ->and($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeFalse(); + expect(method_exists(TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue() + ->and($baselineExemptions->hasClass(TenantRequiredPermissions::class))->toBeFalse(); - expect(method_exists(\App\Filament\Pages\Monitoring\Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse() - ->and($baselineExemptions->hasClass(\App\Filament\Pages\Monitoring\Alerts::class))->toBeTrue() - ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Monitoring\Alerts::class))->toContain('cluster entry'); + expect(method_exists(Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse() + ->and($baselineExemptions->hasClass(Alerts::class))->toBeTrue() + ->and((string) $baselineExemptions->reasonForClass(Alerts::class))->toContain('cluster entry'); expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue() ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests'); @@ -254,16 +745,103 @@ ->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse(); }); -it('keeps cleanup-slice pages declaration-backed without stale baseline exemptions', function (): void { +it('keeps enrolled system panel pages declaration-backed without stale baseline exemptions', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); foreach ([ - \App\Filament\Pages\InventoryCoverage::class, - \App\Filament\Pages\Monitoring\Operations::class, - \App\Filament\Pages\NoAccess::class, - \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class, - \App\Filament\Pages\TenantDiagnostics::class, - \App\Filament\Pages\TenantRequiredPermissions::class, + SystemRunsPage::class, + SystemFailuresPage::class, + SystemStuckPage::class, + SystemDirectoryTenantsPage::class, + SystemDirectoryWorkspacesPage::class, + SystemAccessLogsPage::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled relation managers declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + BackupItemsRelationManager::class, + TenantMembershipsRelationManager::class, + WorkspaceMembershipsRelationManager::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled monitoring pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + Operations::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled tenant table pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + InventoryCoverage::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled canonical detail pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + TenantlessOperationRunViewer::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled singleton tenant pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + NoAccess::class, + TenantDiagnostics::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + +it('keeps enrolled guided workspace diagnostic pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + TenantRequiredPermissions::class, ] as $className) { expect(method_exists($className, 'actionSurfaceDeclaration')) ->toBeTrue("{$className} should declare its action surface once enrolled."); @@ -368,9 +946,223 @@ ->values() ->all(); - expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire']); - expect($table->getBulkActions())->toBeEmpty(); - expect($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot])); + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['expire']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot])); +}); + +it('uses clickable rows without a duplicate View action on the tenant reviews list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $review = composeTenantReviewForTest($tenant, $user); + + $this->actingAs($user); + setTenantPanelContext($tenant); + + $livewire = Livewire::test(ListTenantReviews::class) + ->assertCanSeeTableRecords([$review]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['export_executive_pack']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($review))->toBe(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)); +}); + +it('uses clickable rows while keeping direct download and expire shortcuts on the review packs list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $pack = ReviewPack::factory()->ready()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'initiated_by_user_id' => (int) $user->getKey(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListReviewPacks::class) + ->assertCanSeeTableRecords([$pack]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['download', 'expire']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($pack))->toBe(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant)); +}); + +it('uses clickable rows while grouping restore-run maintenance actions under More', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'status' => 'completed', + ]); + + $restoreRun = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => 'completed', + 'deleted_at' => null, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListRestoreRuns::class) + ->assertCanSeeTableRecords([$restoreRun]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toBe([]) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['rerun', 'restore', 'archive', 'forceDelete']) + ->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More') + ->and($bulkActionNames)->toEqualCanonicalizing(['bulk_delete', 'bulk_restore', 'bulk_force_delete']) + ->and($table->getRecordUrl($restoreRun))->toBe(RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant)); +}); + +it('keeps findings on clickable-row inspection with a single related drill-down and grouped workflow actions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $finding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListFindings::class) + ->assertCanSeeTableRecords([$finding]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $bulkActions = $table->getBulkActions(); + $bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup); + $bulkActionNames = collect($bulkGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->toEqualCanonicalizing(['primary_drill_down']) + ->and($table->getRecordUrl($finding))->toBe(FindingResource::getUrl('view', ['record' => $finding])) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing([ + 'triage', + 'start_progress', + 'assign', + 'resolve', + 'close', + 'request_exception', + 'renew_exception', + 'revoke_exception', + 'reopen', + ]) + ->and($bulkGroup)->toBeInstanceOf(BulkActionGroup::class) + ->and($bulkGroup?->getLabel())->toBe('More') + ->and($bulkActionNames)->toEqualCanonicalizing([ + 'triage_selected', + 'assign_selected', + 'resolve_selected', + 'close_selected', + ]); +}); + +it('uses clickable rows with restore as the only inline shortcut on the policy versions relation manager', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(VersionsRelationManager::class, [ + 'ownerRecord' => $policy, + 'pageClass' => ViewPolicy::class, + ]) + ->assertCanSeeTableRecords([$version]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['restore_to_intune']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($version))->toBe(PolicyVersionResource::getUrl('view', ['record' => $version])); }); it('uses canonical tenantless View run links on representative operation links', function (): void { @@ -384,6 +1176,270 @@ ->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()])); }); +it('uses clickable rows without a lone View action on the monitoring operations list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(Operations::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(OperationRunLinks::tenantlessView($run)); +}); + +it('keeps tenantless run detail header actions on the canonical viewer without list affordances', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => null, + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + session()->forget(WorkspaceContext::SESSION_KEY); + + Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertActionVisible('operate_hub_scope_run_detail') + ->assertActionVisible('operate_hub_back_to_operations') + ->assertActionVisible('refresh'); + + $declaration = TenantlessOperationRunViewer::actionSurfaceDeclaration(); + + expect((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('canonical detail destination') + ->and((string) ($declaration->slot(ActionSurfaceSlot::DetailHeader)?->details ?? '')) + ->toContain('refresh'); +}); + +it('keeps tenant diagnostics as a singleton repair surface with header actions only', function (): void { + [$manager, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($manager); + Filament::setTenant($tenant, true); + + Livewire::test(TenantDiagnostics::class) + ->assertActionVisible('bootstrapOwner') + ->assertActionEnabled('bootstrapOwner'); + + $declaration = TenantDiagnostics::actionSurfaceDeclaration(); + + expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? '')) + ->toContain('repair actions') + ->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('singleton diagnostic surface'); +}); + +it('keeps the no-access page as a singleton recovery surface with a header action', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + Livewire::test(NoAccess::class) + ->assertActionVisible('createWorkspace') + ->assertActionEnabled('createWorkspace'); + + $declaration = NoAccess::actionSurfaceDeclaration(); + + expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? '')) + ->toContain('create-workspace recovery action') + ->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? '')) + ->toContain('singleton recovery surface'); +}); + +it('keeps required permissions as a guided diagnostic page with inline filters and empty-state guidance', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $response = $this->actingAs($user) + ->get("/admin/tenants/{$tenant->external_id}/required-permissions"); + + $response->assertOk() + ->assertSee('Copy missing application permissions') + ->assertSee('Copy missing delegated permissions') + ->assertSee('Re-run verification') + ->assertSee('Start verification'); + + $declaration = TenantRequiredPermissions::actionSurfaceDeclaration(); + + expect((string) ($declaration->exemption(ActionSurfaceSlot::ListHeader)?->reason ?? '')) + ->toContain('body sections') + ->and((string) ($declaration->slot(ActionSurfaceSlot::ListEmptyState)?->details ?? '')) + ->toContain('no-data'); +}); + +it('uses clickable rows with direct triage actions on the system runs list', function (): void { + $run = OperationRun::factory()->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'type' => 'inventory_sync', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::OPERATIONS_VIEW, + PlatformCapabilities::OPERATIONS_MANAGE, + ]); + + $livewire = Livewire::test(SystemRunsPage::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run)); +}); + +it('uses clickable rows with direct triage actions on the system failures list', function (): void { + $run = OperationRun::factory()->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'type' => 'inventory_sync', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::OPERATIONS_VIEW, + PlatformCapabilities::OPERATIONS_MANAGE, + ]); + + $livewire = Livewire::test(SystemFailuresPage::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run)); +}); + +it('uses clickable rows with direct triage actions on the system stuck list', function (): void { + $run = OperationRun::factory()->create([ + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subHours(2), + 'started_at' => null, + 'type' => 'inventory_sync', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::OPERATIONS_VIEW, + PlatformCapabilities::OPERATIONS_MANAGE, + ]); + + $livewire = Livewire::test(SystemStuckPage::class) + ->assertCanSeeTableRecords([$run]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['retry', 'cancel', 'mark_investigated']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($run))->toBe(SystemOperationRunLinks::view($run)); +}); + +it('uses clickable rows without extra row actions on the system tenants directory', function (): void { + $workspace = Workspace::factory()->create([ + 'name' => 'System Directory Workspace', + ]); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'System Directory Tenant', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::DIRECTORY_VIEW, + ]); + + $livewire = Livewire::test(SystemDirectoryTenantsPage::class) + ->assertCanSeeTableRecords([$tenant]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($tenant))->toBe(SystemDirectoryLinks::tenantDetail($tenant)); +}); + +it('uses clickable rows without extra row actions on the system workspaces directory', function (): void { + $workspace = Workspace::factory()->create([ + 'name' => 'System Directory Workspace', + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::DIRECTORY_VIEW, + ]); + + $livewire = Livewire::test(SystemDirectoryWorkspacesPage::class) + ->assertCanSeeTableRecords([$workspace]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($workspace))->toBe(SystemDirectoryLinks::workspaceDetail($workspace)); +}); + +it('keeps system access logs scan-only without row or bulk actions', function (): void { + $tenant = Tenant::factory()->create(); + + $log = AuditLog::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'action' => 'platform.auth.login', + 'status' => 'success', + 'metadata' => ['attempted_email' => 'operator@tenantpilot.test'], + 'recorded_at' => now(), + ]); + + actionSurfaceSystemPanelContext([ + PlatformCapabilities::CONSOLE_VIEW, + ]); + + $livewire = Livewire::test(SystemAccessLogsPage::class) + ->assertCanSeeTableRecords([$log]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($log))->toBeNull(); +}); + it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -406,6 +1462,223 @@ expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item])); }); +it('uses clickable rows without a lone View action on the workspaces list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant(null, true); + + $workspace = $tenant->workspace; + + $livewire = Livewire::test(ListWorkspaces::class) + ->assertCanSeeTableRecords([$workspace]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toContain('edit') + ->and($rowActionNames)->not->toContain('view') + ->and($table->getRecordUrl($workspace))->toBe(WorkspaceResource::getUrl('view', ['record' => $workspace])); +}); + +it('uses clickable rows without a lone View action on the policies list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-action-surface-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy Action Surface', + 'platform' => 'windows', + 'last_synced_at' => now(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListPolicies::class) + ->assertCanSeeTableRecords([$policy]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->not->toContain('view') + ->and($table->getRecordUrl($policy))->toBe(PolicyResource::getUrl('view', ['record' => $policy])); +}); + +it('uses clickable rows without a duplicate Edit action on the alert rules list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + Filament::setTenant(null, true); + + $livewire = Livewire::test(ListAlertRules::class) + ->assertCanSeeTableRecords([$rule]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $rowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->not->toContain('edit') + ->and($table->getRecordUrl($rule))->toBe(AlertRuleResource::getUrl('edit', ['record' => $rule])) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['toggle_enabled', 'delete']) + ->and($table->getBulkActions())->toBeEmpty(); +}); + +it('uses clickable rows without a duplicate Edit action on the alert destinations list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + Filament::setTenant(null, true); + + $livewire = Livewire::test(ListAlertDestinations::class) + ->assertCanSeeTableRecords([$destination]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $rowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->not->toContain('edit') + ->and($table->getRecordUrl($destination))->toBe(AlertDestinationResource::getUrl('edit', ['record' => $destination])) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing(['toggle_enabled', 'delete']) + ->and($table->getBulkActions())->toBeEmpty(); +}); + +it('uses clickable-row view with all secondary provider connection actions grouped under More', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'provider' => 'microsoft', + 'status' => 'connected', + 'is_default' => false, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $livewire = Livewire::test(ListProviderConnections::class) + ->assertCanSeeTableRecords([$connection]); + + $table = $livewire->instance()->getTable(); + $rowActions = $table->getActions(); + $rowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toBeEmpty() + ->and($table->getRecordUrl($connection))->toBe(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) + ->and($moreGroup)->toBeInstanceOf(ActionGroup::class) + ->and($moreGroup?->getLabel())->toBe('More') + ->and($moreActionNames)->toEqualCanonicalizing([ + 'edit', + 'check_connection', + 'inventory_sync', + 'compliance_snapshot', + 'set_default', + 'enable_dedicated_override', + 'rotate_dedicated_credential', + 'delete_dedicated_credential', + 'revert_to_platform', + 'enable_connection', + 'disable_connection', + ]) + ->and($table->getBulkActions())->toBeEmpty(); +}); + +it('uses clickable rows without extra row actions on the alert deliveries list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $workspaceId = (int) $tenant->workspace_id; + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + $delivery = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + ]); + + $this->actingAs($user); + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + Filament::setTenant(null, true); + + $livewire = Livewire::test(ListAlertDeliveries::class) + ->assertCanSeeTableRecords([$delivery]); + + $table = $livewire->instance()->getTable(); + + expect($table->getActions())->toBeEmpty() + ->and($table->getRecordUrl($delivery))->toBe(AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin')); +}); + it('keeps representative operation-start actions observable with actor and scope metadata', function (): void { Queue::fake(); bindFailHardGraphClient(); diff --git a/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php index 74b4f9f9..f3e69b1c 100644 --- a/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php +++ b/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php @@ -13,7 +13,6 @@ use App\Models\PolicyVersion; use App\Models\User; use App\Models\WorkspaceMembership; -use Filament\Actions\Action; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -110,16 +109,16 @@ ], ]); - Livewire::test(BackupItemsRelationManager::class, [ + $component = Livewire::test(BackupItemsRelationManager::class, [ 'ownerRecord' => $backupSet, 'pageClass' => EditBackupSet::class, ]) - ->assertTableColumnFormattedStateSet('policy.display_name', 'Captured RBAC role', $backupItem) - ->assertTableActionVisible('view', $backupItem) - ->assertTableActionExists('view', function (Action $action) use ($tenant, $version): bool { - return $action->getLabel() === 'View version' - && $action->getUrl() === PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant); - }, $backupItem); + ->assertTableColumnFormattedStateSet('policy.display_name', 'Captured RBAC role', $backupItem); + + $table = $component->instance()->getTable(); + + expect($table->getRecordUrl($backupItem)) + ->toBe(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); }); it('returns 404 and queues nothing when a forged foreign-tenant row action record is submitted', function (): void { diff --git a/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php b/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php index 99fa5e16..222bd17c 100644 --- a/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php +++ b/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\ActionGroup; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -224,7 +225,7 @@ function tenantActionSurfaceSearchTitles($results): array ->assertTableActionHidden('archive', $archivedTenant); }); -it('documents and preserves the tenant row overflow contract for lifecycle actions', function (): void { +it('documents the tenant row-click and More-menu contract for lifecycle actions', function (): void { $tenant = Tenant::factory()->active()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false); @@ -242,17 +243,36 @@ function tenantActionSurfaceSearchTitles($results): array $declaration = TenantResource::actionSurfaceDeclaration(); - expect($declaration->listRowPrimaryActionLimit())->toBe(2) - ->and((string) ($declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details ?? ''))->toContain('two'); + expect($declaration->listRowPrimaryActionLimit())->toBe(1) + ->and((string) ($declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details ?? ''))->toContain('one'); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); Filament::setTenant(null, true); - Livewire::actingAs($user) + $component = Livewire::actingAs($user) ->test(ListTenants::class) - ->assertTableActionVisible('view', $tenant) ->assertTableActionVisible('archive', $tenant) ->assertTableActionHidden('related_onboarding', $tenant) ->assertTableActionVisible('related_onboarding_overflow', $tenant) ->assertTableActionHidden('restore', $tenant); + + $table = $component->instance()->getTable(); + $rowActions = $table->getActions(); + $primaryRowActionNames = collect($rowActions) + ->reject(static fn ($action): bool => $action instanceof ActionGroup) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + $moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup); + $moreActionNames = collect($moreGroup?->getActions() ?? []) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($primaryRowActionNames)->not->toContain('view') + ->and($table->getRecordUrl($tenant))->toBe(TenantResource::getUrl('view', ['record' => $tenant])) + ->and($moreActionNames)->toContain('archive') + ->and($moreActionNames)->toContain('related_onboarding_overflow'); }); diff --git a/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php b/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php index f4813912..13c31108 100644 --- a/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php +++ b/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php @@ -36,7 +36,6 @@ Livewire::actingAs($user) ->test(ListTenants::class) - ->assertTableActionVisible('view', $tenant) ->assertTableActionVisible('related_onboarding', $tenant) ->assertTableActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding', $tenant) ->assertTableActionHidden('archive', $tenant) diff --git a/tests/Feature/TenantReview/TenantReviewUiContractTest.php b/tests/Feature/TenantReview/TenantReviewUiContractTest.php index 0e26639b..817dd821 100644 --- a/tests/Feature/TenantReview/TenantReviewUiContractTest.php +++ b/tests/Feature/TenantReview/TenantReviewUiContractTest.php @@ -43,6 +43,29 @@ ->assertSee('No review records match this view'); }); +it('keeps tenant review list inspection on row click and reserves the row action for executive export', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $review = composeTenantReviewForTest($tenant, $user); + + $this->actingAs($user); + setTenantPanelContext($tenant); + + $livewire = Livewire::actingAs($user) + ->test(ListTenantReviews::class) + ->assertCanSeeTableRecords([$review]); + + $table = $livewire->instance()->getTable(); + $rowActionNames = collect($table->getActions()) + ->map(static fn ($action): ?string => $action->getName()) + ->filter() + ->values() + ->all(); + + expect($rowActionNames)->toEqualCanonicalizing(['export_executive_pack']) + ->and($table->getBulkActions())->toBeEmpty() + ->and($table->getRecordUrl($review))->toBe(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)); +}); + it('requires confirmation for destructive tenant-review actions and preserves disabled management visibility for readonly users', function (): void { $tenant = Tenant::factory()->create(); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); diff --git a/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php b/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php index f8acb17e..35e1f879 100644 --- a/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php +++ b/tests/Unit/Tenants/TenantActionPolicySurfaceTest.php @@ -86,7 +86,7 @@ ->and($catalog[2]->label)->toBe('View completed onboarding'); }); -it('keeps tenant index catalogs within the two-primary-action overflow contract', function (): void { +it('keeps tenant index catalogs within the clickable-row overflow contract', function (): void { $tenant = Tenant::factory()->active()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false); @@ -110,15 +110,22 @@ ->values() ->all(); + $inspectKeys = collect($catalog) + ->filter(static fn ($action): bool => $action->group === 'inspect') + ->map(static fn ($action): string => $action->key) + ->values() + ->all(); + $overflowKeys = collect($catalog) ->filter(static fn ($action): bool => $action->group === 'overflow') ->map(static fn ($action): string => $action->key) ->values() ->all(); - expect($primaryKeys)->toBe(['view', 'archive']) + expect($inspectKeys)->toBe(['view']) + ->and($primaryKeys)->toBe(['archive']) ->and($overflowKeys)->toBe(['related_onboarding']) - ->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(2); + ->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(1); }); it('invalidates cached tenant index catalogs when the related onboarding draft lifecycle changes', function (): void { -- 2.45.2 From 4c174c717b104a038857e25491cc24b35ea99d55 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 29 Mar 2026 23:12:40 +0200 Subject: [PATCH 6/6] fix: resolve post-suite state regressions --- app/Filament/Resources/FindingResource.php | 5 +++- app/Services/Auth/CapabilityResolver.php | 20 ++++--------- .../TenantReviewLifecycleService.php | 28 ++++++++++++++++++- .../Resolvers/BackupSetReferenceResolver.php | 2 +- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 4071afb3..124598d3 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -131,7 +131,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).') ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) - ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary workflow actions are grouped under "More"; the only inline row action is the related-record drill-down.') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.') ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.'); @@ -1643,6 +1643,7 @@ public static function reopenAction(): Actions\Action */ private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void { + $pageRecord = $record; $record = static::resolveProtectedFindingRecordOrFail($record); $tenant = static::resolveTenantContextForCurrentPanel(); $user = auth()->user(); @@ -1671,6 +1672,8 @@ private static function runWorkflowMutation(Finding $record, string $successTitl try { $callback($record, $tenant, $user); + + $pageRecord->refresh(); } catch (InvalidArgumentException $e) { Notification::make() ->title('Workflow action failed') diff --git a/app/Services/Auth/CapabilityResolver.php b/app/Services/Auth/CapabilityResolver.php index a88ec831..86c94c29 100644 --- a/app/Services/Auth/CapabilityResolver.php +++ b/app/Services/Auth/CapabilityResolver.php @@ -108,7 +108,9 @@ private function getMembership(User $user, Tenant $tenant): ?array /** * Prime membership cache for a set of tenants in one query. * - * Used to avoid N+1 queries for bulk selection authorization. + * Used to avoid N+1 queries for bulk selection authorization while still + * reflecting membership changes that may have happened earlier in the same + * request or test process. * * @param array $tenantIds */ @@ -120,26 +122,14 @@ public function primeMemberships(User $user, array $tenantIds): void return; } - $missingTenantIds = []; - foreach ($tenantIds as $tenantId) { - $cacheKey = "membership_{$user->id}_{$tenantId}"; - if (! array_key_exists($cacheKey, $this->resolvedMemberships)) { - $missingTenantIds[] = $tenantId; - } - } - - if ($missingTenantIds === []) { - return; - } - $memberships = TenantMembership::query() ->where('user_id', $user->id) - ->whereIn('tenant_id', $missingTenantIds) + ->whereIn('tenant_id', $tenantIds) ->get(['tenant_id', 'role', 'source', 'source_ref']); $byTenantId = $memberships->keyBy('tenant_id'); - foreach ($missingTenantIds as $tenantId) { + foreach ($tenantIds as $tenantId) { $cacheKey = "membership_{$user->id}_{$tenantId}"; $membership = $byTenantId->get($tenantId); $this->resolvedMemberships[$cacheKey] = $membership?->toArray(); diff --git a/app/Services/TenantReviews/TenantReviewLifecycleService.php b/app/Services/TenantReviews/TenantReviewLifecycleService.php index 14f06173..595dd1e1 100644 --- a/app/Services/TenantReviews/TenantReviewLifecycleService.php +++ b/app/Services/TenantReviews/TenantReviewLifecycleService.php @@ -5,12 +5,15 @@ namespace App\Services\TenantReviews; use App\Models\EvidenceSnapshot; +use App\Models\ReviewPack; use App\Models\Tenant; use App\Models\TenantReview; use App\Models\User; use App\Services\Audit\WorkspaceAuditLogger; use App\Support\Audit\AuditActionId; use App\Support\TenantReviewStatus; +use App\Support\Ui\DerivedState\DerivedStateFamily; +use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore; use Illuminate\Support\Facades\DB; use InvalidArgumentException; @@ -20,6 +23,7 @@ public function __construct( private readonly TenantReviewReadinessGate $readinessGate, private readonly TenantReviewService $reviewService, private readonly WorkspaceAuditLogger $auditLogger, + private readonly RequestScopedDerivedStateStore $derivedStateStore, ) {} public function publish(TenantReview $review, User $user): TenantReview @@ -64,6 +68,8 @@ public function publish(TenantReview $review, User $user): TenantReview tenant: $tenant, ); + $this->invalidateArtifactTruthCache($review); + return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']); } @@ -104,6 +110,8 @@ public function archive(TenantReview $review, User $user): TenantReview tenant: $tenant, ); + $this->invalidateArtifactTruthCache($review); + return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']); } @@ -126,7 +134,7 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.'); } - return DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview { + $nextReview = DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview { $nextReview = $this->reviewService->create($tenant, $snapshot, $user); if ((int) $nextReview->getKey() !== (int) $review->getKey()) { @@ -156,5 +164,23 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']); }); + + $this->invalidateArtifactTruthCache($review); + $this->invalidateArtifactTruthCache($nextReview); + + return $nextReview; + } + + private function invalidateArtifactTruthCache(TenantReview $review): void + { + $this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review'); + + $review->loadMissing('currentExportReviewPack'); + + $pack = $review->currentExportReviewPack; + + if ($pack instanceof ReviewPack) { + $this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $pack, 'review_pack'); + } } } diff --git a/app/Support/References/Resolvers/BackupSetReferenceResolver.php b/app/Support/References/Resolvers/BackupSetReferenceResolver.php index d5e28541..69c09201 100644 --- a/app/Support/References/Resolvers/BackupSetReferenceResolver.php +++ b/app/Support/References/Resolvers/BackupSetReferenceResolver.php @@ -58,7 +58,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference secondaryLabel: 'Backup set #'.$backupSet->getKey(), linkTarget: new ReferenceLinkTarget( targetKind: ReferenceClass::BackupSet->value, - url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant), + url: BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'tenant', tenant: $backupSet->tenant), actionLabel: 'View backup set', contextBadge: 'Tenant', ), -- 2.45.2