From 72bfb37ba72e9f7b923d8b577a756f35abc8651f Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 28 Apr 2026 10:13:09 +0000 Subject: [PATCH] feat: add decision-based governance inbox (#291) ## Summary - add a read-first governance inbox page at `/admin/governance/inbox` - aggregate assigned findings, intake, stale operations, alert-delivery failures, and review follow-up into one canonical routing surface - add focused coverage for inbox authorization, navigation context, page behavior, and section builder logic - include the Spec Kit artifacts for spec 250 ## Notes - branch is synced with `dev` - this PR supersedes #290 for the governance inbox work Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/291 --- .../Pages/Governance/GovernanceInbox.php | 494 ++++++++++ .../Providers/Filament/AdminPanelProvider.php | 2 + .../GovernanceInboxSectionBuilder.php | 888 ++++++++++++++++++ .../governance/governance-inbox.blade.php | 164 ++++ .../GovernanceInboxAuthorizationTest.php | 99 ++ .../GovernanceInboxNavigationContextTest.php | 64 ++ .../Governance/GovernanceInboxPageTest.php | 143 +++ .../CommandModelSmokeTest.php | 7 + .../GovernanceInboxSectionBuilderTest.php | 197 ++++ docker-compose.yml | 2 +- .../checklists/requirements.md | 70 ++ .../contracts/governance-inbox.openapi.yaml | 159 ++++ .../data-model.md | 103 ++ specs/250-decision-governance-inbox/plan.md | 305 ++++++ .../quickstart.md | 65 ++ .../250-decision-governance-inbox/research.md | 104 ++ specs/250-decision-governance-inbox/spec.md | 294 ++++++ specs/250-decision-governance-inbox/tasks.md | 173 ++++ 18 files changed, 3332 insertions(+), 1 deletion(-) create mode 100644 apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php create mode 100644 apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php create mode 100644 apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php create mode 100644 apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php create mode 100644 apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php create mode 100644 specs/250-decision-governance-inbox/checklists/requirements.md create mode 100644 specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml create mode 100644 specs/250-decision-governance-inbox/data-model.md create mode 100644 specs/250-decision-governance-inbox/plan.md create mode 100644 specs/250-decision-governance-inbox/quickstart.md create mode 100644 specs/250-decision-governance-inbox/research.md create mode 100644 specs/250-decision-governance-inbox/spec.md create mode 100644 specs/250-decision-governance-inbox/tasks.md diff --git a/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php new file mode 100644 index 00000000..7069a0ae --- /dev/null +++ b/apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php @@ -0,0 +1,494 @@ +|null + */ + private ?array $authorizedTenants = null; + + /** + * @var array|null + */ + private ?array $visibleFindingTenants = null; + + /** + * @var array|null + */ + private ?array $reviewTenants = null; + + /** + * @var array|null + */ + private ?array $inboxPayload = null; + + /** + * @var array|null + */ + private ?array $unfilteredInboxPayload = null; + + private ?Workspace $workspace = null; + + private ?bool $visibleAlertsFamily = null; + + public ?int $tenantId = null; + + public ?string $family = null; + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.'); + } + + public function mount(): void + { + $this->authorizeWorkspaceMembership(); + $this->applyRequestedTenantPrefilter(); + $this->family = $this->resolveRequestedFamily(); + $this->ensureAtLeastOneVisibleFamily(); + $this->ensureRequestedFamilyIsVisible(); + } + + /** + * @return array + */ + public function appliedScope(): array + { + $selectedTenant = $this->selectedTenant(); + $availableFamilies = collect($this->availableFamilies()) + ->keyBy('key'); + + return [ + 'workspace_label' => $this->workspace()?->name, + 'tenant_label' => $selectedTenant?->name, + 'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none', + 'family_key' => $this->family, + 'family_label' => $this->family !== null + ? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family)) + : 'All attention', + 'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0), + ]; + } + + /** + * @return list + */ + public function availableFamilies(): array + { + return $this->inboxPayload()['available_families'] ?? []; + } + + /** + * @return list> + */ + public function sections(): array + { + return $this->inboxPayload()['sections'] ?? []; + } + + /** + * @return array + */ + public function calmEmptyState(): array + { + if ($this->tenantFilterAloneExcludesRows()) { + return [ + 'title' => 'This tenant filter is hiding other visible attention', + 'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.', + 'action_label' => 'Clear tenant filter', + 'action_url' => $this->pageUrl(['tenant' => null]), + ]; + } + + return [ + 'title' => 'No visible governance attention right now', + 'body' => 'The current workspace scope is calm across the visible governance families.', + 'action_label' => null, + 'action_url' => null, + ]; + } + + public function hasTenantPrefilter(): bool + { + return $this->selectedTenant() instanceof Tenant; + } + + public function isActiveFamily(?string $familyKey): bool + { + return $this->family === $familyKey; + } + + public function pageUrl(array $overrides = []): string + { + $selectedTenant = $this->selectedTenant(); + $resolvedTenant = array_key_exists('tenant', $overrides) + ? $overrides['tenant'] + : ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null); + $resolvedFamily = array_key_exists('family', $overrides) + ? $overrides['family'] + : $this->family; + + return static::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null, + 'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ); + } + + public function navigationContext(): CanonicalNavigationContext + { + return new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), + tenantId: $this->tenantId, + backLinkLabel: 'Back to governance inbox', + backLinkUrl: $this->pageUrl(), + ); + } + + private function authorizeWorkspaceMembership(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + throw new NotFoundHttpException; + } + + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + throw new NotFoundHttpException; + } + } + + private function ensureAtLeastOneVisibleFamily(): void + { + if ( + $this->hasVisibleOperationsFamily() + || $this->visibleFindingTenants() !== [] + || $this->reviewTenants() !== [] + || $this->hasVisibleAlertsFamily() + ) { + return; + } + + abort(403); + } + + private function ensureRequestedFamilyIsVisible(): void + { + if ($this->family === null) { + return; + } + + if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) { + return; + } + + throw new NotFoundHttpException; + } + + private function hasVisibleOperationsFamily(): bool + { + return $this->authorizedTenants() !== []; + } + + private function hasVisibleAlertsFamily(): bool + { + if (is_bool($this->visibleAlertsFamily)) { + return $this->visibleAlertsFamily; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->visibleAlertsFamily = false; + } + + return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); + } + + /** + * @return array + */ + private function visibleFindingTenants(): array + { + if ($this->visibleFindingTenants !== null) { + return $this->visibleFindingTenants; + } + + $user = auth()->user(); + $tenants = $this->authorizedTenants(); + + if (! $user instanceof User || $tenants === []) { + return $this->visibleFindingTenants = []; + } + + $resolver = app(CapabilityResolver::class); + $resolver->primeMemberships( + $user, + array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants), + ); + + return $this->visibleFindingTenants = array_values(array_filter( + $tenants, + fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW), + )); + } + + /** + * @return array + */ + private function reviewTenants(): array + { + if ($this->reviewTenants !== null) { + return $this->reviewTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->reviewTenants = []; + } + + $service = app(TenantReviewRegisterService::class); + + if (! $service->canAccessWorkspace($user, $workspace)) { + return $this->reviewTenants = []; + } + + return $this->reviewTenants = $service->authorizedTenants($user, $workspace); + } + + /** + * @return array + */ + private function authorizedTenants(): array + { + if ($this->authorizedTenants !== null) { + return $this->authorizedTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->authorizedTenants = []; + } + + return $this->authorizedTenants = $user->tenants() + ->where('tenants.workspace_id', (int) $workspace->getKey()) + ->where('tenants.status', 'active') + ->orderBy('tenants.name') + ->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id']) + ->all(); + } + + private function applyRequestedTenantPrefilter(): void + { + $requestedTenant = request()->query('tenant_id', request()->query('tenant')); + + if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) { + return; + } + + foreach ($this->authorizedTenants() as $tenant) { + if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) { + continue; + } + + $this->tenantId = (int) $tenant->getKey(); + + return; + } + + throw new NotFoundHttpException; + } + + private function resolveRequestedFamily(): ?string + { + $family = request()->query('family'); + + if (! is_string($family)) { + return null; + } + + return in_array($family, [ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ], true) ? $family : null; + } + + private function workspace(): ?Workspace + { + if ($this->workspace instanceof Workspace) { + return $this->workspace; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + return $this->workspace = Workspace::query()->whereKey($workspaceId)->first(); + } + + /** + * @return array + */ + private function inboxPayload(): array + { + if (is_array($this->inboxPayload)) { + return $this->inboxPayload; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->inboxPayload = [ + 'sections' => [], + 'available_families' => [], + 'family_counts' => [], + 'total_count' => 0, + ]; + } + + return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: $this->authorizedTenants(), + visibleFindingTenants: $this->visibleFindingTenants(), + reviewTenants: $this->reviewTenants(), + canViewAlerts: $this->hasVisibleAlertsFamily(), + selectedTenant: $this->selectedTenant(), + selectedFamily: $this->family, + navigationContext: $this->navigationContext(), + ); + } + + /** + * @return array + */ + private function unfilteredInboxPayload(): array + { + if (is_array($this->unfilteredInboxPayload)) { + return $this->unfilteredInboxPayload; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->unfilteredInboxPayload = [ + 'sections' => [], + 'available_families' => [], + 'family_counts' => [], + 'total_count' => 0, + ]; + } + + return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: $this->authorizedTenants(), + visibleFindingTenants: $this->visibleFindingTenants(), + reviewTenants: $this->reviewTenants(), + canViewAlerts: $this->hasVisibleAlertsFamily(), + selectedTenant: null, + selectedFamily: null, + navigationContext: $this->navigationContext(), + ); + } + + private function selectedTenant(): ?Tenant + { + if (! is_int($this->tenantId)) { + return null; + } + + foreach ($this->authorizedTenants() as $tenant) { + if ((int) $tenant->getKey() === $this->tenantId) { + return $tenant; + } + } + + return null; + } + + private function tenantFilterAloneExcludesRows(): bool + { + if (! is_int($this->tenantId) || $this->family !== null) { + return false; + } + + if ($this->sections() !== []) { + return false; + } + + return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0; + } +} \ No newline at end of file diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 3ba79958..29da07f0 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -7,6 +7,7 @@ use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsIntakeQueue; +use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\Monitoring\FindingExceptionsQueue; @@ -180,6 +181,7 @@ public function panel(Panel $panel): Panel InventoryCoverage::class, TenantRequiredPermissions::class, WorkspaceSettings::class, + GovernanceInbox::class, FindingsHygieneReport::class, FindingsIntakeQueue::class, MyFindingsInbox::class, diff --git a/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php new file mode 100644 index 00000000..bc8e778d --- /dev/null +++ b/apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php @@ -0,0 +1,888 @@ + + */ + private const FAMILY_ORDER = [ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ]; + + public function __construct( + private TenantBackupHealthResolver $backupHealthResolver, + private RestoreSafetyResolver $restoreSafetyResolver, + private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver, + private TenantReviewRegisterService $tenantReviewRegisterService, + ) {} + + /** + * @param array $authorizedTenants + * @param array $visibleFindingTenants + * @param array $reviewTenants + * @return array{ + * sections: list>, + * available_families: list, + * family_counts: array, + * total_count: int, + * } + */ + public function build( + User $user, + Workspace $workspace, + array $authorizedTenants, + array $visibleFindingTenants, + array $reviewTenants, + bool $canViewAlerts, + ?Tenant $selectedTenant = null, + ?string $selectedFamily = null, + ?CanonicalNavigationContext $navigationContext = null, + ): array { + $authorizedTenantsById = $this->indexTenants($authorizedTenants); + $visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants); + $reviewTenantsById = $this->indexTenants($reviewTenants); + + $allSections = []; + $availableFamilies = []; + $familyCounts = []; + + if ($visibleFindingTenantsById !== []) { + $assignedSection = $this->assignedFindingsSection( + user: $user, + visibleFindingTenants: $visibleFindingTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$assignedSection['key']] = $assignedSection; + $availableFamilies[] = [ + 'key' => $assignedSection['key'], + 'label' => $assignedSection['label'], + 'count' => $assignedSection['count'], + ]; + $familyCounts[$assignedSection['key']] = $assignedSection['count']; + + $intakeSection = $this->intakeFindingsSection( + visibleFindingTenants: $visibleFindingTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$intakeSection['key']] = $intakeSection; + $availableFamilies[] = [ + 'key' => $intakeSection['key'], + 'label' => $intakeSection['label'], + 'count' => $intakeSection['count'], + ]; + $familyCounts[$intakeSection['key']] = $intakeSection['count']; + } + + if ($authorizedTenantsById !== []) { + $operationsSection = $this->operationsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$operationsSection['key']] = $operationsSection; + $availableFamilies[] = [ + 'key' => $operationsSection['key'], + 'label' => $operationsSection['label'], + 'count' => $operationsSection['count'], + ]; + $familyCounts[$operationsSection['key']] = $operationsSection['count']; + } + + if ($canViewAlerts) { + $alertsSection = $this->alertsSection( + workspace: $workspace, + authorizedTenants: $authorizedTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$alertsSection['key']] = $alertsSection; + $availableFamilies[] = [ + 'key' => $alertsSection['key'], + 'label' => $alertsSection['label'], + 'count' => $alertsSection['count'], + ]; + $familyCounts[$alertsSection['key']] = $alertsSection['count']; + } + + if ($reviewTenantsById !== []) { + $reviewSection = $this->reviewFollowUpSection( + user: $user, + workspace: $workspace, + reviewTenants: $reviewTenantsById, + selectedTenant: $selectedTenant, + navigationContext: $navigationContext, + ); + $allSections[$reviewSection['key']] = $reviewSection; + $availableFamilies[] = [ + 'key' => $reviewSection['key'], + 'label' => $reviewSection['label'], + 'count' => $reviewSection['count'], + ]; + $familyCounts[$reviewSection['key']] = $reviewSection['count']; + } + + $sections = []; + + foreach (self::FAMILY_ORDER as $familyKey) { + $section = $allSections[$familyKey] ?? null; + + if (! is_array($section)) { + continue; + } + + if ($selectedFamily !== null) { + if ($familyKey === $selectedFamily) { + $sections[] = $section; + } + + continue; + } + + if ((int) ($section['count'] ?? 0) > 0) { + $sections[] = $section; + } + } + + return [ + 'sections' => $sections, + 'available_families' => $availableFamilies, + 'family_counts' => $familyCounts, + 'total_count' => array_sum($familyCounts), + ]; + } + + /** + * @param array $tenants + * @return array + */ + private function indexTenants(array $tenants): array + { + $indexed = []; + + foreach ($tenants as $tenant) { + $indexed[(int) $tenant->getKey()] = $tenant; + } + + return $indexed; + } + + /** + * @param array $visibleFindingTenants + * @return array + */ + private function assignedFindingsSection( + User $user, + array $visibleFindingTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $overdueCount = (clone $baseQuery) + ->whereNotNull('due_at') + ->where('due_at', '<', now()) + ->count(); + $entries = $this->orderedAssignedFindingsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10)) + ->all(); + + return [ + 'key' => 'assigned_findings', + 'label' => 'Assigned findings', + 'count' => $count, + 'summary' => $this->assignedFindingsSummary($count, $overdueCount), + 'dominant_action_label' => 'Open my findings', + 'dominant_action_url' => $this->appendQuery( + MyFindingsInbox::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + ], static fn (mixed $value): bool => is_string($value) && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No assigned findings match this tenant filter right now.' + : 'No assigned findings are visible right now.', + ]; + } + + /** + * @param array $visibleFindingTenants + * @return array + */ + private function intakeFindingsSection( + array $visibleFindingTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $needsTriageCount = (clone $baseQuery) + ->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED]) + ->count(); + $entries = $this->orderedIntakeFindingsQuery(clone $baseQuery) + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20)) + ->all(); + + return [ + 'key' => 'intake_findings', + 'label' => 'Findings intake', + 'count' => $count, + 'summary' => $this->intakeFindingsSummary($count, $needsTriageCount), + 'dominant_action_label' => 'Open findings intake', + 'dominant_action_url' => $this->appendQuery( + FindingsIntakeQueue::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $selectedTenant?->external_id, + 'view' => $needsTriageCount > 0 ? 'needs_triage' : null, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ), + $navigationContext?->toQuery() ?? [], + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No intake findings match this tenant filter right now.' + : 'No intake findings are visible right now.', + ]; + } + + /** + * @param array $authorizedTenants + * @return array + */ + private function operationsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant); + $staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant); + $terminalCount = (clone $terminalQuery)->count(); + $staleCount = (clone $staleQuery)->count(); + $entries = array_merge( + (clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(), + (clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(), + ); + $entries = collect($entries) + ->unique(fn (OperationRun $run): int => (int) $run->getKey()) + ->sortBy([ + fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1, + fn (OperationRun $run): int => -1 * (int) $run->getKey(), + ]) + ->take(self::PREVIEW_LIMIT) + ->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext)) + ->values() + ->all(); + $dominantProblemClass = $terminalCount > 0 + ? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + : OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION; + + return [ + 'key' => 'stale_operations', + 'label' => 'Operations follow-up', + 'count' => $terminalCount + $staleCount, + 'summary' => $this->operationsSummary($terminalCount, $staleCount), + 'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations', + 'dominant_action_url' => OperationRunLinks::index( + tenant: $selectedTenant, + context: $navigationContext, + problemClass: $dominantProblemClass, + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No stale or terminal follow-up operations match this tenant filter right now.' + : 'No stale or terminal follow-up operations are visible right now.', + ]; + } + + /** + * @param array $authorizedTenants + * @return array + */ + private function alertsSection( + Workspace $workspace, + array $authorizedTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant); + $count = (clone $baseQuery)->count(); + $entries = (clone $baseQuery) + ->latest('created_at') + ->latest('id') + ->limit(self::PREVIEW_LIMIT) + ->get() + ->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext)) + ->all(); + + return [ + 'key' => 'alert_delivery_failures', + 'label' => 'Alert delivery failures', + 'count' => $count, + 'summary' => $this->alertsSummary($count), + 'dominant_action_label' => 'Open alert deliveries', + 'dominant_action_url' => $this->appendQuery( + AlertDeliveryResource::getUrl(panel: 'admin'), + array_replace_recursive( + $navigationContext?->toQuery() ?? [], + [ + 'tableFilters' => array_filter([ + 'status' => ['value' => AlertDelivery::STATUS_FAILED], + 'tenant_id' => $selectedTenant instanceof Tenant + ? ['value' => (string) $selectedTenant->getKey()] + : null, + ], static fn (mixed $value): bool => $value !== null), + ], + ), + ), + 'entries' => $entries, + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No failed alert deliveries match this tenant filter right now.' + : 'No failed alert deliveries are visible right now.', + ]; + } + + /** + * @param array $reviewTenants + * @return array + */ + private function reviewFollowUpSection( + User $user, + Workspace $workspace, + array $reviewTenants, + ?Tenant $selectedTenant, + ?CanonicalNavigationContext $navigationContext, + ): array { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($reviewTenants); + $backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds); + $recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant); + $resolved = $this->tenantTriageReviewStateResolver->resolveMany( + workspaceId: (int) $workspace->getKey(), + tenantIds: $tenantIds, + backupHealthByTenant: $backupHealthByTenant, + recoveryEvidenceByTenant: $recoveryEvidenceByTenant, + ); + $latestPublishedReviews = $this->tenantReviewRegisterService + ->latestPublishedQuery($user, $workspace) + ->get() + ->keyBy('tenant_id') + ->all(); + + $rawEntries = []; + + foreach ($tenantIds as $tenantId) { + $tenant = $reviewTenants[$tenantId] ?? null; + $rows = $resolved['rows'][$tenantId] ?? null; + + if (! $tenant instanceof Tenant || ! is_array($rows)) { + continue; + } + + foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) { + $row = $rows[$family] ?? null; + + if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) { + continue; + } + + $derivedState = $row['derived_state'] ?? null; + + if (! in_array($derivedState, [ + TenantTriageReview::STATE_FOLLOW_UP_NEEDED, + TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, + ], true)) { + continue; + } + + $rawEntries[] = $this->reviewEntry( + tenant: $tenant, + family: $family, + row: $row, + latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null, + navigationContext: $navigationContext, + ); + } + } + + usort($rawEntries, function (array $left, array $right): int { + $leftRank = (int) ($left['urgency_rank'] ?? 0); + $rightRank = (int) ($right['urgency_rank'] ?? 0); + + if ($leftRank !== $rightRank) { + return $leftRank <=> $rightRank; + } + + return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? '')); + }); + + $followUpCount = collect($rawEntries) + ->where('status_label', 'Follow-up needed') + ->count(); + $changedCount = collect($rawEntries) + ->where('status_label', 'Changed since review') + ->count(); + + return [ + 'key' => 'review_follow_up', + 'label' => 'Review follow-up', + 'count' => count($rawEntries), + 'summary' => $this->reviewSummary($followUpCount, $changedCount), + 'dominant_action_label' => 'Open review follow-up', + 'dominant_action_url' => $selectedTenant instanceof Tenant + ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) + : $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( + $navigationContext?->toQuery() ?? [], + [ + 'backup_posture' => [ + TenantBackupHealthAssessment::POSTURE_ABSENT, + TenantBackupHealthAssessment::POSTURE_STALE, + TenantBackupHealthAssessment::POSTURE_DEGRADED, + ], + 'recovery_evidence' => [ + TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED, + TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED, + ], + 'review_state' => [ + TenantTriageReview::STATE_FOLLOW_UP_NEEDED, + TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, + ], + 'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ], + )), + 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), + 'empty_state' => $selectedTenant instanceof Tenant + ? 'No review follow-up is visible for this tenant filter right now.' + : 'No review follow-up is visible right now.', + ]; + } + + /** + * @param array $visibleFindingTenants + */ + private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($visibleFindingTenants); + + return Finding::query() + ->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name']) + ->withSubjectDisplayName() + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->where('assignee_user_id', (int) $user->getKey()) + ->whereIn('status', Finding::openStatusesForQuery()); + } + + private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + 'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc', + [now()], + ) + ->orderByRaw('case when due_at is null then 1 else 0 end asc') + ->orderBy('due_at') + ->orderByDesc('id'); + } + + /** + * @param array $visibleFindingTenants + */ + private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = $selectedTenant instanceof Tenant + ? [(int) $selectedTenant->getKey()] + : array_keys($visibleFindingTenants); + + return Finding::query() + ->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name']) + ->withSubjectDisplayName() + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->whereNull('assignee_user_id') + ->whereIn('status', Finding::openStatusesForQuery()); + } + + private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + { + return $query + ->orderByRaw( + "case + when due_at is not null and due_at < ? then 0 + when status = ? then 1 + when status = ? then 2 + else 3 + end asc", + [now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW], + ) + ->orderByRaw('case when due_at is null then 1 else 0 end asc') + ->orderBy('due_at') + ->orderByDesc('id'); + } + + /** + * @param array $authorizedTenants + */ + private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant) + ->terminalFollowUp(); + } + + /** + * @param array $authorizedTenants + */ + private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant) + ->activeStaleAttention(); + } + + /** + * @param array $authorizedTenants + */ + private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = array_keys($authorizedTenants); + + return OperationRun::query() + ->with('tenant') + ->where('workspace_id', (int) $workspace->getKey()) + ->where(function ($query) use ($selectedTenant, $tenantIds): void { + if ($selectedTenant instanceof Tenant) { + $query->where('tenant_id', (int) $selectedTenant->getKey()); + + return; + } + + $query + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->orWhereNull('tenant_id'); + }); + } + + /** + * @param array $authorizedTenants + */ + private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder + { + $tenantIds = array_keys($authorizedTenants); + + return AlertDelivery::query() + ->with('tenant') + ->where('workspace_id', (int) $workspace->getKey()) + ->where('status', AlertDelivery::STATUS_FAILED) + ->where(function ($query) use ($selectedTenant, $tenantIds): void { + if ($selectedTenant instanceof Tenant) { + $query->where('tenant_id', (int) $selectedTenant->getKey()); + + return; + } + + $query + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->orWhereNull('tenant_id'); + }); + } + + /** + * @return array + */ + private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array + { + $sublineParts = array_values(array_filter([ + $finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null, + FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding), + $finding->reopened_at !== null ? 'Reopened' : null, + ])); + + return [ + 'family_key' => $familyKey, + 'source_model' => Finding::class, + 'source_key' => (string) $finding->getKey(), + 'tenant_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null, + 'tenant_label' => $finding->tenant?->name, + 'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(), + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => $baseUrgencyRank + + ($finding->due_at?->isPast() === true ? 0 : 1) + + ($finding->reopened_at !== null ? 0 : 1), + 'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(), + 'destination_url' => $this->appendQuery( + FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @return array + */ + private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array + { + $problemClass = $run->problemClass(); + + return [ + 'family_key' => 'stale_operations', + 'source_model' => OperationRun::class, + 'source_key' => (string) $run->getKey(), + 'tenant_id' => $run->tenant ? (int) $run->tenant->getKey() : null, + 'tenant_label' => $run->tenant?->name, + 'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + ? 'Terminal follow-up operation' + : 'Stale active operation', + 'subline' => OperationRunLinks::identifier($run), + 'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1, + 'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP + ? 'Terminal follow-up' + : 'Stale', + 'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @return array + */ + private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array + { + $payload = is_array($delivery->payload) ? $delivery->payload : []; + $headline = is_string($payload['title'] ?? null) && $payload['title'] !== '' + ? (string) $payload['title'] + : 'Failed alert delivery'; + $sublineParts = array_values(array_filter([ + is_string($delivery->last_error_message) && $delivery->last_error_message !== '' + ? $delivery->last_error_message + : null, + is_string($delivery->event_type) && $delivery->event_type !== '' + ? $delivery->event_type + : null, + ])); + + return [ + 'family_key' => 'alert_delivery_failures', + 'source_model' => AlertDelivery::class, + 'source_key' => (string) $delivery->getKey(), + 'tenant_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null, + 'tenant_label' => $delivery->tenant?->name, + 'headline' => $headline, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => 0, + 'status_label' => 'Failed', + 'destination_url' => $this->appendQuery( + AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'), + $navigationContext?->toQuery() ?? [], + ), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + /** + * @param array $row + * @return array + */ + private function reviewEntry( + Tenant $tenant, + string $family, + array $row, + mixed $latestPublishedReview, + ?CanonicalNavigationContext $navigationContext, + ): array { + $state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED); + $familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH + ? 'Backup health' + : 'Recovery evidence'; + $headline = $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED + ? $familyLabel.' needs review follow-up' + : $familyLabel.' changed since review'; + $sublineParts = array_values(array_filter([ + is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== '' + ? 'Last review: '.$row['reviewed_by_user_name'] + : null, + isset($row['reviewed_at']) && $row['reviewed_at'] !== null + ? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString() + : null, + ])); + $destinationUrl = $latestPublishedReview !== null + ? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant') + : CustomerReviewWorkspace::tenantPrefilterUrl($tenant); + + return [ + 'family_key' => 'review_follow_up', + 'source_model' => TenantTriageReview::class, + 'source_key' => (string) $tenant->getKey().':'.$family, + 'tenant_id' => (int) $tenant->getKey(), + 'tenant_label' => $tenant->name, + 'headline' => $headline, + 'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts), + 'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1, + 'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED + ? 'Follow-up needed' + : 'Changed since review', + 'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []), + 'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox', + ]; + } + + private function assignedFindingsSummary(int $count, int $overdueCount): string + { + if ($count === 0) { + return 'No assigned findings are visible in the current scope.'; + } + + if ($overdueCount > 0) { + return sprintf( + '%d assigned finding%s remain open. %d %s overdue.', + $count, + $count === 1 ? '' : 's', + $overdueCount, + $overdueCount === 1 ? 'is' : 'are', + ); + } + + return sprintf( + '%d assigned finding%s remain open in the visible scope.', + $count, + $count === 1 ? '' : 's', + ); + } + + private function intakeFindingsSummary(int $count, int $needsTriageCount): string + { + if ($count === 0) { + return 'No intake findings are visible in the current scope.'; + } + + return sprintf( + '%d unassigned finding%s remain in intake. %d still need first triage.', + $count, + $count === 1 ? '' : 's', + $needsTriageCount, + ); + } + + private function operationsSummary(int $terminalCount, int $staleCount): string + { + if ($terminalCount + $staleCount === 0) { + return 'No stale or terminal follow-up operations are visible in the current scope.'; + } + + if ($terminalCount > 0 && $staleCount > 0) { + return sprintf( + '%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.', + $terminalCount, + $terminalCount === 1 ? '' : 's', + $staleCount, + $staleCount === 1 ? '' : 's', + ); + } + + if ($terminalCount > 0) { + return sprintf( + '%d terminal follow-up operation%s need monitoring attention.', + $terminalCount, + $terminalCount === 1 ? '' : 's', + ); + } + + return sprintf( + '%d stale active run%s need monitoring attention.', + $staleCount, + $staleCount === 1 ? '' : 's', + ); + } + + private function alertsSummary(int $count): string + { + if ($count === 0) { + return 'No failed alert deliveries are visible in the current scope.'; + } + + return sprintf( + '%d failed alert delivery attempt%s remain visible in this workspace.', + $count, + $count === 1 ? '' : 's', + ); + } + + private function reviewSummary(int $followUpCount, int $changedCount): string + { + $total = $followUpCount + $changedCount; + + if ($total === 0) { + return 'No review follow-up is visible in the current scope.'; + } + + return sprintf( + '%d review concern%s need attention. %d marked follow-up needed and %d changed since review.', + $total, + $total === 1 ? '' : 's', + $followUpCount, + $changedCount, + ); + } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url.$separator.http_build_query($query); + } +} \ No newline at end of file diff --git a/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php new file mode 100644 index 00000000..6280e61e --- /dev/null +++ b/apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php @@ -0,0 +1,164 @@ + + @php + $scope = $this->appliedScope(); + $sections = $this->sections(); + $emptyState = $this->calmEmptyState(); + @endphp + + +
+
+ + Governance inbox +
+ +
+

+ Governance inbox +

+ +

+ This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. +

+
+ +
+ @if (filled($scope['workspace_label'] ?? null)) + + Workspace: {{ $scope['workspace_label'] }} + + @endif + + + Scope: {{ $scope['family_label'] ?? 'All attention' }} + + + + Visible items: {{ $scope['total_count'] ?? 0 }} + + + @if (filled($scope['tenant_label'] ?? null)) + + Tenant: {{ $scope['tenant_label'] }} + + @endif +
+ +
+ + All attention + {{ $scope['total_count'] ?? 0 }} + + + @foreach ($this->availableFamilies() as $family) + + {{ $family['label'] }} + {{ $family['count'] }} + + @endforeach +
+ + @if ($this->hasTenantPrefilter()) +
+ The inbox is currently filtered to one tenant. + + + Clear tenant filter + +
+ @endif +
+
+ + @if ($sections === []) + +
+
+

{{ $emptyState['title'] }}

+

{{ $emptyState['body'] }}

+
+ + @if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null)) +
+ + {{ $emptyState['action_label'] }} + +
+ @endif +
+
+ @else + @foreach ($sections as $section) + +
+
+
+
+

{{ $section['label'] }}

+ + {{ $section['count'] }} + +
+ +

{{ $section['summary'] }}

+
+ +
+ + {{ $section['dominant_action_label'] }} + +
+
+ + @if ($section['count'] === 0) +
+ {{ $section['empty_state'] }} +
+ @else +
    + @foreach ($section['entries'] as $entry) +
  • +
    +
    + @if (filled($entry['tenant_label'] ?? null)) +
    + {{ $entry['tenant_label'] }} +
    + @endif + +
    + + {{ $entry['headline'] }} + + + + {{ $entry['status_label'] }} + +
    + + @if (filled($entry['subline'] ?? null)) +

    {{ $entry['subline'] }}

    + @endif +
    + +
    + + Open source + +
    +
    +
  • + @endforeach +
+ @endif +
+
+ @endforeach + @endif +
\ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php new file mode 100644 index 00000000..2b83361f --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php @@ -0,0 +1,99 @@ +create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertRedirect('/admin/choose-workspace'); +}); + +it('returns 404 for users outside the active workspace on the governance inbox route', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) Workspace::factory()->create()->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for workspace members with no qualifying family visibility anywhere', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + mock(WorkspaceCapabilityResolver::class, function ($mock): void { + $mock->shouldReceive('isMember')->andReturnTrue(); + $mock->shouldReceive('can')->andReturnFalse(); + }); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertForbidden(); +}); + +it('allows readonly tenant members to open the governance inbox through operations-family visibility', function (): void { + $tenant = Tenant::factory()->create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Governance inbox'); +}); + +it('returns 404 for explicit tenant filters outside the actor scope', function (): void { + $visibleTenant = Tenant::factory()->create(['status' => 'active']); + [$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly'); + + $hiddenTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $visibleTenant->workspace_id, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey()) + ->assertNotFound(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php new file mode 100644 index 00000000..506e626e --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php @@ -0,0 +1,64 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner'); + + $finding = Finding::factory() + ->for($tenant) + ->assignedTo((int) $user->getKey()) + ->create(); + + $run = OperationRun::factory() + ->forTenant($tenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + $context = new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')), + backLinkLabel: 'Back to governance inbox', + backLinkUrl: GovernanceInbox::getUrl(panel: 'admin'), + ); + + $response = $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')); + + $response->assertOk(); + + $expectedMyFindingsUrl = htmlspecialchars( + MyFindingsInbox::getUrl(panel: 'admin').'?'.http_build_query($context->toQuery()), + ENT_QUOTES, + ); + $expectedOperationUrl = htmlspecialchars( + OperationRunLinks::tenantlessView($run, $context), + ENT_QUOTES, + ); + + $response->assertSee($expectedMyFindingsUrl, false) + ->assertSee($expectedOperationUrl, false) + ->assertSee((string) $finding->getKey()) + ->assertSee('nav%5Bback_label%5D=Back+to+governance+inbox', false); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php new file mode 100644 index 00000000..4f3a33a0 --- /dev/null +++ b/apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php @@ -0,0 +1,143 @@ +create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner'); + + $bravoTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $bravoTenant->getKey() => ['role' => 'owner'], + ]); + + Finding::factory() + ->for($alphaTenant) + ->assignedTo((int) $user->getKey()) + ->ownedBy((int) $user->getKey()) + ->overdueByHours() + ->create(); + + Finding::factory() + ->for($bravoTenant) + ->reopened() + ->create(); + + OperationRun::factory() + ->forTenant($alphaTenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'status' => AlertDelivery::STATUS_FAILED, + 'payload' => [ + 'title' => 'Delivery failed', + 'body' => 'A notification destination failed.', + ], + ]); + + $backupHealthResolver = app(TenantBackupHealthResolver::class); + $fingerprints = app(TenantTriageReviewFingerprint::class); + $alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant)); + + expect($alphaBackupFingerprint)->not->toBeNull(); + + TenantTriageReview::factory() + ->for($alphaTenant) + ->followUpNeeded() + ->create([ + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => $alphaBackupFingerprint['fingerprint'], + 'review_snapshot' => $alphaBackupFingerprint['snapshot'], + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('Assigned findings') + ->assertSee('Findings intake') + ->assertSee('Operations follow-up') + ->assertSee('Alert delivery failures') + ->assertSee('Review follow-up') + ->assertSee('Open my findings') + ->assertSee('Open terminal follow-up') + ->assertSee('Open alert deliveries') + ->assertSee('Open review follow-up'); +}); + +it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void { + $alphaTenant = Tenant::factory()->create([ + 'status' => 'active', + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + [$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner'); + + $bravoTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + (int) $bravoTenant->getKey() => ['role' => 'owner'], + ]); + + Finding::factory() + ->for($bravoTenant) + ->assignedTo((int) $user->getKey()) + ->create(); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $alphaTenant->workspace_id, + 'status' => AlertDelivery::STATUS_FAILED, + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey()) + ->assertOk() + ->assertSee('This tenant filter is hiding other visible attention') + ->assertSee('Clear tenant filter'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id]) + ->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures') + ->assertOk() + ->assertSee('Alert delivery failures') + ->assertSee('No failed alert deliveries match this tenant filter right now.') + ->assertDontSee('Open my findings'); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php index e39b2941..f9fed5b6 100644 --- a/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php +++ b/apps/platform/tests/Feature/PlatformRelocation/CommandModelSmokeTest.php @@ -27,3 +27,10 @@ ->toContain(".:/var/www/repo:ro") ->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo'); }); + +it('keeps the local queue service in code-reloading listen mode', function (): void { + $compose = file_get_contents(repo_path('docker-compose.yml')); + + expect($compose)->toContain('command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3') + ->not->toContain('command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000'); +}); diff --git a/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php new file mode 100644 index 00000000..938561ac --- /dev/null +++ b/apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php @@ -0,0 +1,197 @@ +create(); + $user = User::factory()->create(); + + $alphaTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + $bravoTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + Finding::factory() + ->for($alphaTenant) + ->assignedTo((int) $user->getKey()) + ->ownedBy((int) $user->getKey()) + ->overdueByHours() + ->create([ + 'status' => Finding::STATUS_IN_PROGRESS, + 'subject_external_id' => 'assigned-finding', + ]); + + Finding::factory() + ->for($bravoTenant) + ->reopened() + ->create([ + 'subject_external_id' => 'intake-finding', + ]); + + OperationRun::factory() + ->forTenant($alphaTenant) + ->create([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'completed_at' => now()->subMinute(), + ]); + + OperationRun::factory() + ->forTenant($bravoTenant) + ->create([ + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(6), + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + 'event_type' => 'alerts.failed_delivery', + 'payload' => [ + 'title' => 'Delivery failed', + 'body' => 'Alert delivery could not be completed.', + ], + ]); + + $backupHealthResolver = app(TenantBackupHealthResolver::class); + $fingerprints = app(TenantTriageReviewFingerprint::class); + + $alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant)); + $bravoBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($bravoTenant)); + + expect($alphaBackupFingerprint)->not->toBeNull() + ->and($bravoBackupFingerprint)->not->toBeNull(); + + TenantTriageReview::factory() + ->for($alphaTenant) + ->followUpNeeded() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => $alphaBackupFingerprint['fingerprint'], + 'review_snapshot' => $alphaBackupFingerprint['snapshot'], + 'reviewed_at' => now()->subDay(), + ]); + + TenantTriageReview::factory() + ->for($bravoTenant) + ->reviewed() + ->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'reviewed_by_user_id' => (int) $user->getKey(), + 'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + 'review_fingerprint' => hash('sha256', 'stale-review-fingerprint'), + 'review_snapshot' => $bravoBackupFingerprint['snapshot'], + 'reviewed_at' => now()->subDays(2), + ]); + + $context = new CanonicalNavigationContext( + sourceSurface: 'governance.inbox', + canonicalRouteName: 'filament.admin.pages.governance.inbox', + backLinkLabel: 'Back to governance inbox', + backLinkUrl: '/admin/governance/inbox', + ); + + $payload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$alphaTenant, $bravoTenant], + visibleFindingTenants: [$alphaTenant, $bravoTenant], + reviewTenants: [$alphaTenant, $bravoTenant], + canViewAlerts: true, + navigationContext: $context, + ); + + expect(collect($payload['sections'])->pluck('key')->all()) + ->toBe([ + 'assigned_findings', + 'intake_findings', + 'stale_operations', + 'alert_delivery_failures', + 'review_follow_up', + ]) + ->and($payload['family_counts'])->toMatchArray([ + 'assigned_findings' => 1, + 'intake_findings' => 1, + 'stale_operations' => 2, + 'alert_delivery_failures' => 1, + 'review_follow_up' => 2, + ]); + + $sections = collect($payload['sections'])->keyBy('key'); + + expect($sections['assigned_findings']['dominant_action_url']) + ->toContain('/admin/findings/my-work') + ->toContain('nav%5Bback_label%5D=Back+to+governance+inbox') + ->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up') + ->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up') + ->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed') + ->and(collect($sections['review_follow_up']['entries'])->pluck('status_label')->all()) + ->toBe(['Follow-up needed', 'Changed since review']); +}); + +it('keeps an explicitly selected visible family with an honest empty state when tenant filtering removes every row', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + $alphaTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Alpha Tenant', + 'external_id' => 'alpha-tenant', + ]); + $bravoTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Bravo Tenant', + 'external_id' => 'bravo-tenant', + ]); + + AlertDelivery::factory()->create([ + 'tenant_id' => null, + 'workspace_id' => (int) $workspace->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + ]); + + $payload = app(GovernanceInboxSectionBuilder::class)->build( + user: $user, + workspace: $workspace, + authorizedTenants: [$alphaTenant, $bravoTenant], + visibleFindingTenants: [], + reviewTenants: [], + canViewAlerts: true, + selectedTenant: $alphaTenant, + selectedFamily: 'alert_delivery_failures', + ); + + expect($payload['sections'])->toHaveCount(1) + ->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures') + ->and($payload['sections'][0]['count'])->toBe(0) + ->and($payload['sections'][0]['empty_state'])->toContain('tenant filter'); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 20e950b0..cd60f661 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,7 @@ services: - laravel.test - pgsql - redis - command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000 + command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3 pgsql: image: 'postgres:16' diff --git a/specs/250-decision-governance-inbox/checklists/requirements.md b/specs/250-decision-governance-inbox/checklists/requirements.md new file mode 100644 index 00000000..936e80cf --- /dev/null +++ b/specs/250-decision-governance-inbox/checklists/requirements.md @@ -0,0 +1,70 @@ +# Preparation Review Checklist: Decision-Based Governance Inbox v1 + +**Purpose**: Validate the governance inbox preparation package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation +**Created**: 2026-04-28 +**Feature**: [spec.md](../spec.md) + +## Applicability And Low-Impact Gate + +- [x] CHK001 The package explicitly treats this as an operator-facing workspace decision surface, so the low-impact `N/A` path is not used. +- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording. + +## Native, Shared-Family, And State Ownership + +- [x] CHK003 The inbox remains a native Filament page that reuses existing source surfaces instead of introducing a fake-native task console or separate monitoring shell. +- [x] CHK004 Shared families remain shared: findings, operations, alerts, and review follow-up stay on their existing source pages, while the new page stays a routing and decision layer. +- [x] CHK005 Page and URL-query state owners are named once, and the package does not collapse them into new persisted workflow state. +- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: each section has one dominant source CTA and the page owns no mutation lane. + +## Shared Pattern Reuse + +- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and the existing source pages. +- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded `Support/GovernanceInbox/` seam is explicitly constrained as a last resort rather than a new default abstraction. +- [x] CHK009 The package does not create a parallel operator UX language for claim, acknowledge, stale-run handling, or review follow-up; it routes into the current source-family vocabulary. + +## OperationRun Start UX Contract + +- [x] CHK019 The package explicitly states that the inbox only deep-links into existing `OperationRun` detail and does not start, queue, or complete runs. +- [x] CHK020 Canonical operation URLs are delegated to the shared `OperationRunLinks` path rather than recomposed locally on the inbox page. +- [x] CHK021 No queued DB-notification or terminal-notification behavior is added because the slice is read-only. +- [x] CHK022 No OperationRun exception is required; if implementation later adds local run-start or blocked-run messaging, that would be out-of-scope drift. + +## Provider Boundary And Vocabulary + +- [x] CHK010 The package keeps provider-specific semantics behind existing normalized governance, alerting, and review seams and does not spread provider language into a new platform-core contract. +- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays inside existing workspace, tenant, operations, findings, alerts, and review vocabulary. + +## Signals, Exceptions, And Test Depth + +- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package. +- [x] CHK013 No bounded exception is required in the preparation package; if implementation proves a bounded assembly helper is necessary, it must be recorded in the active feature close-out entry. +- [x] CHK014 The required surface test profile is explicit: `global-context-shell`. +- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused `Unit` plus `Feature` coverage only. + +## Audience-Aware Disclosure And Decision Hierarchy + +- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support or raw evidence. +- [x] CHK024 The inbox default path does not expose raw JSON, copied payloads, provider diagnostics, or other debug semantics by default. +- [x] CHK025 Exactly one dominant next action remains primary per section or entry: open the relevant existing source surface. +- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by keeping proof and detailed reasoning on the source pages. +- [x] CHK027 Support/raw sections remain off the inbox page entirely, and the page stays within Filament visual language, progressive disclosure, and calm read-only presentation. + +## Review Outcome + +- [x] CHK016 Review outcome class: `acceptable-special-case` +- [x] CHK017 Workflow outcome: `keep` +- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records any bounded assembly-seam exception and the final proof outcome. + +## Notes + +- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and supporting design artifacts. It does not claim application code exists. +- The slice remains bounded to one read-only workspace decision surface in the current admin plane. No new task engine, no new attention state, and no local mutation lane are approved by this package. +- If implementation later proves that a bounded `Support/GovernanceInbox/` seam is necessary, that must stay derived and page-scoped rather than becoming a generalized workflow framework. + +## Guardrail / Exception / Smoke Coverage + +- Implementation status: complete for the bounded v1 slice. +- Guardrail result: PASS. The implemented page stayed native, read-only, shared-primitives-first, and inside the existing admin plane without adding a new task engine, persisted inbox truth, or local mutation lane. +- Bounded exception result: `document-in-feature`. `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` was added as the smallest readable cross-family assembly seam. +- Validation result: the focused unit and feature proof command passed with `10 passed (53 assertions)`, and dirty-only Pint passed. +- Smoke result: PASS. A manual integrated-browser run on `/admin/governance/inbox` verified route load, canonical operations drill-through with `nav` context, and successful return to the inbox. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml b/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml new file mode 100644 index 00000000..89ce5f8d --- /dev/null +++ b/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml @@ -0,0 +1,159 @@ +openapi: 3.1.0 +info: + title: Decision-Based Governance Inbox v1 + version: 0.1.0 + summary: Conceptual contract for the canonical governance inbox page. +paths: + /admin/governance/inbox: + get: + summary: Render the governance inbox page + description: >- + Returns the derived governance inbox composition for the current workspace actor. + This is a conceptual page contract used for planning, not a public API commitment. + parameters: + - in: query + name: tenant_id + schema: + type: integer + nullable: true + description: Optional tenant prefilter. Out-of-scope values resolve as not found. + - in: query + name: family + schema: + type: string + enum: + - assigned_findings + - intake_findings + - stale_operations + - alert_delivery_failures + - review_follow_up + description: Optional source-family filter. `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention. + - in: query + name: nav[source_surface] + schema: + type: string + description: Optional shared navigation context source. + responses: + '200': + description: Derived governance inbox payload for page rendering. + content: + application/json: + schema: + type: object + required: + - title + - applied_scope + - sections + properties: + title: + type: string + example: Governance inbox + applied_scope: + type: object + properties: + tenant_id: + type: integer + nullable: true + family: + type: string + nullable: true + workspace_scoped: + type: boolean + sections: + type: array + items: + type: object + required: + - key + - label + - count + - summary + - dominant_action + - entries + properties: + key: + type: string + description: Family key; `stale_operations` covers stale and terminal-follow-up operations attention. + label: + type: string + count: + type: integer + summary: + type: string + empty_state: + type: string + nullable: true + description: Family-specific empty-state copy used when the family is explicitly selected but has no visible entries. + dominant_action: + type: object + required: + - label + - url + properties: + label: + type: string + url: + type: string + entries: + type: array + items: + type: object + required: + - family_key + - source_model + - source_key + - headline + - status_label + - destination_url + properties: + family_key: + type: string + description: Matches the owning section key; `stale_operations` covers stale and terminal-follow-up operations attention. + source_model: + type: string + source_key: + type: string + tenant_id: + type: integer + nullable: true + tenant_label: + type: string + nullable: true + headline: + type: string + subline: + type: string + nullable: true + urgency_rank: + type: integer + status_label: + type: string + destination_url: + type: string + back_label: + type: string + nullable: true + '404': + description: Workspace membership missing or explicit tenant prefilter is outside scope. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + example: Not Found + '403': + description: Workspace member is in scope but lacks every qualifying visible-family capability for the inbox. + content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + example: Forbidden \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/data-model.md b/specs/250-decision-governance-inbox/data-model.md new file mode 100644 index 00000000..cdadd947 --- /dev/null +++ b/specs/250-decision-governance-inbox/data-model.md @@ -0,0 +1,103 @@ +# Data Model: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Model Posture + +This slice introduces no new persisted entity. Every object below is a derived read model used to compose one decision-first page over existing repo truth. + +## Existing Source Truth + +| Source Model | Ownership | Relevant Truth Reused | +|---|---|---| +| `Finding` | tenant-owned | assigned work, intake work, severity, due or overdue state, reopened state, tenant entitlement | +| `OperationRun` | tenant-owned with workspace monitoring access | stale or terminal-follow-up attention, canonical run destination | +| `AlertDelivery` | workspace-scoped | failed or otherwise operator-relevant alert delivery outcomes | +| `TenantReview` | tenant-owned | latest review drill-through destination | +| `TenantTriageReview` | tenant-owned | follow-up-needed and changed-since-review attention | + +## Derived Read Models + +### GovernanceInboxSection + +Represents one visible source family on the inbox page. + +| Field | Type | Notes | +|---|---|---| +| `key` | string | bounded page-local family key such as `assigned_findings`, `intake_findings`, `stale_operations`, `alert_delivery_failures`, `review_follow_up`; `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention | +| `label` | string | operator-facing section title aligned to the source family | +| `count` | int | visible item count for the current actor and active filters | +| `summary` | string | calm one-line summary of why the family matters | +| `dominant_action_label` | string | primary CTA label, routed to the existing source surface | +| `dominant_action_url` | string | canonical source destination | +| `entries` | list | bounded preview list, not a second queue truth | +| `empty_state` | string | optional local empty explanation when the family is selected explicitly | + +### GovernanceAttentionEntry + +Represents one preview item inside a visible section. + +| Field | Type | Notes | +|---|---|---| +| `family_key` | string | matches the owning `GovernanceInboxSection.key` | +| `source_model` | string | `Finding`, `OperationRun`, `AlertDelivery`, `TenantReview`, or `TenantTriageReview` | +| `source_key` | string | stable source identifier for routing only | +| `tenant_id` | int or null | nullable for workspace-scoped alert or run cases | +| `tenant_label` | string or null | only shown when truthful | +| `headline` | string | concise operator-facing summary | +| `subline` | string or null | bounded reason, owner, or due-state context | +| `urgency_rank` | int | derived sort priority within the family | +| `status_label` | string | reused source-family wording | +| `destination_url` | string | existing canonical route | +| `back_label` | string | return label back to the inbox | + +## Filter State + +### GovernanceInboxFilterState + +| Field | Type | Notes | +|---|---|---| +| `tenant_id` | int or null | optional tenant prefilter; explicit out-of-scope values return `404` | +| `family` | string or null | optional family filter for one visible source family; `stale_operations` remains the canonical filter key for stale or terminal-follow-up operations attention | +| `nav` | array or null | optional shared navigation payload used for return continuity | + +## Ordering Rules + +### Section Order + +1. Assigned findings +2. Findings intake +3. Stale or terminal-follow-up operations +4. Alert-delivery failures +5. Review follow-up + +This order is deliberately explicit and page-local. It is not a new persisted workflow taxonomy. + +### Entry Order + +- Findings-based sections reuse their existing queue ordering. +- Operations reuse the current monitoring-attention ordering exposed by the canonical operations surface. +- Alert-delivery failures order newest unresolved operator-relevant failures first. +- Review follow-up orders explicit follow-up-needed states before changed-since-review states. + +## Relationships + +- One `GovernanceInboxSection` maps to one existing source family. +- One `GovernanceInboxSection` has many derived `GovernanceAttentionEntry` values. +- Each `GovernanceAttentionEntry` points to exactly one existing source record and one existing source destination. +- No derived object owns or mutates source truth. + +## Persistence Rules + +- No new table. +- No new cache. +- No new inbox-specific audit stream. +- No new acknowledged, snoozed, or assigned state. + +## Data Integrity Rules + +- Hidden tenants never contribute to derived section counts or entry previews. +- Family visibility is capability-driven; invisible families do not render empty placeholders. +- Tenantless alert or operation entries must not invent tenant labels. +- Source destinations must stay canonical and existing; the inbox must not invent a parallel detail shell. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/plan.md b/specs/250-decision-governance-inbox/plan.md new file mode 100644 index 00000000..e8576d4e --- /dev/null +++ b/specs/250-decision-governance-inbox/plan.md @@ -0,0 +1,305 @@ +# Implementation Plan: Decision-Based Governance Inbox v1 + +**Branch**: `250-decision-governance-inbox` | **Date**: 2026-04-28 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [spec.md](spec.md) + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Introduce one canonical workspace governance inbox inside the existing `/admin` plane by adding a native Filament v5 read-only page that composes existing findings, alerts, stale-operations, and portfolio-triage signals into one decision-first work surface. The page should answer the first operator question quickly, then route into the existing source pages for execution and proof instead of creating a new cross-domain task engine. + +This slice is explicitly composition-only. It does not replace `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail surfaces; it does not add acknowledge, snooze, claim, or assignment mutations; and it does not create persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no new asset bundle is expected for v1. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing findings, alerts, operations, and review-triage services +**Storage**: PostgreSQL via existing `findings`, `operation_runs`, `alert_deliveries`, `tenant_reviews`, and `tenant_triage_reviews`; no new persistence planned +**Testing**: Pest v4 unit plus feature coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces +**Project Type**: Web application (Laravel monolith with Filament panels) +**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; family previews should be fetched through bounded derived queries rather than one polymorphic persistence layer +**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; avoid new persistence, new workflow states, new task engines, and page-local mutation semantics; reuse source-page routing and action hierarchies +**Scale/Scope**: 1 new admin page, 5 derived source families, 0 new runtime entities, and 1 bounded derived section assembly seam + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` for assigned-findings truth, urgency ordering, and workspace-shell tenant-prefilter behavior. +- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` for intake truth, `Needs triage` semantics, and read-first queue behavior. +- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` plus `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` for stale or terminal-follow-up operation attention and canonical run drill-through. +- `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, and the existing alerts cluster for alert-family entry points and delivery-failure truth. +- `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` for review follow-up and triage-state truth. +- `apps/platform/app/Models/Finding.php`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Models/AlertDelivery.php`, `apps/platform/app/Models/TenantReview.php`, and `apps/platform/app/Models/TenantTriageReview.php` for the source data contracts. +- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/OperationRunLinks.php` for source-page routing and return-link continuity. +- `apps/platform/app/Support/OperateHub/OperateHubShell.php`, `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php`, and `apps/platform/app/Support/Filament/TablePaginationProfiles.php` for workspace scope and durable filter state. +- `apps/platform/app/Support/Badges/BadgeRenderer.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php`, `apps/platform/app/Support/Rbac/UiEnforcement.php`, and `apps/platform/app/Support/Rbac/UiTooltips.php` for existing status, action, and capability affordance patterns. +- Likely new implementation files if code work later proceeds: `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, and a bounded support namespace under `apps/platform/app/Support/GovernanceInbox/` only if the page cannot stay readable with page-local composition. + +## UI / Filament & Livewire Fit + +- Implement as a native Filament v5 `Page` in the existing admin plane, not as a new Resource, custom SPA shell, or second monitoring console. +- Keep the inbox read-first and section-based. Each visible family should render one calm summary block plus bounded preview entries and one dominant CTA into the existing source surface. +- Do not model the inbox as a polymorphic table over mixed Eloquent records if that forces a new persisted or generic task abstraction. Section composition over existing family queries is the preferred v1 shape. +- Livewire v4 hydration must preserve tenant and family filter state through public, query-backed, or session-backed state. Do not rely on private properties for any state that must survive a Livewire interaction. +- The new surface is a `Page`, not a globally searchable `Resource`. Existing source resources retain their current search posture. + +## RBAC / Policy Fit + +- Workspace membership remains the first gate. The inbox should not render at all for non-members, and explicit out-of-scope tenant targeting must stay `404`. +- Page access stays capability-derived: the actor must be a workspace member and have visibility to at least one family through the same capability contract the source page already uses. In-scope workspace members who lack every qualifying family capability should receive `403`, not a silent empty shell. +- Findings families reuse tenant capability checks such as `Capabilities::TENANT_FINDINGS_VIEW`, while source mutations like claim or triage continue to enforce `Capabilities::TENANT_FINDINGS_ASSIGN` or `Capabilities::TENANT_FINDINGS_TRIAGE` on their existing surfaces. +- Review follow-up entries reuse `Capabilities::TENANT_REVIEW_VIEW`; any manual follow-up mutation remains on the existing review/triage seam and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE`. +- Alert-family visibility remains workspace-scoped through `Capabilities::ALERTS_VIEW`. +- Operations entries must only appear when the underlying run destination would already be visible through the existing operation-viewer and tenant-entitlement rules. The inbox must not invent a weaker path. + +## Audit / Logging Fit + +- The inbox is read-only and should not create a new page-view audit stream. +- Existing mutation or download actions continue to log on their existing source surfaces. +- The only acceptable additional audit work in v1 would be reuse of existing action IDs on underlying source pages if implementation discovers a missing drill-through event, but the inbox itself should not become a new audit-heavy surface. + +## Data & Query Fit + +- Prefer derived section queries over a generic inbox-item projector or persisted cache. +- The findings sections should reuse the same inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue` rather than duplicating lifecycle logic with new constants. +- The operations section should reuse the same stale or terminal-follow-up classification that already drives the canonical Operations page. Section-level operations CTAs may land on `/admin/operations`, but entry-level operation drill-through should land on the canonical run detail route `/admin/operations/{run}`. +- The alert section should derive from alert-delivery failure truth and the alerts overview, not from alert-rule configuration state. +- The review-follow-up section should derive from `TenantTriageReview` state and existing review register truth, not from a new parallel follow-up model. +- If implementation needs one bounded derived assembly seam, it should remain a page-scoped support helper that normalizes sections and preview entries only. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament +- **Shared-family relevance**: governance queues, monitoring drill-through, navigation continuity, badge/status reuse +- **State layers in scope**: page, URL-query +- **Audience modes in scope**: operator-MSP +- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third on source pages only +- **Raw/support gating plan**: hidden by default on the inbox page; source pages keep their existing capability-gated disclosure +- **One-primary-action / duplicate-truth control**: each section gets one dominant CTA into an existing source surface; later detail stays off the inbox page +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if implementation introduces a generic task model or local mutations +- **Special surface test profiles**: global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none planned; any new cross-domain workflow state or local mutation must be treated as exception-required drift +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `AlertDeliveryResource`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing source-page action-surface declarations +- **Shared abstractions reused**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `ActionSurfaceDeclaration`, and current source-page query rules +- **New abstraction introduced? why?**: one bounded section or entry assembler may be needed to keep the page readable and deterministic across families, but it must remain derived and page-scoped +- **Why the existing abstraction was sufficient or insufficient**: existing source pages are sufficient for truth and mutation, but insufficient as the first workspace attention surface because they only answer one family each +- **Bounded deviation / spread control**: none planned. If a support namespace is added, it must stay under `Support/GovernanceInbox/`, remain read-only, and not become a cross-product task engine + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes, deep-link only +- **Central contract reused**: `OperationRunLinks` and the existing tenantless operation viewer +- **Delegated UX behaviors**: existing canonical run URL resolution and navigation context only +- **Surface-owned behavior kept local**: deciding whether an operation attention entry appears and which existing run destination is primary +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: `N/A` +- **Platform-core seams**: existing governance, alerts, operations, and review vocabulary only +- **Neutral platform terms / contracts preserved**: `governance inbox`, `attention`, `operation`, `review follow-up`, `alert delivery failure`, and existing source nouns +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: `N/A` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first / snapshot truth: PASS. The inbox consumes existing findings, operations, alerts, and review state only. +- Read/write separation: PASS. The page stays read-only and pushes execution back to source surfaces. +- Graph contract path: PASS. No new Graph calls or provider contract work is part of this slice. +- Deterministic capabilities: PASS. The plan reuses existing capability registries and source-page rules. +- Workspace isolation + tenant isolation: PASS. Workspace membership remains a `404` boundary; explicit out-of-scope tenant filters remain `404`; broad listings omit hidden rows. +- RBAC-UX plane separation: PASS. Everything stays inside the admin `/admin` plane. +- Destructive confirmation standard: PASS by non-use. The inbox introduces no destructive or risky action. +- Global search safety: PASS. The new slice is a Page, not a searchable Resource. +- OperationRun and Ops-UX: PASS by deep-link-only reuse. The page starts no run and adds no new run UX state. +- Data minimization: PASS. Default-visible content stays limited to family, urgency, scope, and next action. +- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused `Unit` and `Feature` lanes only. +- Proportionality / no premature abstraction: PASS with one bounded exception. If a section assembler is needed, it remains page-scoped and derived. +- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored attention entity is planned. +- Behavioral state (STATE-001): PASS. The inbox reuses existing source states and does not add a second workflow state family. +- Shared pattern first / UI semantics / Filament native UI: PASS. Existing navigation, badge, and queue semantics are reused. +- Provider boundary (PROV-001): PASS. The slice stays on already-normalized platform seams. +- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new panel is required. +- Asset strategy: PASS. No new asset registration is planned; if implementation later registers an asset anyway, deployment keeps the normal `cd apps/platform && php artisan filament:assets` step. + +**Gate evaluation**: PASS. + +- The inbox stays inside the existing admin plane and current workspace or tenant membership model. +- The page remains a read-only decision hub, not a new execution workflow. +- Existing source pages and services are sufficient for v1 if implementation resists introducing a generic inbox state model. + +**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml)). + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for section and preview assembly plus source-link decisions; Feature for page rendering, authorization, filter behavior, and navigation continuity +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves family assembly without Filament boot cost; feature coverage proves page access, family visibility, tenant-prefilter behavior, and source-page routing on a native page +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` +- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse findings, operation runs, alert deliveries, reviews, and triage-review fixtures rather than adding browser setup or generic workflow helpers +- **Expensive defaults or shared helper growth introduced?**: no; any section assembler must stay cheap by default and avoid eager-loading broad unrelated state +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because tenant-prefilter and navigation continuity are part of the page contract +- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page stays read-only, and verify every CTA lands on an existing source surface with hidden tenants omitted from counts and labels +- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local unit plus feature increase +- **Review-stop questions**: lane fit, hidden fixture cost, accidental generic workflow helpers, source-page duplication risk +- **Escalation path**: `document-in-feature` for contained assembly-seam notes; `reject-or-split` if implementation introduces a generic task model or local mutation lane +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: routine read-surface and navigation upkeep stays inside this feature unless implementation proves a structural need for a broader workflow engine + +## Rollout & Risk Controls + +- Keep the v1 audience anchored to existing workspace operators and tenant-entitled actors only. +- Treat the page as a routing surface. Do not add local claim, acknowledge, snooze, or follow-up mutation actions during implementation. +- Prefer extending existing source query seams over introducing new persisted or cross-domain workflow state. +- Keep navigation labels aligned with the source pages so the inbox reads as an entry surface, not a replacement shell. +- Validate the page with focused unit and feature coverage before considering any broader dashboard-entry or widget work. + +## Project Structure + +### Documentation (this feature) + +```text +specs/250-decision-governance-inbox/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── governance-inbox.openapi.yaml +└── tasks.md # Created later by /speckit.tasks, not by this plan step +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── Findings/ +│ │ │ │ ├── MyFindingsInbox.php +│ │ │ │ └── FindingsIntakeQueue.php +│ │ │ ├── Governance/ +│ │ │ │ └── GovernanceInbox.php # likely new page if implementation proceeds +│ │ │ ├── Monitoring/ +│ │ │ │ ├── Operations.php +│ │ │ │ └── Alerts.php +│ │ │ └── Operations/ +│ │ │ └── TenantlessOperationRunViewer.php +│ │ └── Resources/ +│ │ ├── AlertDeliveryResource.php +│ │ └── TenantReviewResource.php +│ ├── Models/ +│ │ ├── Finding.php +│ │ ├── OperationRun.php +│ │ ├── AlertDelivery.php +│ │ ├── TenantReview.php +│ │ └── TenantTriageReview.php +│ ├── Services/ +│ │ ├── PortfolioTriage/TenantTriageReviewService.php +│ │ └── TenantReviews/TenantReviewRegisterService.php +│ ├── Support/ +│ │ ├── Badges/ +│ │ ├── Filament/ +│ │ ├── GovernanceInbox/ # only if a bounded support seam is required +│ │ ├── Navigation/ +│ │ ├── OperateHub/ +│ │ ├── OperationRunLinks.php +│ │ ├── Rbac/ +│ │ └── Ui/ActionSurface/ +│ └── Policies/ +├── bootstrap/providers.php +├── resources/views/filament/pages/governance/ # likely new page view if implementation proceeds +└── tests/ + ├── Feature/Governance/ + └── Unit/Support/GovernanceInbox/ +``` + +**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching view, and reuse existing source-page routing, RBAC, and status semantics rather than creating a separate workflow subsystem. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 - bounded section or entry assembler | one page still needs deterministic cross-family section ordering and source-surface links | inline page composition alone risks duplicated ordering rules and unreadable page code once five families are involved | + +## Proportionality Review + +- **Current operator problem**: operators cannot decide what needs attention first from one workspace surface despite the repo already having real findings, alerts, operations, and review-follow-up truth. +- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act. +- **Narrowest correct implementation**: add one read-only workspace inbox page over existing source-page queries and routing seams, with at most one bounded derived section or entry assembly helper. +- **Ownership cost created**: one page, one view, one bounded derived assembly seam, and focused unit plus feature coverage. +- **Alternative intentionally rejected**: a persisted inbox-item table or generic task engine was rejected because it adds durable workflow truth before the read-only decision surface is proven. +- **Release truth**: current-release workflow compression, not future workboard preparation. + +## Phase 0 — Research (output: research.md) + +Research resolved the remaining implementation-shaping decisions: + +- choose a section-based composition page over a polymorphic task table or persisted queue +- reuse findings queue semantics from `MyFindingsInbox` and `FindingsIntakeQueue` +- reuse stale or terminal-follow-up operation semantics from `Operations` +- treat alert-delivery failures as the narrow alert-family slice for v1 instead of alert-rule configuration +- reuse `TenantTriageReview` follow-up truth for review-family attention +- rely on `CanonicalNavigationContext` and `OperationRunLinks` for drill-through continuity + +**Output**: [research.md](research.md) + +## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md) + +Design artifacts capture the narrow implementation shape: + +- existing persisted truth reused: findings, operation runs, alert deliveries, tenant reviews, and triage reviews +- new code-owned truth limited to derived inbox sections and preview entries only +- conceptual contract covers one workspace page with optional tenant and family filters plus source-surface links +- quickstart documents the intended slice order, validation commands, and read-only posture + +**Artifacts**: + +- [data-model.md](data-model.md) +- [contracts/governance-inbox.openapi.yaml](contracts/governance-inbox.openapi.yaml) +- [quickstart.md](quickstart.md) + +## Phase 2 — Planning (for tasks.md) + +Dependency-ordered implementation outline for the later `tasks.md` step: + +1. Add the native governance inbox page shell and read-only view in the admin plane. +2. Resolve the bounded section assembly seam, preferring reuse of source-page query rules over a new workflow subsystem. +3. Add family sections for assigned findings, intake, stale operations, alert-delivery failures, and triage follow-up. +4. Reuse `CanonicalNavigationContext`, `RelatedNavigationResolver`, and `OperationRunLinks` for every drill-through path. +5. Add tenant and family filter state with honest empty-state behavior and `404` handling for explicit out-of-scope tenant targeting. +6. Add focused unit and feature tests only; no browser, queue, or heavy-governance family is expected. + +## Guardrail / Exception / Smoke Coverage + +- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the slice adds no new globally searchable Resource, no destructive inbox action, and no new registered asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are introduced. +- Shared seam outcome: `document-in-feature`. A bounded derived helper was required as `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` because the existing source pages did not expose a reusable cross-family inbox API. The seam stayed page-scoped and read-only; no persisted inbox state or generic workflow engine was introduced. +- Source CTA outcome: PASS. Assigned findings route to `MyFindingsInbox` and tenant finding detail, intake routes to `FindingsIntakeQueue` and tenant finding detail, operations route through `OperationRunLinks` into the canonical tenantless monitoring detail, alerts route to `AlertDeliveryResource` index or view, and review follow-up routes into the existing tenant review or customer review surfaces. The inbox page itself remains mutation-free. +- Filter and authorization outcome: PASS. Workspace membership remains the first gate, explicit out-of-scope tenant filters still resolve as `404`, in-scope members with no visible families still receive `403`, and tenant or family filters stay query-only and capability-safe. +- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`. +- Smoke evidence: integrated-browser smoke on `http://localhost/admin/governance/inbox` passed in an authenticated workspace session. The inbox loaded successfully, the operations-family CTA opened the canonical `/admin/operations` route with `problemClass=terminal_follow_up` plus the shared `nav` payload, the monitoring page rendered a visible `Back to governance inbox` control, and that return link brought the session back to `/admin/governance/inbox`. +- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Review outcome class: `acceptable-special-case`. +- Workflow outcome: `keep`. +- Exception note: none beyond the bounded section-builder seam already recorded above. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/quickstart.md b/specs/250-decision-governance-inbox/quickstart.md new file mode 100644 index 00000000..67464ec8 --- /dev/null +++ b/specs/250-decision-governance-inbox/quickstart.md @@ -0,0 +1,65 @@ +# Quickstart: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Purpose + +This quickstart captures the smallest intended implementation and validation path for the governance inbox slice. It is preparation-only guidance for later implementation work. + +## Planned Implementation Shape + +1. Add one native Filament page at `/admin/governance/inbox`. +2. Compose five bounded source families from existing repo truth: + - assigned findings + - findings intake + - stale or terminal-follow-up operations + - alert-delivery failures + - review follow-up +3. Keep the page read-only and route every action into an existing source surface. +4. Keep tenant and family filters query-safe and workspace-safe. + +## Planned Validation Commands + +Run the minimum proving commands once implementation exists: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Review Checklist For Later Implementation + +- Open `/admin/governance/inbox` as a workspace operator with at least two visible signal families. +- Verify the page stays read-only and does not offer claim, snooze, acknowledge, assign, or triage mutation controls. +- Verify a tenant-scoped launch prefilters the page to the current tenant. +- Verify explicit out-of-scope `tenant_id` query input returns `404`. +- Verify each visible section opens an existing source surface and preserves a back-link or source context. + +## Guardrails To Preserve + +- No new persisted inbox-item table. +- No generic cross-domain task engine. +- No browser-only validation requirement by default. +- No raw-support or debug detail rendered on the inbox page. + +## Close-Out Target For Later Implementation + +Record the final outcome in `Guardrail / Exception / Smoke Coverage` once implementation happens, including: + +- whether a bounded `Support/GovernanceInbox/` seam was actually needed +- whether all source CTAs stayed on existing canonical surfaces +- whether any contained drift resolved as `document-in-feature` +- the final proof outcome from the focused unit and feature validation commands + +## Guardrail / Exception / Smoke Coverage + +- Guardrail result: PASS. The implemented slice stayed on the existing Filament v5 / Livewire v4 admin plane, kept provider registration untouched in `apps/platform/bootstrap/providers.php`, introduced no destructive inbox action, and added no new registered asset bundle. +- Bounded seam result: `document-in-feature`. The final implementation required `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` as a derived page-scoped assembler because the current source pages did not expose a reusable cross-family API. +- Source-surface result: PASS. All dominant section CTAs and preview-entry links stayed on existing findings, operations, alerts, and review surfaces; no inbox-local mutation lane or detail shell was added. +- Focused proof result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` passed with `10 passed (53 assertions)`. +- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed. +- Smoke result: PASS. Manual integrated-browser smoke confirmed `/admin/governance/inbox` loads in workspace context, the operations CTA navigates to the canonical monitoring route with return context, and the explicit back link returns to the inbox. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/research.md b/specs/250-decision-governance-inbox/research.md new file mode 100644 index 00000000..152d75cd --- /dev/null +++ b/specs/250-decision-governance-inbox/research.md @@ -0,0 +1,104 @@ +# Research: Decision-Based Governance Inbox v1 + +**Date**: 2026-04-28 +**Feature**: [spec.md](spec.md) + +## Decision Summary + +The repo already contains the underlying governance attention signals. The missing product slice is not another source page or another workflow state, but one bounded decision-first page that composes the existing source seams into a calm workspace starting point. + +## Key Decisions + +### 1. Use section-based composition, not a generic task engine + +- **Decision**: Build the inbox as one read-only Filament page with bounded family sections and preview entries. +- **Why**: A polymorphic table or persisted inbox-item model would import a second workflow truth before the first read-only operator surface is proven. +- **Repo truth**: Findings, operations, alerts, and review follow-up already have their own truthful pages and models. + +### 2. Reuse findings queue semantics directly + +- **Decision**: The assigned-findings and intake sections should reuse the inclusion and urgency rules already owned by `MyFindingsInbox` and `FindingsIntakeQueue`. +- **Why**: Those pages already codify open-status filtering, tenant entitlement, urgency ordering, and calm empty states. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + - `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` + +### 3. Use stale or terminal-follow-up operations as the operations-family signal + +- **Decision**: The operations section should derive from the same stale or follow-up attention rules already exposed on the canonical `Operations` page. +- **Why**: The repo already has a canonical operations monitoring surface and run-detail route; the inbox should route into it instead of inventing a second operations diagnostic layer. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Support/OperationRunLinks.php` + +### 4. Keep the alert-family slice narrow: failed alert deliveries, not alert-rule config + +- **Decision**: The alerts section should surface delivery failures or similar operator-attention alert outcomes, not alert-rule configuration. +- **Why**: Delivery failure is the actionable alerting gap that belongs in an attention inbox. Alert-rule editing stays a configuration workflow on its existing surfaces. +- **Source seams**: + - `apps/platform/app/Filament/Pages/Monitoring/Alerts.php` + - `apps/platform/app/Filament/Resources/AlertDeliveryResource.php` + - `apps/platform/app/Models/AlertDelivery.php` + +### 5. Use triage-review follow-up as the review-family signal + +- **Decision**: The review section should derive from `TenantTriageReview` states such as `follow_up_needed` and changed-since-review semantics. +- **Why**: The repo already distinguishes review follow-up from the underlying review artifact; the inbox should reuse that distinction rather than invent a second attention reason model. +- **Source seams**: + - `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` + - `apps/platform/app/Models/TenantTriageReview.php` + - `apps/platform/app/Filament/Resources/TenantReviewResource.php` + +### 6. Preserve navigation continuity through shared context helpers + +- **Decision**: Every section and preview entry should use existing navigation helpers for back links and canonical destinations. +- **Why**: The inbox only reduces attention load if it preserves return context instead of opening detached utility flows. +- **Source seams**: + - `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` + - `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php` + - `apps/platform/app/Support/OperateHub/OperateHubShell.php` + +### 7. Keep the inbox read-only in v1 + +- **Decision**: No claim, snooze, acknowledge, assign, or triage mutations are introduced on the inbox page. +- **Why**: Those mutations already belong to source surfaces and would force the inbox to become a second workflow owner. +- **Result**: The inbox remains a decision hub, not an execution surface. + +## Access Model Decision + +- Workspace membership remains the first gate. +- The page only needs to exist for actors who can already see at least one family. +- Rows and counts must stay family-specific: + - findings sections require `Capabilities::TENANT_FINDINGS_VIEW` + - review follow-up requires `Capabilities::TENANT_REVIEW_VIEW` + - alert-family sections require `Capabilities::ALERTS_VIEW` + - source mutations remain on source pages with their existing capabilities +- Explicit out-of-scope `tenant_id` inputs return `404`. + +## Rejected Alternatives + +### Rejected: persisted inbox-item table + +- **Reason**: adds durable workflow truth, migration cost, audit burden, and new lifecycle semantics before the read-only composition page is proven. + +### Rejected: generic cross-domain work-item abstraction + +- **Reason**: over-generalizes five concrete families into a second vocabulary and invites a platform-level task framework that current-release truth does not require. + +### Rejected: extend one existing page instead of adding a canonical inbox + +- **Reason**: no single existing page can truthfully host all five families without becoming the wrong domain owner. + +## Implications For Implementation + +- Prefer one bounded `Support/GovernanceInbox/` seam only if page-local composition becomes unreadable. +- Keep source-family labels close to existing UI copy to avoid a second UX language. +- Keep empty states honest: + - tenant-prefilter-hidden attention -> `Clear tenant filter` + - globally calm -> one neutral workspace return CTA +- Do not add page-level audit noise for mere page views. + +## Planning Outcome + +The smallest viable implementation slice is one new read-only workspace page that reuses existing source-page queries, existing navigation helpers, and existing capability semantics. No new persistence or mutation lane is justified. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/spec.md b/specs/250-decision-governance-inbox/spec.md new file mode 100644 index 00000000..4d46a6fc --- /dev/null +++ b/specs/250-decision-governance-inbox/spec.md @@ -0,0 +1,294 @@ +# Feature Specification: Decision-Based Governance Inbox v1 + +**Feature Branch**: `250-decision-governance-inbox` +**Created**: 2026-04-28 +**Status**: Draft +**Input**: User description: "Select the next best open spec candidate from roadmap and spec-candidates, then prepare a narrow repo-grounded Spec Kit package for a decision-oriented governance inbox that consolidates existing findings, alerts, stale operations, and portfolio triage signals without implementing application code." + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot already has real findings queues, alerting, operations monitoring, and portfolio triage state, but operators still have to reconstruct what needs attention by moving across several surfaces before they can decide what to open next. +- **Today's failure**: The product still behaves like an entity-first console instead of a decision-first work surface. Operators can miss stale operations, alert-delivery failures, review follow-up, or unassigned findings because each signal family lives on its own page. +- **User-visible improvement**: One canonical workspace inbox shows the most important governance attention from more than one existing signal family and routes the operator straight into the right existing execution or evidence surface. +- **Smallest enterprise-capable version**: One new read-first workspace page under `/admin` that aggregates existing assigned-findings, findings-intake, stale-operations, alert-delivery-failure, and review-follow-up signals into bounded sections with calm summaries and direct links into existing source pages. No new mutation lane ships on the inbox itself. +- **Explicit non-goals**: No replacement of `My Findings`, `Findings intake`, `Operations`, `Alerts`, or review-triage detail pages; no new persisted inbox-item table; no generic cross-domain task engine; no new acknowledge, snooze, or assignment state; no customer-facing inbox; no AI recommendations; no cross-workspace workboard. +- **Permanent complexity imported**: One canonical inbox page, one bounded derived section or entry assembly seam, one cross-family priority order, query-state handling for tenant and family filters, and focused unit plus feature coverage. +- **Why now**: The implementation ledger marks the missing decision inbox as a P0 workflow blocker immediately after Customer Review Workspace, while `spec-candidates.md` still lists it as P1. This package follows the stronger ledger urgency because the repo already has the underlying signal families, so the next product value is compression of operator attention, not another isolated source page. +- **Why not local**: Extending only `My Findings`, only `Operations`, or only `Alerts` would keep the current multi-page reconstruction problem intact and would not provide one truthful starting point for workspace attention. +- **Approval class**: Workflow Compression +- **Red flags triggered**: One mild `many surfaces` flag because the page composes several existing signal families. Defense: the slice stays read-only, introduces no new persistence, and explicitly reuses underlying source pages instead of replacing them. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - new canonical workspace route `/admin/governance/inbox` + - existing `/admin/findings/my-work` + - existing `/admin/findings/intake` + - existing `/admin/operations` + - existing alerts cluster routes under `/admin/alerts/*` + - existing `/admin/reviews` and tenant-scoped review detail routes used for triage follow-up drill-through +- **Data Ownership**: + - tenant-owned `Finding`, `OperationRun`, `TenantReview`, and `TenantTriageReview` remain the only source of truth for their respective sections + - workspace-scoped `AlertDelivery`, `AlertRule`, and `AlertDestination` remain the alerting source of truth + - the governance inbox is a derived read surface only; it introduces no new table, cache, mirror entity, or workflow state +- **RBAC**: + - workspace membership remains the first access boundary for the inbox page + - page entry is allowed only when the actor is a workspace member and at least one source family is visible through existing capabilities + - non-members and explicit out-of-scope tenant targeting remain `404` deny-as-not-found boundaries + - in-scope workspace members who lack every qualifying source-family capability receive `403` instead of a silent empty shell + - assigned-findings and intake sections only include tenants where the actor has `Capabilities::TENANT_FINDINGS_VIEW` + - triage follow-up rows only include tenants where the actor has `Capabilities::TENANT_REVIEW_VIEW`; any follow-up mutation remains on the existing review surface and continues to require `Capabilities::TENANT_TRIAGE_REVIEW_MANAGE` + - alert-delivery failure sections only appear for actors who can access workspace alerts through `Capabilities::ALERTS_VIEW` + - operation-attention rows only appear when the actor could already open the underlying operation destination through the existing run and tenant entitlement rules + - the inbox itself is read-first; source-surface mutations such as claim, triage, acknowledge, or follow-up continue to enforce their existing server-side Gates or Policies + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped findings, review, or tenant dashboard surface, the inbox prefilters to that tenant while keeping the family filter on `All attention`. Operators may clear only the tenant prefilter to return to all visible attention across the workspace. +- **Explicit entitlement checks preventing cross-tenant leakage**: Explicit `tenant_id` inputs outside the actor's visible scope resolve as not found. Broad workspace listings silently omit inaccessible tenants, hidden signal families, and blocked drill-through targets from counts, labels, and empty-state hints. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: navigation entry points, dashboard signals/cards, status messaging, action links, monitoring and governance drill-through, and badge semantics +- **Systems touched**: `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, `TenantReviewResource`, `TenantTriageReviewService`, `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and existing alert, findings, and review source pages +- **Existing pattern(s) to extend**: native Filament workspace pages with tenant-prefilter state, existing queue summaries, `OperateHubShell` scope handling, `CanonicalNavigationContext` back-link continuity, and `ActionSurfaceDeclaration` documentation +- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, `CanonicalAdminTenantFilterState`, and the existing source-page query rules from `MyFindingsInbox`, `FindingsIntakeQueue`, `Operations`, `Alerts`, and review/triage services +- **Why the existing shared path is sufficient or insufficient**: Existing source pages already own the underlying truth and mutation semantics, but they are insufficient as a first decision surface because they only answer one family at a time. The inbox should compose those seams, not replace them. +- **Allowed deviation and why**: none planned. If implementation needs a bounded local section assembler, it must remain derived, page-scoped, and must not become a cross-product task framework. +- **Consistency impact**: Priority language, empty-state language, badge semantics, and drill-through labels must stay aligned with the existing source surfaces so the inbox feels like a routing layer over product truth rather than a parallel UX language. +- **Review focus**: Reviewers must block any implementation that duplicates local claim, acknowledge, triage, or stale-run mutation semantics on the inbox page or invents a second cross-domain workflow state. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes, deep-link only +- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` plus the existing tenantless operation viewer path +- **Delegated start/completion UX behaviors**: canonical `Open operation` / run-detail URL resolution and existing operation-context navigation only; no new queued toast, run-enqueued event, or terminal-notification behavior is introduced +- **Local surface-owned behavior that remains**: the inbox only decides whether an operations attention section is shown and which existing run link is primary for that entry +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: `N/A` +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +N/A - no shared provider/platform boundary is widened. The inbox consumes already-normalized governance, alerts, operations, and review seams without introducing new provider-specific contracts. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Governance inbox page | yes | Native Filament page plus shared primitives | governance queues, monitoring drill-through, navigation continuity, badge/status reuse | page, URL-query | no | One new canonical read-only decision surface; source pages remain authoritative | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Governance inbox page | Primary Decision Surface | Operator opens the workspace and decides which existing governance surface needs attention first | visible attention by family, tenant scope, urgency, count, and dominant next action | full source detail, operation detail, alerts context, and review/finding evidence only after opening the source surface | Primary because it becomes the first workspace attention surface across more than one signal family | Follows the operator question `what needs attention now?` before the entity-specific question `what does this record contain?` | Replaces multi-page search across findings, alerts, operations, and review follow-up with one calm starting point | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Governance inbox page | operator-MSP | family summary, top attention entries, urgency cues, tenant scope, and direct next action into the existing source surface | source-specific detail remains on `My Findings`, `Findings intake`, `Operations`, `Alerts`, and review surfaces | raw payloads, alert body details, operation diagnostics, and evidence payloads stay on existing source pages and remain capability-gated there | `Open attention source` per section or entry | raw/support detail is not rendered on the inbox page | the inbox states the decision truth once, then relies on source pages for proof rather than re-explaining the same blocker in parallel blocks | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the existing source surface for the highest-priority attention family or entry | explicit section or preview-entry CTA into the underlying source surface | forbidden | section footers or preview-entry links only | none | `/admin/governance/inbox` | existing source-specific routes, including `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/operations/{run}` for entry-level operations drill-through, alerts cluster routes, and review routes | active workspace, optional tenant prefilter, family filter | Governance inbox | which attention family needs action now and where the operator should go next | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | Workspace operator / MSP operator | Decide which existing governance surface should be opened next | Workspace decision hub | What needs attention right now across my visible governance surfaces, and where should I go to act? | section counts, top items, tenant label when applicable, urgency cues, family label, and source CTA | source-specific reason detail, evidence, alert metadata, and full operation diagnostics remain on source surfaces | urgency, source family, tenant scope, follow-up state, delivery failure state, stale/terminal attention state | none on the inbox page itself | Open my findings, Open intake, Open operation, Open alerts, Open review follow-up | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one bounded derived section or entry assembly seam may be needed to compose multi-family attention into one page +- **New enum/state/reason family?**: no persisted family; any family keys remain local derived page constants only +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: operators cannot answer `what needs attention now?` from one workspace surface, even though the repo already has real findings, alerts, operations, and review-follow-up truth +- **Existing structure is insufficient because**: current pages answer only one family each and force entity-first reconstruction before the operator can act +- **Narrowest correct implementation**: one read-only workspace page that derives its sections from existing source-page query semantics and routes operators into the existing source surfaces +- **Ownership cost**: one new page, one bounded derived assembly seam, tenant and family query-state handling, and focused unit plus feature coverage +- **Alternative intentionally rejected**: a generic cross-product task engine or persisted inbox-item table was rejected because it would import new workflow truth before the read-only decision surface is proven +- **Release truth**: current-release workflow compression, not future-release workboard infrastructure + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: unit coverage proves attention-family assembly, ordering, and source-link decisions cheaply; focused feature coverage proves workspace membership, per-family visibility, tenant-prefilter behavior, and navigation continuity on a native Filament page. Browser coverage is not the narrowest honest proof for this slice. +- **New or expanded test families**: one focused `GovernanceInbox` feature family and one focused `Unit/Support/GovernanceInbox` family +- **Fixture / helper cost impact**: moderate; tests need visible and hidden tenants, findings in assigned and intake states, stale or terminal-follow-up runs, failed alert deliveries, and triage review states, but should reuse existing factories and avoid browser or heavy-governance setup +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient once explicit tests prove tenant-prefilter state, family omission, and source-surface navigation context +- **Reviewer handoff**: reviewers must confirm that hidden tenant signals never leak into counts or labels, the page stays read-only, and every CTA lands on an existing source surface rather than a new local mutation lane +- **Budget / baseline / trend impact**: low feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` + +## Scope Boundaries + +### In Scope + +- one canonical workspace-level governance inbox page in the existing admin plane +- bounded attention sections for assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up signals +- calm counts and top-entry previews per visible family +- existing source-surface links with preserved navigation context +- tenant and family filters with honest empty-state behavior + +### Non-Goals + +- replacing or hiding the existing source pages that already own findings, operations, alerts, or review state +- new acknowledge, snooze, claim, triage, or assign actions on the inbox page +- a new persisted inbox-item or work-state table +- cross-workspace or customer-facing inboxes +- AI prioritization, autonomous routing, or recommendation logic +- raw-support or debug detail on the inbox page itself + +## Assumptions + +- existing source pages already expose enough truth to derive section counts and top previews without introducing a second workflow state +- alert-delivery failures are the narrowest alert-family attention slice for v1; alert-rule configuration remains secondary +- existing `CanonicalNavigationContext` and `OperationRunLinks` seams are sufficient for return-link continuity +- the page can stay useful even when only a subset of families is visible for the current actor + +## Risks + +- a single mixed attention list could tempt implementation toward a new generic task model; this must be resisted in favor of bounded section composition +- some operations or alert items may be workspace-scoped while other families are tenant-scoped, which increases the chance of misleading empty states if filter logic is not explicit +- if the page tries to surface too much source detail, it can become a duplicate of the underlying pages instead of a decision hub + +## Follow-up Candidates + +- bounded acknowledge or snooze semantics once a real cross-family attention state exists in the product +- dashboard or workspace-overview entry signals into the governance inbox after the canonical page is proven +- a broader decision-based operating system slice only after the first read-only inbox is adopted successfully + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See Multi-Family Attention In One Place (Priority: P1) + +As a workspace operator, I want one inbox that shows the most important governance attention across more than one signal family so I can decide where to work next without scanning multiple pages first. + +**Why this priority**: This is the core missing value. Without a multi-family attention surface, the product still forces page-hopping before any decision can be made. + +**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and triage follow-up. Open the inbox and verify that the page shows more than one visible family with calm counts and top entries. + +**Acceptance Scenarios**: + +1. **Given** the actor has visible assigned findings and stale operations, **When** they open the governance inbox, **Then** both families appear with separate counts, urgency cues, and one dominant source CTA each. +2. **Given** the actor can access only findings and not alerts, **When** they open the governance inbox, **Then** alert sections, labels, and counts do not appear at all. +3. **Given** no visible attention exists in any accessible family, **When** they open the governance inbox, **Then** the page shows one calm empty state and does not imply hidden work exists elsewhere. + +--- + +### User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1) + +As a workspace operator, I want the inbox to route me into the correct existing page with preserved context so the inbox stays a decision hub and not a duplicate execution surface. + +**Why this priority**: The page only reduces attention load if the next click is obvious and lands in the existing product truth. + +**Independent Test**: Open the governance inbox, choose one attention entry from findings, operations, and review follow-up, and verify that each CTA lands on the correct existing destination with back-link or context continuity preserved. + +**Acceptance Scenarios**: + +1. **Given** an assigned-findings section is visible, **When** the actor chooses its dominant action, **Then** the destination opens the existing `My Findings` or tenant finding detail surface instead of a new local inbox detail shell. +2. **Given** an operations attention entry is visible, **When** the actor opens it, **Then** the destination uses the canonical operation URL path and preserves a return path back to the inbox. +3. **Given** a review follow-up section is visible, **When** the actor opens it, **Then** the destination lands on the existing review or triage surface rather than a duplicate summary on the inbox page. + +--- + +### User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2) + +As a workspace operator, I want the governance inbox to respect tenant context and family filters without leaking hidden tenants, hidden families, or inaccessible records. + +**Why this priority**: A decision hub is dangerous if it implies missing or hidden work incorrectly or if it leaks cross-tenant state through filter labels or empty-state hints. + +**Independent Test**: Open the inbox with an active tenant context, with an explicit family filter, and with an inaccessible tenant query parameter. Verify the resulting rows, counts, and empty states are truthful and capability-safe. + +**Acceptance Scenarios**: + +1. **Given** an active tenant context exists, **When** the actor opens the governance inbox, **Then** the page prefilters to that tenant and allows the actor to clear only the tenant prefilter back to all visible attention. +2. **Given** a `tenant_id` query parameter references a tenant outside the actor's scope, **When** the governance inbox loads, **Then** the request resolves as not found instead of rendering an empty or hinting state. +3. **Given** the actor applies a family filter for one accessible family, **When** the page renders, **Then** counts, previews, and empty-state copy describe only that visible family and do not mention hidden families. + +### Edge Cases + +- a single tenant may contribute more than one visible family at once; the inbox must keep those families separate instead of inventing a merged workflow state +- alert-delivery failure rows may be workspace-scoped and tenantless; the page must not fabricate tenant labels or tenant-only actions for them +- an operation run may remain in the workspace database after the actor loses tenant entitlement; the inbox must omit it rather than leak stale references +- a tenant prefilter can hide otherwise visible attention in other tenants; the empty state must explain the tenant boundary honestly before claiming the workspace is calm + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds no Microsoft Graph call, no new queue start, no new `OperationRun`, and no new persisted truth. It adds one derived read-only decision surface over existing findings, alerts, operations, and review-triage truth. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The inbox must stay derived. It must not create a new task engine, persisted attention table, or cross-domain workflow state. Any new assembly seam must remain bounded to page composition and reuse existing source-state semantics. + +**Constitution alignment (XCUT-001):** The inbox must extend existing shared navigation, badge, and source-surface patterns rather than inventing a parallel interaction family for claim, acknowledge, stale-run handling, or review follow-up. + +**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The inbox must remain decision-first. Default-visible content is family, urgency, scope, and next action only. Diagnostics, evidence, and raw-support details stay on the source pages. + +**Constitution alignment (TEST-GOV-001):** The implementation must stay in focused `Unit` and `Feature` lanes. No browser or heavy-governance family is justified by default for this slice. + +**Constitution alignment (RBAC-UX):** Workspace membership remains the first boundary. Explicit out-of-scope tenant filters return `404`. Once workspace membership is established, missing per-family capabilities continue to suppress rows or source actions instead of leaking inaccessible truth. + +**Constitution alignment (RBAC-UX - page access):** Non-members and out-of-scope tenant targeting return `404`, while in-scope workspace members who lack every qualifying family capability receive `403` on page access. + +### Functional Requirements + +- **FR-001**: The system MUST provide a canonical governance inbox at `/admin/governance/inbox` inside the existing admin plane. +- **FR-002**: The inbox MUST aggregate visible attention from more than one underlying signal family using existing product truth rather than a new persisted workflow state. +- **FR-003**: The first supported attention families in v1 MUST be assigned findings, findings intake, stale or terminal-follow-up operations, alert-delivery failures, and review follow-up. +- **FR-004**: The inbox MUST remain read-first. It MUST route to existing source surfaces for claim, triage, operation review, alert drill-through, or review follow-up instead of re-implementing those mutations locally. +- **FR-005**: The inbox MUST expose family counts, top attention previews, tenant scope when applicable, and one dominant source CTA per visible section. +- **FR-006**: The inbox MUST support an optional tenant prefilter and optional family filter. When tenant context is active, the tenant prefilter is applied by default. +- **FR-007**: The inbox MUST omit inaccessible tenants, inaccessible families, and inaccessible source actions from counts, labels, empty-state hints, and preview content. +- **FR-008**: If the actor explicitly targets an out-of-scope tenant through query state, the inbox MUST return `404` deny-as-not-found semantics. +- **FR-009**: Operation-related entries MUST reuse canonical run URLs and existing operation lifecycle semantics instead of inventing local stale-run logic. +- **FR-010**: Alert-related entries MUST derive from existing alert delivery or alert overview truth and MUST NOT duplicate alert-rule configuration state as work items. +- **FR-011**: Review-follow-up entries MUST derive from existing tenant review and triage-review truth and MUST NOT create a second follow-up state family. +- **FR-012**: The inbox MUST NOT introduce a new globally searchable resource, a new panel, or a new asset bundle for v1. +- **FR-013**: The inbox MUST enforce `404` for non-members and explicit out-of-scope tenant targeting, and `403` for in-scope workspace members who lack any qualifying visible-family capability. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Governance inbox page | `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` | `Clear tenant filter` only when a tenant prefilter is active | explicit section and preview-entry CTA into existing source surfaces; no local detail shell | none | none | `Clear tenant filter` when the tenant filter alone hides attention; otherwise `Open workspace dashboard` | n/a | n/a | no direct audit; page stays read-only | Action Surface Contract stays satisfied because the page has one dominant navigation goal and no local mutation lane | + +### Key Entities *(include if feature involves data)* + +- **Governance inbox section**: A derived grouping for one source family that carries a title, visible count, dominant next action, and top previews. +- **Governance attention entry**: A derived preview item that points to one existing source surface and carries only the minimal status, scope, and urgency information needed for the next click. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an operator can determine within 15 seconds whether assigned findings, intake findings, stale operations, alert-delivery failures, or review follow-up require attention from one page. +- **SC-002**: 100% of covered automated tests show that hidden tenants and hidden families do not leak into counts, labels, or empty-state hints. +- **SC-003**: 100% of covered automated tests show that each visible family routes to an existing canonical source surface rather than a new local mutation or detail shell. +- **SC-004**: With seeded workspace data from at least two signal families, the inbox can show both on one page without introducing a new persisted workflow state. \ No newline at end of file diff --git a/specs/250-decision-governance-inbox/tasks.md b/specs/250-decision-governance-inbox/tasks.md new file mode 100644 index 00000000..189fd67d --- /dev/null +++ b/specs/250-decision-governance-inbox/tasks.md @@ -0,0 +1,173 @@ +--- + +description: "Task list for Decision-Based Governance Inbox v1" + +--- + +# Tasks: Decision-Based Governance Inbox v1 + +**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/` +**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` + +**Tests**: Required (Pest). Keep proof in focused `Unit` and `Feature` lanes only, using the targeted Sail commands already captured in the feature artifacts. +**Operations**: The inbox introduces no new `OperationRun`, queue, or result ledger. It only deep-links into existing run detail surfaces through the shared operation-link contract. +**RBAC**: Workspace membership remains the first gate. Explicit out-of-scope tenant filters remain `404`. Source-family rows and source-family destinations stay capability-gated through existing registries and policies. +**Organization**: Tasks are grouped by user story so the multi-family read surface, source-surface routing, and filter-safety behavior remain independently testable after the shared foundation is in place. + +## Test Governance Checklist + +- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay under `apps/platform/tests/Unit/Support/GovernanceInbox/` and `apps/platform/tests/Feature/Governance/` only; no browser or heavy-governance lane is added. +- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; do not add a generic workflow fixture or seeded inbox-item state. +- [x] Planned validation commands cover section assembly, page access, and navigation continuity without pulling in unrelated lane cost. +- [x] The declared surface test profile remains `global-context-shell` because tenant-prefilter and navigation continuity are part of the page contract. +- [x] Any bounded assembly-seam drift resolves as `document-in-feature` unless implementation proves a structural workflow-engine need. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded slice, source seams, and reviewer stop conditions before runtime implementation begins. + +- [x] T001 Review the bounded slice, explicit non-goals, and guardrail expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` +- [x] T002 [P] Review the implementation-shaping decisions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/contracts/governance-inbox.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` +- [x] T003 [P] Confirm the source-page seams that must remain authoritative: `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the read-only page shell, authorization boundaries, and bounded assembly seam that every user story depends on. + +**Critical**: No user story work should begin until this phase is complete. + +- [x] T004 [P] Add focused authorization coverage for workspace membership, explicit out-of-scope tenant-prefilter `404` behavior, in-scope member `403` behavior when no qualifying family capability exists, and family-level omission rules in `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` +- [x] T005 Create the native governance inbox page shell and Blade view in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, keeping the surface read-only and inside the admin plane +- [x] T006 Resolve the section-assembly seam by reusing existing source-page query rules first; only if the page becomes unreadable, add a bounded helper under `apps/platform/app/Support/GovernanceInbox/` and record the choice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` +- [x] T007 [P] Thread tenant and family filter state plus navigation context through `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, reusing `CanonicalNavigationContext` and `CanonicalAdminTenantFilterState` rather than introducing a page-local state system + +**Checkpoint**: The inbox page shell, page access rules, and bounded assembly decision exist. User-story work can now proceed independently. + +--- + +## Phase 3: User Story 1 - See Multi-Family Attention In One Place (Priority: P1) MVP + +**Goal**: Let a workspace operator see more than one visible signal family from one decision-first page without introducing a second workflow state. + +**Independent Test**: Seed visible assigned findings, intake findings, stale operations, alert-delivery failures, and review follow-up, then verify the inbox shows calm section summaries and top previews from more than one family. + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Add unit coverage for derived section and preview-entry assembly in `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` +- [x] T009 [P] [US1] Add feature coverage for multi-family page rendering, calm counts, and honest global empty-state behavior in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` + +### Implementation for User Story 1 + +- [x] T010 [US1] Derive the assigned-findings and intake sections from the existing query semantics in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` without introducing new workflow-state constants +- [x] T011 [US1] Derive the operations and alerts sections from `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, and `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, keeping the alert-family slice focused on delivery-failure attention rather than alert-rule configuration +- [x] T012 [US1] Derive the review follow-up section from `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Models/TenantTriageReview.php`, and the existing review register truth, then render all visible sections on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` + +**Checkpoint**: User Story 1 is independently functional when more than one visible family can appear on the inbox page without new persisted workflow state. + +--- + +## Phase 4: User Story 2 - Open The Right Existing Source Surface With Context (Priority: P1) + +**Goal**: Route every visible section and preview entry into the correct existing source surface so the inbox stays a decision hub rather than becoming a duplicate execution shell. + +**Independent Test**: Open the inbox, use findings, operations, alerts, and review-follow-up CTAs, and verify each destination is an existing canonical source route with preserved return or source context. + +### Tests for User Story 2 + +- [x] T013 [P] [US2] Add focused navigation-context coverage for source-surface CTAs and back-link continuity in `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` + +### Implementation for User Story 2 + +- [x] T014 [US2] Route findings and review-follow-up sections through existing source pages using `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and the existing resource URL helpers on `apps/platform/app/Filament/Resources/TenantReviewResource.php` +- [x] T015 [US2] Route operation attention entries through `apps/platform/app/Support/OperationRunLinks.php` and the canonical tenantless operation detail route `/admin/operations/{run}` instead of inventing a new inbox-local detail shell +- [x] T016 [US2] Keep the inbox read-only by ensuring claim, triage, acknowledge, snooze, and follow-up mutations remain on their source surfaces; if any source surface needs small back-link hardening, change the smallest source page rather than adding local mutations on `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` + +**Checkpoint**: User Story 2 is independently functional when every visible CTA lands on an existing source surface with preserved context and the inbox still owns no mutations. + +--- + +## Phase 5: User Story 3 - Filter The Inbox Honestly Without Leakage (Priority: P2) + +**Goal**: Keep tenant and family filtering honest so the inbox never leaks hidden tenants, hidden families, or inaccessible source destinations. + +**Independent Test**: Load the inbox with an active tenant context, a family filter, and an explicit hidden tenant query parameter, then verify the resulting counts, labels, and empty states are truthful. + +### Tests for User Story 3 + +- [x] T017 [P] [US3] Extend feature coverage for tenant-prefilter state, family filters, hidden-family omission, and tenant-specific empty-state branches in `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` + +### Implementation for User Story 3 + +- [x] T018 [US3] Add family and tenant filter handling to `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, keeping active tenant context durable and clearable without inventing a second filter persistence system +- [x] T019 [US3] Ensure hidden tenants and hidden families never contribute to section counts, preview labels, or empty-state hints, and keep tenantless alert or operations entries truthful when rendered on `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` + +**Checkpoint**: User Story 3 is independently functional when tenant and family filters remain capability-safe and globally honest. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish narrow validation, formatting, and reviewer close-out without widening scope. + +- [x] T020 [P] Run the focused unit validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` +- [x] T021 [P] Run the focused page and authorization validation commands from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` and `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` +- [x] T022 [P] Run the focused navigation-context validation command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` against `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php` +- [x] T023 Run dirty-only Pint for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md` +- [x] T024 Record the final `Guardrail / Exception / Smoke Coverage` close-out, including whether a bounded `Support/GovernanceInbox/` seam was needed and whether any contained drift resolved as `document-in-feature`, in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/250-decision-governance-inbox/checklists/requirements.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user-story work. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the first independently valuable slice. +- **Phase 4 (US2)**: depends on Phase 2 and is safest after US1 because it reuses the same page and view files. +- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because filter and empty-state behavior depend on the final visible sections. +- **Phase 6 (Polish)**: depends on the stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently shippable as the minimal read-only decision surface once Phase 2 is complete. +- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same page composition and routing files are shared hotspots. +- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because family and tenant filters depend on the visible section set. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap. +- Finish shared query or routing reuse before widening the page view. +- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- First shippable slice = **Phase 2 + User Story 1 + User Story 2**. That delivers the canonical decision-first inbox page with the required multi-family attention surface and the required routing into existing source surfaces. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and validate the multi-family read surface. +3. Deliver US2 and validate that all CTAs land on existing source surfaces. +4. Deliver US3 and validate filter honesty plus `404` handling. +5. Finish with Phase 6 validation, formatting, and close-out recording. + +### Team Strategy + +1. Finish Phase 2 together before splitting story work. +2. Parallelize unit and feature test authoring inside each story first. +3. Serialize merges touching `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`, because they are the main conflict hotspots for this slice. + +## Notes + +- [P] tasks should stay on different files or clearly isolated seams. +- Each story remains independently testable, but the first shippable slice includes both US1 and US2 because routing into existing source surfaces is part of the required product contract. +- Re-run the narrowest relevant Pest command after each story checkpoint before moving forward. +- Stop at each checkpoint if the page starts drifting toward a generic workflow engine or local mutation lane. \ No newline at end of file