feat: add findings operator inbox #258

Merged
ahmido merged 2 commits from 221-findings-operator-inbox into dev 2026-04-21 09:19:55 +00:00
21 changed files with 3078 additions and 38 deletions
Showing only changes of commit 8cc73dff71 - Show all commits

View File

@ -224,6 +224,8 @@ ## Active Technologies
- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders (220-governance-run-summaries)
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` (221-findings-operator-inbox)
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -258,9 +260,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

@ -0,0 +1,687 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Findings;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class MyFindingsInbox extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $title = 'My Findings';
protected static ?string $slug = 'findings/my-work';
protected string $view = 'filament.pages.findings.my-findings-inbox';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleTenants = null;
private ?Workspace $workspace = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.');
}
public function mount(): void
{
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['overdue', 'reopened', 'high_severity'],
request(),
);
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->normalizeTenantFilterState();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->queueBaseQuery())
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->columns([
TextColumn::make('tenant.name')
->label('Tenant'),
TextColumn::make('subject_display_name')
->label('Finding')
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
->description(fn (Finding $record): ?string => $this->ownerContext($record))
->wrap(),
TextColumn::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
->description(fn (Finding $record): ?string => $this->reopenedCue($record)),
TextColumn::make('due_at')
->label('Due')
->dateTime()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
Filter::make('overdue')
->label('Overdue')
->query(fn (Builder $query): Builder => $query
->whereNotNull('due_at')
->where('due_at', '<', now())),
Filter::make('reopened')
->label('Reopened')
->query(fn (Builder $query): Builder => $query->whereNotNull('reopened_at')),
Filter::make('high_severity')
->label('High severity')
->query(fn (Builder $query): Builder => $query->whereIn('severity', Finding::highSeverityValues())),
])
->actions([])
->bulkActions([])
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
->emptyStateActions($this->emptyStateActions());
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$tenant = $this->filteredTenant();
return [
'workspace_scoped' => true,
'assignee_scope' => 'current_user_only',
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
'tenant_label' => $tenant?->name,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function availableFilters(): array
{
return [
[
'key' => 'assignee_scope',
'label' => 'Assigned to me',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => collect($this->visibleTenants())
->map(fn (Tenant $tenant): array => [
'value' => (string) $tenant->getKey(),
'label' => (string) $tenant->name,
])
->values()
->all(),
],
[
'key' => 'overdue',
'label' => 'Overdue',
'fixed' => false,
'options' => [],
],
[
'key' => 'reopened',
'label' => 'Reopened',
'fixed' => false,
'options' => [],
],
[
'key' => 'high_severity',
'label' => 'High severity',
'fixed' => false,
'options' => [],
],
];
}
/**
* @return array{open_assigned: int, overdue_assigned: int}
*/
public function summaryCounts(): array
{
$query = $this->filteredQueueQuery();
return [
'open_assigned' => (clone $query)->count(),
'overdue_assigned' => (clone $query)
->whereNotNull('due_at')
->where('due_at', '<', now())
->count(),
];
}
/**
* @return array<string, mixed>
*/
public function emptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'No assigned findings match this tenant scope',
'body' => 'Your current tenant filter is hiding assigned work that is still visible elsewhere in this workspace.',
'icon' => 'heroicon-o-funnel',
'action_name' => 'clear_tenant_filter_empty',
'action_label' => 'Clear tenant filter',
'action_kind' => 'clear_tenant_filter',
];
}
$activeTenant = $this->activeVisibleTenant();
if ($activeTenant instanceof Tenant) {
return [
'title' => 'No visible assigned findings right now',
'body' => 'Nothing currently assigned to you needs attention in the visible tenant scope. You can still open tenant findings for broader context.',
'icon' => 'heroicon-o-clipboard-document-check',
'action_name' => 'open_tenant_findings_empty',
'action_label' => 'Open tenant findings',
'action_kind' => 'url',
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant),
];
}
return [
'title' => 'No visible assigned findings right now',
'body' => 'Nothing currently assigned to you needs attention across the visible tenant scope. Choose a tenant to continue working elsewhere in the workspace.',
'icon' => 'heroicon-o-clipboard-document-check',
'action_name' => 'choose_tenant_empty',
'action_label' => 'Choose a tenant',
'action_kind' => 'url',
'action_url' => route('filament.admin.pages.choose-tenant'),
];
}
public function updatedTableFilters(): void
{
$this->normalizeTenantFilterState();
}
public function clearTenantFilter(): void
{
$this->removeTableFilter('tenant_id');
$this->resetTable();
}
/**
* @return array<int, Tenant>
*/
public function visibleTenants(): array
{
if ($this->visibleTenants !== null) {
return $this->visibleTenants;
}
$user = auth()->user();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || $tenants === []) {
return $this->visibleTenants = [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return $this->visibleTenants = array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
private function authorizePageAccess(): 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;
}
}
/**
* @return array<int, Tenant>
*/
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 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();
}
private function queueBaseQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
$tenantIds = array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->visibleTenants(),
);
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return Finding::query()->whereRaw('1 = 0');
}
return Finding::query()
->with(['tenant', 'ownerUser', 'assigneeUser'])
->withSubjectDisplayName()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery())
->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');
}
private function filteredQueueQuery(bool $includeTenantFilter = true): Builder
{
$query = $this->queueBaseQuery();
$filters = $this->currentQueueFiltersState();
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterIdFromFilters($filters)) !== null) {
$query->where('tenant_id', $tenantId);
}
if ($this->filterIsActive($filters, 'overdue')) {
$query
->whereNotNull('due_at')
->where('due_at', '<', now());
}
if ($this->filterIsActive($filters, 'reopened')) {
$query->whereNotNull('reopened_at');
}
if ($this->filterIsActive($filters, 'high_severity')) {
$query->whereIn('severity', Finding::highSeverityValues());
}
return $query;
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->visibleTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => (string) $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->visibleTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function normalizeTenantFilterState(): void
{
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
return;
}
if ($this->currentTenantFilterId() !== null) {
return;
}
$this->removeTableFilter('tenant_id');
}
/**
* @return array<string, mixed>
*/
private function currentQueueFiltersState(): array
{
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
return array_replace_recursive(
is_array($persisted) ? $persisted : [],
$this->tableFilters ?? [],
);
}
private function currentTenantFilterId(): ?int
{
return $this->currentTenantFilterIdFromFilters($this->currentQueueFiltersState());
}
/**
* @param array<string, mixed> $filters
*/
private function currentTenantFilterIdFromFilters(array $filters): ?int
{
$tenantFilter = data_get($filters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
return null;
}
$tenantId = (int) $tenantFilter;
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenantId;
}
}
return null;
}
/**
* @param array<string, mixed> $filters
*/
private function filterIsActive(array $filters, string $name): bool
{
return (bool) data_get($filters, "{$name}.isActive", false);
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function activeVisibleTenant(): ?Tenant
{
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $activeTenant instanceof Tenant) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ($tenant->is($activeTenant)) {
return $tenant;
}
}
return null;
}
private function tenantPrefilterSource(): string
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return 'none';
}
$activeTenant = $this->activeVisibleTenant();
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
return 'active_tenant_context';
}
return 'explicit_filter';
}
private function ownerContext(Finding $record): ?string
{
$ownerLabel = FindingResource::accountableOwnerDisplayFor($record);
$assigneeLabel = $record->assigneeUser?->name;
if ($record->owner_user_id === null || $ownerLabel === $assigneeLabel) {
return null;
}
return 'Owner: '.$ownerLabel;
}
private function reopenedCue(Finding $record): ?string
{
if ($record->reopened_at === null) {
return null;
}
return 'Reopened';
}
private function tenantFilterAloneExcludesRows(): bool
{
if ($this->currentTenantFilterId() === null) {
return false;
}
if ((clone $this->filteredQueueQuery())->exists()) {
return false;
}
return (clone $this->filteredQueueQuery(includeTenantFilter: false))->exists();
}
private function findingDetailUrl(Finding $record): string
{
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}
private function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'findings.my_inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->currentTenantFilterId(),
backLinkLabel: 'Back to my findings',
backLinkUrl: $this->queueUrl(),
);
}
private function queueUrl(): string
{
$tenant = $this->filteredTenant();
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $tenant?->external_id,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
/**
* @return array<int, Action>
*/
private function emptyStateActions(): array
{
$emptyState = $this->emptyState();
$action = Action::make((string) $emptyState['action_name'])
->label((string) $emptyState['action_label'])
->icon('heroicon-o-arrow-right')
->color('gray');
if (($emptyState['action_kind'] ?? null) === 'clear_tenant_filter') {
return [
$action->action(fn (): mixed => $this->clearTenantFilter()),
];
}
return [
$action->url((string) $emptyState['action_url']),
];
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}

View File

@ -165,9 +165,9 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('finding_due_attention')
->label('Due state')
->badge()
->state(fn (Finding $record): ?string => static::dueAttentionLabel($record))
->color(fn (Finding $record): string => static::dueAttentionColor($record))
->visible(fn (Finding $record): bool => static::dueAttentionLabel($record) !== null),
->state(fn (Finding $record): ?string => static::dueAttentionLabelFor($record))
->color(fn (Finding $record): string => static::dueAttentionColorFor($record))
->visible(fn (Finding $record): bool => static::dueAttentionLabelFor($record) !== null),
TextEntry::make('finding_governance_validity_leading')
->label('Governance')
->badge()
@ -215,10 +215,10 @@ public static function infolist(Schema $schema): Schema
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
TextEntry::make('owner_user_id_leading')
->label('Accountable owner')
->state(fn (Finding $record): string => static::accountableOwnerDisplay($record)),
->state(fn (Finding $record): string => static::accountableOwnerDisplayFor($record)),
TextEntry::make('assignee_user_id_leading')
->label('Active assignee')
->state(fn (Finding $record): string => static::activeAssigneeDisplay($record)),
->state(fn (Finding $record): string => static::activeAssigneeDisplayFor($record)),
TextEntry::make('finding_responsibility_summary')
->label('Current split')
->state(fn (Finding $record): string => static::responsibilitySummary($record))
@ -764,7 +764,7 @@ public static function table(Table $table): Table
->dateTime()
->sortable()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)),
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record)),
Tables\Columns\TextColumn::make('ownerUser.name')
->label('Accountable owner')
->placeholder('—'),
@ -2065,12 +2065,12 @@ private static function responsibilityStateColor(Finding $finding): string
};
}
private static function accountableOwnerDisplay(Finding $finding): string
public static function accountableOwnerDisplayFor(Finding $finding): string
{
return $finding->ownerUser?->name ?? 'Unassigned';
}
private static function activeAssigneeDisplay(Finding $finding): string
public static function activeAssigneeDisplayFor(Finding $finding): string
{
return $finding->assigneeUser?->name ?? 'Unassigned';
}
@ -2152,7 +2152,7 @@ private static function primaryNextAction(Finding $finding): ?string
->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding));
}
private static function dueAttentionLabel(Finding $finding): ?string
public static function dueAttentionLabelFor(Finding $finding): ?string
{
if (! $finding->hasOpenStatus() || ! $finding->due_at) {
return null;
@ -2169,9 +2169,9 @@ private static function dueAttentionLabel(Finding $finding): ?string
return null;
}
private static function dueAttentionColor(Finding $finding): string
public static function dueAttentionColorFor(Finding $finding): string
{
return match (static::dueAttentionLabel($finding)) {
return match (static::dueAttentionLabelFor($finding)) {
'Overdue' => 'danger',
'Due soon' => 'warning',
default => 'gray',

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\NoAccess;
@ -176,6 +177,7 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class,
TenantRequiredPermissions::class,
WorkspaceSettings::class,
MyFindingsInbox::class,
FindingExceptionsQueue::class,
ReviewRegister::class,
])

View File

@ -76,6 +76,12 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if ($path === '/admin/findings/my-work') {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if ($path === '/admin/operations/'.$request->route('run')) {
$this->configureNavigationForRequest($panel);
@ -113,7 +119,7 @@ public function handle(Request $request, Closure $next): Response
str_starts_with($path, '/admin/w/')
|| str_starts_with($path, '/admin/workspaces')
|| str_starts_with($path, '/admin/operations')
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace'], true)
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work'], true)
) {
$this->configureNavigationForRequest($panel);
@ -251,6 +257,10 @@ private function adminPathRequiresTenantSelection(string $path): bool
return false;
}
if (str_starts_with($path, '/admin/findings/my-work')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
}
}

View File

@ -6,10 +6,12 @@
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
@ -40,6 +42,7 @@
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class WorkspaceOverviewBuilder
{
@ -130,6 +133,8 @@ public function build(Workspace $workspace, User $user): array
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
];
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
$zeroTenantState = null;
if ($accessibleTenants->isEmpty()) {
@ -168,6 +173,7 @@ public function build(Workspace $workspace, User $user): array
'workspace_id' => $workspaceId,
'workspace_name' => (string) $workspace->name,
'accessible_tenant_count' => $accessibleTenants->count(),
'my_findings_signal' => $myFindingsSignal,
'summary_metrics' => $summaryMetrics,
'triage_review_progress' => $triageReviewProgress['families'],
'attention_items' => $attentionItems,
@ -198,6 +204,68 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection
->get(['id', 'name', 'external_id', 'workspace_id']);
}
/**
* @param Collection<int, Tenant> $accessibleTenants
* @return array<string, mixed>
*/
private function myFindingsSignal(int $workspaceId, Collection $accessibleTenants, User $user): array
{
$visibleTenantIds = $accessibleTenants
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
$openAssignedCount = $visibleTenantIds === []
? 0
: (int) $this->scopeToVisibleTenants(
Finding::query(),
$workspaceId,
$visibleTenantIds,
)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery())
->count();
$overdueAssignedCount = $visibleTenantIds === []
? 0
: (int) $this->scopeToVisibleTenants(
Finding::query(),
$workspaceId,
$visibleTenantIds,
)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery())
->whereNotNull('due_at')
->where('due_at', '<', now())
->count();
$isCalm = $openAssignedCount === 0;
return [
'headline' => $isCalm
? 'Assigned work is calm'
: sprintf(
'%d assigned %s visible in this workspace',
$openAssignedCount,
Str::plural('finding', $openAssignedCount),
),
'description' => $isCalm
? 'No visible assigned findings currently need attention across your entitled tenants.'
: sprintf(
'Visible assigned work stays in one queue. %d overdue %s currently need follow-up.',
$overdueAssignedCount,
Str::plural('finding', $overdueAssignedCount),
),
'open_assigned_count' => $openAssignedCount,
'overdue_assigned_count' => $overdueAssignedCount,
'is_calm' => $isCalm,
'cta_label' => 'Open my findings',
'cta_url' => MyFindingsInbox::getUrl(panel: 'admin'),
];
}
/**
* @param Collection<int, Tenant> $accessibleTenants
* @return list<array<string, mixed>>

View File

@ -0,0 +1,92 @@
<x-filament-panels::page>
@php($scope = $this->appliedScope())
@php($summary = $this->summaryCounts())
@php($availableFilters = $this->availableFilters())
<div class="space-y-6">
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="space-y-2">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
<x-filament::icon icon="heroicon-o-clipboard-document-check" class="h-3.5 w-3.5" />
Assigned to me
</div>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
My Findings
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
Review open assigned findings across visible tenants in one queue. Tenant context can narrow the view, but the personal assignment scope stays fixed.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
Open assigned
</div>
<div class="mt-2 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $summary['open_assigned'] }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Visible rows after the current filters.
</div>
</div>
<div class="rounded-2xl border border-danger-200 bg-danger-50/70 p-4 shadow-sm dark:border-danger-700/50 dark:bg-danger-950/30">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-danger-700 dark:text-danger-200">
Overdue
</div>
<div class="mt-2 text-3xl font-semibold text-danger-950 dark:text-danger-100">
{{ $summary['overdue_assigned'] }}
</div>
<div class="mt-1 text-sm text-danger-800 dark:text-danger-200">
Assigned findings that are already past due.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
Applied scope
</div>
<div class="mt-2 text-sm font-semibold text-gray-950 dark:text-white">
Assigned to me only
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
@if (($scope['tenant_prefilter_source'] ?? 'none') === 'active_tenant_context')
Tenant prefilter from active context:
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
@elseif (($scope['tenant_prefilter_source'] ?? 'none') === 'explicit_filter')
Tenant filter applied:
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
@else
All visible tenants are currently included.
@endif
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
Available filters
</div>
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($availableFilters as $filter)
<span class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium {{ ($filter['fixed'] ?? false) ? 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' }}">
{{ $filter['label'] }}
@if (($filter['fixed'] ?? false) === true)
<span class="ml-2 text-[11px] uppercase tracking-[0.12em] opacity-70">Fixed</span>
@endif
</span>
@endforeach
</div>
</div>
</div>
</div>
</x-filament::section>
{{ $this->table }}
</div>
</x-filament-panels::page>

View File

@ -2,6 +2,7 @@
@php
$workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
$quickActions = $overview['quick_actions'] ?? [];
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
@endphp
@ -57,6 +58,49 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
</div>
@endif
@if (is_array($myFindingsSignal))
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-3">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
<x-filament::icon icon="heroicon-o-clipboard-document-check" class="h-3.5 w-3.5" />
Assigned to me
</div>
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
{{ $myFindingsSignal['headline'] }}
</h2>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $myFindingsSignal['description'] }}
</p>
</div>
<div class="flex flex-wrap gap-2 text-xs">
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-3 py-1 font-medium text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Open assigned: {{ $myFindingsSignal['open_assigned_count'] }}
</span>
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ ($myFindingsSignal['overdue_assigned_count'] ?? 0) > 0 ? 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200' : 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/50 dark:bg-success-950/30 dark:text-success-200' }}">
Overdue: {{ $myFindingsSignal['overdue_assigned_count'] }}
</span>
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ ($myFindingsSignal['is_calm'] ?? false) ? 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/50 dark:bg-success-950/30 dark:text-success-200' : 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200' }}">
{{ ($myFindingsSignal['is_calm'] ?? false) ? 'Calm' : 'Needs follow-up' }}
</span>
</div>
</div>
<x-filament::button
tag="a"
color="primary"
:href="$myFindingsSignal['cta_url']"
icon="heroicon-o-arrow-right"
>
{{ $myFindingsSignal['cta_label'] }}
</x-filament::button>
</div>
</section>
@endif
@if (is_array($zeroTenantState))
<section class="rounded-2xl border border-warning-200 bg-warning-50 p-5 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">

View File

@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
use function Pest\Laravel\mock;
it('redirects inbox visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->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(MyFindingsInbox::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the 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(MyFindingsInbox::getUrl(panel: 'admin'))
->assertNotFound();
});
it('keeps the inbox accessible while suppressing blocked-tenant rows and summary counts', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
]);
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')
->andReturnUsing(static function (User $user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return $capability === Capabilities::TENANT_FINDINGS_VIEW ? false : false;
});
});
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$component = Livewire::actingAs($user)->test(MyFindingsInbox::class)
->assertCanNotSeeTableRecords([$finding])
->assertSee('No visible assigned findings right now');
expect($component->instance()->summaryCounts())->toBe([
'open_assigned' => 0,
'overdue_assigned' => 0,
]);
});
it('suppresses hidden-tenant findings and keeps their detail route not found', 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,
]);
$visibleFinding = Finding::factory()->for($visibleTenant)->create([
'workspace_id' => (int) $visibleTenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_TRIAGED,
]);
$hiddenFinding = Finding::factory()->for($hiddenTenant)->create([
'workspace_id' => (int) $hiddenTenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
Livewire::actingAs($user)
->test(MyFindingsInbox::class)
->assertCanSeeTableRecords([$visibleFinding])
->assertCanNotSeeTableRecords([$hiddenFinding]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(FindingResource::getUrl('view', ['record' => $hiddenFinding], panel: 'tenant', tenant: $hiddenTenant))
->assertNotFound();
});
it('preserves forbidden detail destinations for workspace members who still lack findings capability', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
]);
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')
->andReturnUsing(static function (User $user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return $capability === Capabilities::TENANT_FINDINGS_VIEW ? false : false;
});
});
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(MyFindingsInbox::class)
->assertCanNotSeeTableRecords([$finding]);
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, static function (User $user, ?Tenant $resolvedTenant = null): bool {
return false;
});
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->assertForbidden();
});

View File

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
use function Pest\Laravel\mock;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('builds an assigned-to-me signal from visible assigned findings only', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 21, 9, 0, 0, 'UTC'));
$tenantA = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'readonly', workspaceRole: 'readonly');
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Bravo Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Hidden Tenant',
]);
Finding::factory()->for($tenantA)->create([
'workspace_id' => (int) $tenantA->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
'due_at' => now()->subHour(),
]);
Finding::factory()->for($tenantB)->create([
'workspace_id' => (int) $tenantB->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->addDay(),
]);
Finding::factory()->for($tenantB)->create([
'workspace_id' => (int) $tenantB->workspace_id,
'assignee_user_id' => null,
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
]);
Finding::factory()->for($hiddenTenant)->create([
'workspace_id' => (int) $hiddenTenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
]);
$signal = app(WorkspaceOverviewBuilder::class)
->build($tenantA->workspace()->firstOrFail(), $user)['my_findings_signal'];
expect($signal['open_assigned_count'])->toBe(2)
->and($signal['overdue_assigned_count'])->toBe(1)
->and($signal['is_calm'])->toBeFalse()
->and($signal['cta_label'])->toBe('Open my findings')
->and($signal['cta_url'])->toBe(MyFindingsInbox::getUrl(panel: 'admin'));
});
it('keeps the signal calm when no visible assigned findings remain', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now(),
]);
$signal = app(WorkspaceOverviewBuilder::class)
->build($tenant->workspace()->firstOrFail(), $user)['my_findings_signal'];
expect($signal['open_assigned_count'])->toBe(0)
->and($signal['overdue_assigned_count'])->toBe(0)
->and($signal['is_calm'])->toBeTrue()
->and($signal['description'])->toContain('visible assigned');
});
it('suppresses blocked-tenant findings from the assigned-to-me signal', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$blockedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Blocked Tenant',
]);
createUserWithTenant($blockedTenant, $user, role: 'readonly', workspaceRole: 'readonly');
Finding::factory()->for($visibleTenant)->create([
'workspace_id' => (int) $visibleTenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
]);
Finding::factory()->for($blockedTenant)->create([
'workspace_id' => (int) $blockedTenant->workspace_id,
'assignee_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_NEW,
]);
mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $blockedTenant): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')
->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $blockedTenant): bool {
expect([(int) $visibleTenant->getKey(), (int) $blockedTenant->getKey()])
->toContain((int) $tenant->getKey());
return match ($capability) {
Capabilities::TENANT_FINDINGS_VIEW => (int) $tenant->getKey() === (int) $visibleTenant->getKey(),
default => false,
};
});
});
$signal = app(WorkspaceOverviewBuilder::class)
->build($visibleTenant->workspace()->firstOrFail(), $user)['my_findings_signal'];
expect($signal['open_assigned_count'])->toBe(1)
->and($signal['overdue_assigned_count'])->toBe(0)
->and($signal['description'])->not->toContain($blockedTenant->name);
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -13,8 +14,11 @@
->get('/admin')
->assertOk()
->assertSee('Overview')
->assertSee('Switch workspace');
->assertSee('Switch workspace')
->assertSee('Assigned to me')
->assertSee('Open my findings');
expect(Filament::getPanel('admin')->getHomeUrl())->toBe(route('admin.home'));
expect((string) $response->getContent())->toContain('href="'.route('admin.home').'"');
expect((string) $response->getContent())->toContain('href="'.MyFindingsInbox::getUrl(panel: 'admin').'"');
});

View File

@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function myWorkInboxActingUser(string $role = 'owner', string $workspaceRole = 'readonly'): array
{
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
test()->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
return [$user, $tenant];
}
function myWorkInboxPage(?User $user = null, array $query = [])
{
if ($user instanceof User) {
test()->actingAs($user);
}
setAdminPanelContext();
$factory = $query === [] ? Livewire::actingAs(auth()->user()) : Livewire::withQueryParams($query)->actingAs(auth()->user());
return $factory->test(MyFindingsInbox::class);
}
function makeAssignedFindingForInbox(Tenant $tenant, User $assignee, array $attributes = []): Finding
{
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'owner_user_id' => (int) $assignee->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => fake()->uuid(),
], $attributes));
}
it('shows only visible assigned open findings and exposes the fixed filter contract', function (): void {
[$user, $tenantA] = myWorkInboxActingUser();
$tenantA->forceFill(['name' => 'Alpha Tenant'])->save();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Tenant Bravo',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Hidden Tenant',
]);
$otherAssignee = User::factory()->create();
createUserWithTenant($tenantA, $otherAssignee, role: 'operator', workspaceRole: 'readonly');
$otherOwner = User::factory()->create();
createUserWithTenant($tenantB, $otherOwner, role: 'owner', workspaceRole: 'readonly');
$assignedVisible = makeAssignedFindingForInbox($tenantA, $user, [
'subject_external_id' => 'visible-a',
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
$assignedOwnerSplit = makeAssignedFindingForInbox($tenantB, $user, [
'subject_external_id' => 'visible-b',
'owner_user_id' => (int) $otherOwner->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => now()->subDay(),
]);
$ownerOnly = makeAssignedFindingForInbox($tenantA, $otherAssignee, [
'subject_external_id' => 'owner-only',
'owner_user_id' => (int) $user->getKey(),
]);
$assignedTerminal = makeAssignedFindingForInbox($tenantA, $user, [
'subject_external_id' => 'terminal',
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now(),
]);
$assignedToOther = makeAssignedFindingForInbox($tenantA, $otherAssignee, [
'subject_external_id' => 'other-assignee',
]);
$hiddenAssigned = makeAssignedFindingForInbox($hiddenTenant, $user, [
'subject_external_id' => 'hidden',
]);
$component = myWorkInboxPage($user)
->assertCanSeeTableRecords([$assignedVisible, $assignedOwnerSplit])
->assertCanNotSeeTableRecords([$ownerOnly, $assignedTerminal, $assignedToOther, $hiddenAssigned])
->assertSee('Owner: '.$otherOwner->name)
->assertSee('Assigned to me only');
expect($component->instance()->summaryCounts())->toBe([
'open_assigned' => 2,
'overdue_assigned' => 1,
]);
expect($component->instance()->availableFilters())->toBe([
[
'key' => 'assignee_scope',
'label' => 'Assigned to me',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => [
['value' => (string) $tenantA->getKey(), 'label' => 'Alpha Tenant'],
['value' => (string) $tenantB->getKey(), 'label' => $tenantB->name],
],
],
[
'key' => 'overdue',
'label' => 'Overdue',
'fixed' => false,
'options' => [],
],
[
'key' => 'reopened',
'label' => 'Reopened',
'fixed' => false,
'options' => [],
],
[
'key' => 'high_severity',
'label' => 'High severity',
'fixed' => false,
'options' => [],
],
]);
});
it('defaults to the active tenant prefilter and lets the operator clear it without dropping personal scope', function (): void {
[$user, $tenantA] = myWorkInboxActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$findingA = makeAssignedFindingForInbox($tenantA, $user, [
'subject_external_id' => 'tenant-a',
'status' => Finding::STATUS_NEW,
]);
$findingB = makeAssignedFindingForInbox($tenantB, $user, [
'subject_external_id' => 'tenant-b',
'status' => Finding::STATUS_TRIAGED,
]);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
$component = myWorkInboxPage($user)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$findingB])
->assertCanNotSeeTableRecords([$findingA])
->assertActionVisible('clear_tenant_filter');
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'assignee_scope' => 'current_user_only',
'tenant_prefilter_source' => 'active_tenant_context',
'tenant_label' => $tenantB->name,
]);
$component->callAction('clear_tenant_filter')
->assertCanSeeTableRecords([$findingA, $findingB]);
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'assignee_scope' => 'current_user_only',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
});
it('orders overdue work before reopened work and keeps deterministic due-date and id tie breaks', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
$overdue = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'overdue',
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => now()->subDay(),
]);
$reopened = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'reopened',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHours(6),
'due_at' => now()->addDay(),
]);
$ordinarySooner = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary-sooner',
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->addDays(2),
]);
$ordinaryLater = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary-later',
'status' => Finding::STATUS_NEW,
'due_at' => now()->addDays(4),
]);
$ordinaryNoDue = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary-no-due',
'status' => Finding::STATUS_TRIAGED,
'due_at' => null,
]);
myWorkInboxPage($user)
->assertCanSeeTableRecords([$overdue, $reopened, $ordinarySooner, $ordinaryLater, $ordinaryNoDue], inOrder: true)
->assertSee('Reopened');
});
it('applies reopened and high-severity filters without widening beyond assigned work', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
$reopenedHigh = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'reopened-high',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHour(),
'severity' => Finding::SEVERITY_CRITICAL,
]);
$highOnly = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'high-only',
'status' => Finding::STATUS_TRIAGED,
'severity' => Finding::SEVERITY_HIGH,
]);
$ordinary = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary',
'status' => Finding::STATUS_NEW,
'severity' => Finding::SEVERITY_MEDIUM,
]);
myWorkInboxPage($user)
->set('tableFilters.reopened.isActive', true)
->assertCanSeeTableRecords([$reopenedHigh])
->assertCanNotSeeTableRecords([$highOnly, $ordinary])
->set('tableFilters.reopened.isActive', false)
->set('tableFilters.high_severity.isActive', true)
->assertCanSeeTableRecords([$reopenedHigh, $highOnly])
->assertCanNotSeeTableRecords([$ordinary]);
});
it('renders the tenant-prefilter empty-state branch and offers only a clear-filter recovery action', function (): void {
[$user, $tenantA] = myWorkInboxActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Work Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
makeAssignedFindingForInbox($tenantB, $user, [
'subject_external_id' => 'available-elsewhere',
]);
$component = myWorkInboxPage($user, [
'tenant' => (string) $tenantA->external_id,
])
->assertCanNotSeeTableRecords([])
->assertSee('No assigned findings match this tenant scope')
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
expect($component->instance()->summaryCounts())->toBe([
'open_assigned' => 0,
'overdue_assigned' => 0,
]);
});
it('renders the calm zero-work branch and points back to tenant selection when no active tenant context exists', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
Filament::setTenant(null, true);
myWorkInboxPage($user)
->assertSee('No visible assigned findings right now')
->assertTableEmptyStateActionsExistInOrder(['choose_tenant_empty']);
});
it('uses the active visible tenant for the calm empty-state drillback when tenant context exists', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
$component = myWorkInboxPage($user)
->assertSee('No visible assigned findings right now')
->assertTableEmptyStateActionsExistInOrder(['open_tenant_findings_empty']);
expect($component->instance()->emptyState())->toMatchArray([
'action_name' => 'open_tenant_findings_empty',
'action_label' => 'Open tenant findings',
'action_kind' => 'url',
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
]);
});
it('builds tenant detail drilldowns with inbox continuity', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
$finding = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'continuity',
]);
$component = myWorkInboxPage($user);
$detailUrl = $component->instance()->getTable()->getRecordUrl($finding);
expect($detailUrl)->toContain(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->and($detailUrl)->toContain('nav%5Bback_label%5D=Back+to+my+findings');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($detailUrl)
->assertOk()
->assertSee('Back to my findings');
});

View File

@ -42,7 +42,9 @@ ## Promoted to Spec
- Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`)
- Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`)
- Governance Operator Outcome Compression → Spec 214 (`governance-outcome-compression`)
- Finding Ownership Semantics Clarification → Spec 219 (`finding-ownership-semantics`)
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
@ -358,30 +360,6 @@ ### Tenant Operational Readiness & Status Truth Hierarchy
> Findings execution layer cluster: complementary to existing Spec 154 (`finding-risk-acceptance`). Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec.
### Finding Ownership Semantics Clarification
- **Type**: domain semantics / workflow hardening
- **Source**: findings execution layer candidate pack 2026-04-17; current Finding owner/assignee overlap analysis
- **Problem**: Finding already models `owner` and `assignee`, but the semantic split is not crisp enough to support inboxes, escalation, stale-work detection, or consistent audit language. Accountability and active execution responsibility can currently blur together in UI copy, policies, and workflow rules.
- **Why it matters**: Without a shared contract, every downstream workflow slice will invent its own meaning for owner versus assignee. That produces ambiguous queues, brittle escalation rules, and inconsistent governance language.
- **Proposed direction**: Define canonical semantics for accountability owner versus active assignee; align labels, policies, audit/event vocabulary, and permission expectations around that split; encode expected lifecycle states for unassigned, assigned, reassigned, and orphaned work without introducing team-queue abstractions yet.
- **Explicit non-goals**: Team-/queue-based ownership, ticketing, comments, and notification delivery.
- **Dependencies**: Existing Findings model and workflow state machine, Findings UI surfaces, audit vocabulary.
- **Roadmap fit**: Findings Workflow v2 hardening lane.
- **Strategic sequencing**: First. The rest of the findings execution layer consumes this decision.
- **Priority**: high
### Findings Operator Inbox v1
- **Type**: operator surface / workflow execution
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment auditability and day-to-day operator flow
- **Problem**: Findings can be assigned, but TenantPilot lacks a personal work surface that turns assignment into a real operator queue. Assigned work is still discovered indirectly through broader tenant or findings lists.
- **Why it matters**: Without a dedicated personal queue, assignment remains metadata instead of operational flow. A "My Work" surface is the simplest bridge from governance data to daily execution.
- **Proposed direction**: Add a workspace-level or otherwise permission-safe "My Findings / My Work" surface for the current user; emphasize open, due, overdue, high-severity, and reopened findings; provide fast drilldown into the finding record; add a small "assigned to me" dashboard signal; reuse existing RBAC and finding visibility rules instead of inventing a second permission system.
- **Explicit non-goals**: Team queues, complex routing rules, external ticketing, and multi-step approval chains.
- **Dependencies**: Ownership semantics, `assignee_user_id`, `due_at`, finding status logic, RBAC on finding read/open.
- **Roadmap fit**: Findings Workflow v2; feeds later governance inbox work.
- **Strategic sequencing**: Second, ideally immediately after ownership semantics.
- **Priority**: high
### Findings Intake & Team Queue v1
- **Type**: workflow execution / team operations
- **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Findings Operator Inbox V1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-20
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated after initial spec drafting on 2026-04-20.
- No clarification markers remain.
- Candidate ledger was cleaned for the two findings candidates that are now specced as Spec 219 and Spec 221.

View File

@ -0,0 +1,399 @@
openapi: 3.1.0
info:
title: Findings Operator Inbox Surface Contract
version: 1.0.0
description: >-
Internal reference contract for the canonical My Findings inbox and the
workspace overview Assigned to me signal. The application continues to
return rendered HTML through Filament and Livewire. The vendor media types
below document the structured page models that must be derivable before
rendering. This is not a public API commitment.
paths:
/admin/findings/my-work:
get:
summary: Canonical personal findings inbox
description: >-
Returns the rendered admin-plane inbox for the current user's visible
assigned open findings. Personal assignment scope is fixed. Tenant
prefiltering may be derived from the active admin tenant context.
responses:
'302':
description: Redirects into the existing workspace or tenant chooser flow when membership exists but workspace context is not yet established
'200':
description: Rendered My Findings inbox page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.my-findings-inbox+json:
schema:
$ref: '#/components/schemas/MyFindingsInboxPage'
'404':
description: Workspace scope is not visible because membership is missing or out of scope
/admin:
get:
summary: Workspace overview with assigned-to-me signal
description: >-
Returns the rendered workspace overview. The vendor media type documents
the embedded personal findings signal used to decide whether assigned
findings work exists before opening the inbox.
responses:
'302':
description: Redirects into the existing workspace or tenant chooser flow when membership exists but workspace context is not yet established
'200':
description: Rendered workspace overview page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.workspace-overview-my-findings+json:
schema:
$ref: '#/components/schemas/WorkspaceOverviewMyFindingsSignalSurface'
'404':
description: Workspace scope is not visible because membership is missing or out of scope
/admin/t/{tenant}/findings/{finding}:
get:
summary: Tenant finding detail with inbox continuity support
description: >-
Returns the rendered tenant finding detail page. The logical contract
below documents only the continuity inputs required when the page is
opened from the My Findings inbox.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: finding
in: path
required: true
schema:
type: integer
- name: nav
in: query
required: false
style: deepObject
explode: true
schema:
$ref: '#/components/schemas/CanonicalNavigationContext'
responses:
'200':
description: Rendered tenant finding detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.finding-detail-continuation+json:
schema:
$ref: '#/components/schemas/FindingDetailContinuation'
'403':
description: Viewer is in scope but lacks the existing findings capability for the tenant detail destination
'404':
description: Tenant or finding is not visible because workspace or tenant entitlement is missing
components:
schemas:
MyFindingsInboxPage:
type: object
required:
- header
- appliedScope
- availableFilters
- summaryCounts
- rows
- emptyState
properties:
header:
$ref: '#/components/schemas/InboxHeader'
appliedScope:
$ref: '#/components/schemas/InboxAppliedScope'
availableFilters:
description: Includes the fixed assignee-scope filter plus tenant, overdue, reopened, and high-severity filters. Tenant filter options are derived only from visible capability-eligible tenants.
type: array
items:
$ref: '#/components/schemas/InboxFilterDefinition'
summaryCounts:
$ref: '#/components/schemas/MyFindingsSummaryCounts'
rows:
description: Rows are ordered overdue first, reopened non-overdue second, then remaining findings. Within each bucket, rows with due dates sort by dueAt ascending, rows without due dates sort last, and remaining ties sort by findingId descending.
type: array
items:
$ref: '#/components/schemas/AssignedFindingInboxRow'
emptyState:
oneOf:
- $ref: '#/components/schemas/InboxEmptyState'
- type: 'null'
InboxHeader:
type: object
required:
- title
- description
properties:
title:
type: string
enum:
- My Findings
description:
type: string
clearTenantFilterAction:
oneOf:
- $ref: '#/components/schemas/ActionLink'
- type: 'null'
InboxAppliedScope:
type: object
required:
- workspaceScoped
- assigneeScope
- tenantPrefilterSource
properties:
workspaceScoped:
type: boolean
assigneeScope:
type: string
enum:
- current_user_only
tenantPrefilterSource:
type: string
enum:
- active_tenant_context
- explicit_filter
- none
tenantLabel:
type:
- string
- 'null'
InboxFilterDefinition:
type: object
required:
- key
- label
- fixed
properties:
key:
type: string
enum:
- assignee_scope
- tenant
- overdue
- reopened
- high_severity
label:
type: string
fixed:
type: boolean
options:
type: array
items:
$ref: '#/components/schemas/FilterOption'
FilterOption:
type: object
required:
- value
- label
properties:
value:
type: string
label:
type: string
MyFindingsSummaryCounts:
type: object
description: Counts derived from the currently visible inbox queue after the fixed assignee scope and any active tenant, overdue, reopened, or high-severity filters are applied.
required:
- openAssigned
- overdueAssigned
properties:
openAssigned:
type: integer
minimum: 0
overdueAssigned:
type: integer
minimum: 0
AssignedFindingInboxRow:
type: object
required:
- findingId
- tenantId
- tenantLabel
- summary
- severity
- status
- dueState
- detailUrl
properties:
findingId:
type: integer
tenantId:
type: integer
tenantLabel:
type: string
summary:
type: string
severity:
$ref: '#/components/schemas/Badge'
status:
$ref: '#/components/schemas/Badge'
dueAt:
type:
- string
- 'null'
format: date-time
dueState:
$ref: '#/components/schemas/DueState'
reopened:
type: boolean
ownerLabel:
type:
- string
- 'null'
detailUrl:
type: string
navigationContext:
oneOf:
- $ref: '#/components/schemas/CanonicalNavigationContext'
- type: 'null'
DueState:
type: object
required:
- label
- tone
properties:
label:
type: string
tone:
type: string
enum:
- calm
- warning
- danger
InboxEmptyState:
type: object
required:
- title
- body
- action
properties:
title:
type: string
body:
type: string
reason:
type: string
enum:
- no_visible_assigned_work
- active_tenant_prefilter_excludes_rows
action:
description: For `active_tenant_prefilter_excludes_rows`, clears the tenant prefilter and returns the queue to all visible tenants. For `no_visible_assigned_work`, opens the active tenant's findings list when tenant context exists; otherwise opens `/admin/choose-tenant` so the operator can establish tenant context before entering tenant findings.
$ref: '#/components/schemas/ActionLink'
WorkspaceOverviewMyFindingsSignalSurface:
type: object
required:
- workspaceId
- myFindingsSignal
properties:
workspaceId:
type: integer
myFindingsSignal:
$ref: '#/components/schemas/MyFindingsSignal'
MyFindingsSignal:
type: object
required:
- openAssignedCount
- overdueAssignedCount
- isCalm
- headline
- cta
properties:
openAssignedCount:
type: integer
minimum: 0
overdueAssignedCount:
type: integer
minimum: 0
isCalm:
type: boolean
headline:
type: string
enum:
- Assigned to me
description:
type:
- string
- 'null'
cta:
$ref: '#/components/schemas/OpenMyFindingsActionLink'
FindingDetailContinuation:
type: object
description: Continuity payload for tenant finding detail when it is opened from the My Findings inbox. The backLink is present whenever canonical inbox navigation context is provided and may be null only for direct entry without inbox continuity context.
required:
- findingId
- tenantId
properties:
findingId:
type: integer
tenantId:
type: integer
backLink:
description: Present when the detail page is reached from the My Findings inbox with canonical navigation context; null only for direct navigation that did not originate from the inbox.
oneOf:
- $ref: '#/components/schemas/BackToMyFindingsActionLink'
- type: 'null'
CanonicalNavigationContext:
type: object
required:
- source_surface
- canonical_route_name
properties:
source_surface:
type: string
canonical_route_name:
type: string
tenant_id:
type:
- integer
- 'null'
back_label:
type:
- string
- 'null'
back_url:
type:
- string
- 'null'
ActionLink:
type: object
required:
- label
- url
properties:
label:
type: string
url:
type: string
OpenMyFindingsActionLink:
allOf:
- $ref: '#/components/schemas/ActionLink'
- type: object
properties:
label:
type: string
enum:
- Open my findings
BackToMyFindingsActionLink:
allOf:
- $ref: '#/components/schemas/ActionLink'
- type: object
properties:
label:
type: string
enum:
- Back to my findings
Badge:
type: object
required:
- label
properties:
label:
type: string
color:
type:
- string
- 'null'

View File

@ -0,0 +1,169 @@
# Data Model: Findings Operator Inbox V1
## Overview
This feature does not add or modify persisted entities. It introduces two derived read models:
- the canonical admin-plane `My Findings` inbox at `/admin/findings/my-work`
- one compact `Assigned to me` signal on `/admin`
Both remain projections over existing finding, tenant membership, and workspace context truth.
## Existing Persistent Inputs
### 1. Finding
- Purpose: Tenant-owned workflow record representing current governance or execution work.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `severity`
- `due_at`
- `subject_display_name`
- `owner_user_id`
- `assignee_user_id`
- `reopened_at`
- Relationships used by this feature:
- `tenant()`
- `ownerUser()`
- `assigneeUser()`
Relevant existing semantics:
- `Finding::openStatusesForQuery()` defines inbox inclusion for open work.
- `Finding::openStatuses()` and terminal statuses remain unchanged.
- Spec 219 defines assignee inclusion and owner-only exclusion.
### 2. Tenant
- Purpose: Tenant boundary for findings ownership and tenant-plane detail navigation.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `name`
- `external_id`
- `status`
### 3. TenantMembership
- Purpose: Per-tenant entitlement boundary for visibility.
- Key persisted fields used by this feature:
- `tenant_id`
- `user_id`
- `role`
The inbox and overview signal must only materialize findings from tenants where the current user still has membership and findings-view entitlement.
### 4. Workspace Context
- Purpose: Active workspace selection in the admin plane.
- Source: Existing workspace session context, not a new persisted model for this feature.
- Effect on this feature:
- gates entry into the admin inbox
- constrains visible tenants to the current workspace
- feeds the workspace overview signal
## Derived Presentation Entities
### 1. AssignedFindingInboxRow
Logical row model for `/admin/findings/my-work`.
| Field | Meaning | Source |
|---|---|---|
| `findingId` | Target finding identifier | `Finding.id` |
| `tenantId` | Tenant route scope for detail drilldown | `Finding.tenant_id` |
| `tenantLabel` | Tenant name visible in the queue | `Tenant.name` |
| `summary` | Operator-facing finding summary | `Finding.subject_display_name` plus existing fallback logic |
| `severity` | Severity badge value | `Finding.severity` |
| `status` | Workflow/lifecycle badge value | `Finding.status` |
| `dueAt` | Due date if present | `Finding.due_at` |
| `dueState` | Derived urgency label such as overdue or due soon | existing finding due-state logic |
| `reopened` | Whether reopened context should be emphasized | `Finding.status === reopened` or existing lifecycle cues |
| `ownerLabel` | Accountable owner when different from assignee | `ownerUser.name` |
| `detailUrl` | Tenant finding detail route | derived from tenant finding view route |
| `navigationContext` | Return-path payload back to the inbox | derived from `CanonicalNavigationContext` |
Validation rules:
- Row inclusion requires all of the following:
- finding belongs to the current workspace
- finding belongs to a tenant the current user may inspect
- finding status is in `Finding::openStatusesForQuery()`
- `assignee_user_id` equals the current user ID
- Owner-only findings are excluded.
- Hidden-tenant findings produce no row, no count, and no filter option.
### 2. MyFindingsInboxState
Logical state model for the inbox page.
| Field | Meaning |
|---|---|
| `workspaceId` | Current admin workspace scope |
| `assigneeUserId` | Fixed personal-work scope |
| `tenantFilter` | Optional active-tenant prefilter, defaulted from canonical admin tenant context |
| `overdueOnly` | Optional urgency narrowing |
| `reopenedOnly` | Optional reopened-work narrowing |
| `highSeverityOnly` | Optional severity narrowing |
Rules:
- `assigneeUserId` is fixed and cannot be cleared in v1.
- `tenantFilter` is clearable.
- `tenantFilter` values may only reference entitled tenants.
- Invalid or stale tenant filter state is discarded rather than widening visibility.
- Inbox summary counts reflect the currently visible queue after the fixed assignee scope and any active tenant, overdue, reopened, or high-severity filters are applied.
### 3. MyFindingsSignal
Logical summary model for the workspace overview.
| Field | Meaning | Source |
|---|---|---|
| `openAssignedCount` | Count of visible open assigned findings | derived `Finding` count |
| `overdueAssignedCount` | Count of visible overdue assigned findings | derived `Finding` count with `due_at < now()` |
| `isCalm` | Whether the signal should render calm wording | derived from the two counts |
| `headline` | Operator-facing signal summary | derived presentation copy |
| `description` | Supporting copy clarifying visible-scope truth | derived presentation copy |
| `ctaLabel` | Explicit drill-in label | fixed vocabulary `Open my findings` |
| `ctaUrl` | Canonical inbox route | derived from the new admin page URL |
Validation rules:
- Counts are workspace-scoped and tenant-entitlement scoped.
- The signal never implies hidden or inaccessible work.
- Calm wording is only allowed when both visible counts are zero.
## State And Ordering Rules
### Inbox inclusion order
1. Restrict to the current workspace.
2. Restrict to visible tenant IDs.
3. Restrict to `assignee_user_id = current user`.
4. Restrict to `Finding::openStatusesForQuery()`.
5. Apply optional tenant/overdue/reopened/high-severity filters.
6. Sort overdue work first, reopened non-overdue work next, then remaining work.
7. Within each urgency bucket, rows with due dates sort by `dueAt` ascending, rows without due dates sort last, and remaining ties sort by `findingId` descending.
### Urgency semantics
- Overdue work is the highest-priority urgency bucket.
- Reopened non-overdue work is the next urgency bucket.
- High-severity remains a filter and emphasis cue rather than a separate mandatory sort bucket.
- Terminal findings are never part of the inbox or signal.
### Empty-state semantics
- If no visible assigned open findings exist anywhere in scope, the inbox shows a calm empty state.
- If the active tenant prefilter causes the empty state while other visible tenants still have work, the empty state must explain the tenant boundary and provide a clear fallback CTA.
## Authorization-sensitive Output
- Tenant labels, filter values, rows, and counts are only derived from entitled tenants.
- The inbox itself is workspace-context dependent.
- Detail navigation remains tenant-scoped and must preserve existing `404`/`403` semantics on the destination.
- The derived signal and row model remain useful without ever revealing hidden tenant names or quantities.

View File

@ -0,0 +1,245 @@
# Implementation Plan: Findings Operator Inbox V1
**Branch**: `221-findings-operator-inbox` | **Date**: 2026-04-20 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/spec.md`
**Note**: This plan keeps the work inside the existing admin workspace shell, tenant-owned `Finding` truth, and current tenant finding detail surface. The intended implementation is one new canonical `/admin` page plus one small workspace overview signal. It does not add persistence, capabilities, workflow states, queue automation, or a second mutation lane.
## Summary
Add one canonical admin-plane inbox at `/admin/findings/my-work` that shows the current user's open assigned findings across visible, capability-eligible tenants, keeps urgency first with a deterministic overdue-then-reopened ordering, and drills into the existing tenant finding detail with a preserved return path. Reuse existing `Finding` lifecycle and responsibility semantics, `CanonicalAdminTenantFilterState` for active-tenant prefiltering and applied-scope metadata, `CanonicalNavigationContext` for queue-to-detail continuity, and extend `WorkspaceOverviewBuilder` plus the workspace overview Blade view with one compact `Assigned to me` signal that exposes open and overdue counts with a single CTA into the inbox.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
**Primary Dependencies**: Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
**Storage**: PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned
**Testing**: Pest v4 feature tests with Livewire/Filament page assertions
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
**Project Type**: Laravel monolith inside the `wt-plattform` monorepo
**Performance Goals**: Keep inbox and overview rendering DB-only, eager-load tenant/owner/assignee display context, avoid N+1 queries, and keep the first operator scan within the 10-second acceptance target
**Constraints**: No Graph calls, no new `OperationRun`, no new workflow states or owner semantics, no new capabilities, no new queue mutations, no hidden-tenant leakage, no duplicate personal-work truth between inbox and overview, and no new assets
**Scale/Scope**: One admin page, one workspace overview signal, one derived cross-tenant personal-assignment query, three focused feature suites, and one focused extension to existing route-alignment coverage
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament page, table, empty-state, badge, and Blade summary primitives only
- **Shared-family relevance**: findings workflow family and workspace overview summary family
- **State layers in scope**: shell, page, detail, URL-query
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none; the inbox remains a standard read-first admin page and the overview signal stays an embedded summary, not a second queue
- **Active feature PR close-out entry**: Guardrail
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature reads live `Finding` assignment and lifecycle truth only; no new snapshot or backup semantics are introduced |
| Read/write separation | PASS | PASS | The inbox and overview signal are read-only; existing finding lifecycle mutations remain on tenant finding detail |
| Graph contract path | PASS | PASS | No Graph client, contract-registry, or external API work is added |
| Deterministic capabilities / RBAC-UX | PASS | PASS | Workspace membership gates the admin page, missing workspace context follows the existing chooser or resume flow, tenant entitlement plus existing findings capability gate row visibility, filter values, summary counts, overview counts, and detail drilldown, and non-members remain `404` |
| Workspace / tenant isolation | PASS | PASS | Admin-plane inbox is workspace-scoped while drilldown stays on `/admin/t/{tenant}/findings/{finding}` with tenant-safe continuity |
| Run observability / Ops-UX | PASS | PASS | No long-running work, no `OperationRun`, and no notification or progress-surface changes are introduced |
| Proportionality / no premature abstraction | PASS | PASS | The plan keeps the query logic inside the new page and existing workspace builder seams; no new shared registry or durable abstraction is required |
| Persisted truth / few layers | PASS | PASS | Inbox rows and overview counts remain direct derivations of existing `Finding`, tenant membership, and workspace context data |
| Badge semantics (BADGE-001) | PASS | PASS | Severity, lifecycle, and urgency cues reuse existing findings badge semantics rather than introducing local status language |
| Filament-native UI (UI-FIL-001) | PASS | PASS | Implementation stays within Filament page, table, header action, empty-state, and existing Blade summary composition |
| Action surface / inspect model | PASS | PASS | The inbox keeps one inspect model, full-row open to finding detail, no redundant `View`, and no dangerous queue actions |
| Decision-first / OPSURF | PASS | PASS | The inbox is the primary decision surface and the overview signal stays secondary with one CTA |
| Test governance (TEST-GOV-001) | PASS | PASS | Proof remains in three focused feature suites plus one focused extension to existing route-alignment coverage, with no browser or heavy-governance promotion |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design uses existing Filament v5 page patterns and Livewire v4-compatible page/table behavior only |
| Provider registration / global search / assets | PASS | PASS | Panel providers already live in `apps/platform/bootstrap/providers.php`; the new page extends `AdminPanelProvider` only, `FindingResource` global search behavior is unchanged and already has a View page, and no new assets are required |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for the admin inbox page, authorization boundary behavior, and workspace overview signal
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The feature is proven by visible operator behavior, capability-safe tenant filtering, continuity into existing tenant detail, empty-state fallback behavior, and overview-to-inbox truth alignment. Focused feature tests cover that without adding unit seams, browser automation, or heavy-governance breadth.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/MyFindingsSignalTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Moderate. The tests need one workspace, multiple visible and hidden tenants, owner-versus-assignee combinations, open and terminal findings, and explicit workspace plus active-tenant session context.
- **Expensive defaults or shared helper growth introduced?**: no; the new suites should reuse existing `createUserWithTenant(...)` and `Finding::factory()` flows and keep any inbox-specific fixture helper local to the new tests
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: named special profile `global-context-shell` is required because the inbox depends on workspace context, active tenant continuity, and admin-to-tenant drilldown behavior
- **Closing validation and reviewer handoff**: Reviewers should rely on the exact commands above and verify that hidden-tenant or capability-blocked findings never leak into rows, filter options, or counts, owner-only findings stay out of the queue, the active tenant prefilter is clearable without removing personal scope, the empty state offers the correct fallback CTA, and the detail page exposes a working return path to the inbox.
- **Budget / baseline / trend follow-up**: none
- **Review-stop questions**: Did the implementation stay read-only? Did the overview signal remain a signal instead of a second queue? Did any shared query abstraction appear without clear reuse pressure beyond this feature? Did row drilldown preserve tenant-safe continuity?
- **Escalation path**: document-in-feature unless a second cross-tenant findings surface or a new shared query framework is proposed, in which case split or follow up with a dedicated spec
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: The feature remains bounded to one queue surface and one overview signal, with no new persistence, workflow family, or reusable platform abstraction
## Project Structure
### Documentation (this feature)
```text
specs/221-findings-operator-inbox/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── findings-operator-inbox.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── Findings/
│ │ │ └── MyFindingsInbox.php
│ │ └── Resources/
│ │ └── FindingResource.php
│ ├── Providers/
│ │ └── Filament/
│ │ └── AdminPanelProvider.php
│ └── Support/
│ ├── Filament/
│ │ └── CanonicalAdminTenantFilterState.php
│ ├── Navigation/
│ │ └── CanonicalNavigationContext.php
│ └── Workspaces/
│ └── WorkspaceOverviewBuilder.php
├── resources/
│ └── views/
│ └── filament/
│ └── pages/
│ ├── findings/
│ │ └── my-findings-inbox.blade.php
│ └── workspace-overview.blade.php
└── tests/
└── Feature/
├── Authorization/
│ └── MyWorkInboxAuthorizationTest.php
├── Dashboard/
│ └── MyFindingsSignalTest.php
├── Filament/
│ └── WorkspaceOverviewNavigationTest.php
└── Findings/
└── MyWorkInboxTest.php
```
**Structure Decision**: Standard Laravel monolith. The feature stays inside the existing admin panel provider, finding domain model, workspace overview builder, and focused Pest feature suites. No new base directory, package, or persistent model is required.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| none | — | — |
## Proportionality Review
- **Current operator problem**: Assignee-based finding work exists in the data model but not as a trustworthy cross-tenant start-of-day surface.
- **Existing structure is insufficient because**: The tenant-local findings list can filter to `My assigned work`, but it still forces users to guess the correct tenant first and does not create one canonical admin-plane queue.
- **Narrowest correct implementation**: Add one admin page and one small workspace overview signal, both derived directly from existing finding assignment, lifecycle, due-date, severity, and entitlement truth.
- **Ownership cost created**: One new page/view pair, one small overview payload addition, three focused feature suites, and one focused extension to existing route-alignment coverage.
- **Alternative intentionally rejected**: A broader cross-tenant findings register, owner-plus-assignee mixed queue, or new shared findings query service. Those would increase semantics and maintenance cost beyond the current release need.
- **Release truth**: Current-release truth. The work operationalizes the already-shipped assignee concept now rather than preparing future queue or notification systems.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/research.md`.
Key decisions:
- Implement the inbox as an admin panel page with slug `findings/my-work` under the existing `AdminPanelProvider`, not as a tenant resource variant and not as a standalone controller route.
- Keep the cross-tenant personal queue fully derived from `Finding` plus existing workspace and tenant entitlements, using `Finding::openStatusesForQuery()` and eager-loaded tenant/owner/assignee relationships.
- Reuse `CanonicalAdminTenantFilterState` to apply the active tenant as the default prefilter, expose the applied-scope state, and preserve the clear-filter behavior without inventing a second context mechanism.
- Use `CanonicalNavigationContext` when building row drilldowns so tenant finding detail can expose `Back to my findings` with tenant-safe continuity.
- Add the workspace signal as a dedicated overview payload rendered in the existing `/admin` Blade page rather than overloading generic summary metrics, because the signal must show open count, overdue count, and one explicit CTA.
- Prove the feature with three focused Pest feature suites plus one focused extension to existing route-alignment coverage.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/`:
- `research.md`: routing, query, continuity, and workspace-signal decisions
- `data-model.md`: existing entities plus derived inbox row and workspace signal projections
- `contracts/findings-operator-inbox.logical.openapi.yaml`: internal logical contract for the inbox, overview signal, and detail continuity inputs
- `quickstart.md`: focused validation workflow for implementation and review
Design decisions:
- No schema migration is required; the inbox and overview signal remain fully derived.
- The canonical implementation seam is one new admin page plus one existing workspace overview builder/view extension, not a new shared findings-query subsystem.
- Active tenant context stays canonical through `CanonicalAdminTenantFilterState`, while detail continuity stays canonical through `CanonicalNavigationContext`.
- Existing tenant finding detail remains the only mutation surface for assignment and workflow actions.
## Phase 1 Agent Context Update
Run:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Constitution Check — Post-Design Re-evaluation
- PASS — the design remains read-surface only, adds no writes, no Graph calls, no `OperationRun`, no new capability family, and no new assets.
- PASS — Livewire v4.0+ and Filament v5 constraints remain satisfied, panel provider registration stays in `apps/platform/bootstrap/providers.php`, and no global-search or destructive-action behavior changes are introduced.
## Implementation Strategy
### Phase A — Add The Canonical Admin Inbox Surface
**Goal**: Create one workspace-scoped personal findings queue under `/admin/findings/my-work`.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Add a new Filament admin page with slug `findings/my-work`, `HasTable`, workspace membership access checks, and fixed assignee/open-status scope |
| A.2 | `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php` | Render the page shell, page description, native Filament table, and branch-specific calm empty-state CTAs: clear the tenant prefilter when it alone excludes rows, otherwise open tenant findings for the active tenant or `/admin/choose-tenant` when no tenant context exists |
| A.3 | `apps/platform/app/Providers/Filament/AdminPanelProvider.php` | Register the new page in the existing admin panel; do not move provider registration because it already lives in `apps/platform/bootstrap/providers.php` |
### Phase B — Derive Queue Truth From Existing Findings Semantics
**Goal**: Keep inclusion, urgency, and owner-versus-assignee behavior aligned with Specs 111 and 219.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Build the table query and derived inbox counts from `Finding`, restricted to the current workspace, visible capability-eligible tenants, `assignee_user_id = auth()->id()`, and `Finding::openStatusesForQuery()` |
| B.2 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/app/Filament/Resources/FindingResource.php` | Reuse or mirror existing severity, lifecycle, overdue, reopened, and owner context presentation helpers without inventing new badge semantics |
| B.3 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Add the available-filters contract for fixed assignee scope, tenant, overdue, reopened, and high-severity filters, capability-safe tenant filter options, applied-scope metadata, summary counts, and deterministic urgency sorting with due-date and finding-ID tie-breakers while keeping personal assignment scope fixed and non-removable |
### Phase C — Honor Active Tenant Context And Queue-to-Detail Continuity
**Goal**: Make the inbox feel canonical inside the existing admin shell instead of a detached register.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Reuse `CanonicalAdminTenantFilterState` so the active tenant becomes the default prefilter and can be cleared via one header action |
| C.2 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Build row URLs to tenant finding detail with `CanonicalNavigationContext` carrying `Back to my findings` continuity |
| C.3 | `apps/platform/app/Filament/Resources/FindingResource.php` or existing detail page seam | Ensure the tenant detail page consumes the navigation context and exposes the inbox return link without changing mutation semantics |
### Phase D — Add The Workspace Overview Signal Without Creating A Second Queue
**Goal**: Make `/admin` show whether personal findings work exists and link into the inbox in one click.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add a small `my_findings_signal` payload with visible capability-safe open assigned count, overdue count, calm state, and CTA URL |
| D.2 | `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` | Render one compact assigned-to-me summary block ahead of the broader workspace metrics |
| D.3 | Existing workspace navigation helpers | Keep the CTA pointed at the canonical inbox route and avoid duplicating queue logic or urgency semantics in the overview itself |
### Phase E — Protect The Feature With Focused Regression Coverage
**Goal**: Lock down visibility, prioritization, tenant safety, and overview-to-inbox truth alignment.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php` | Cover capability-safe visible rows only, owner-context rendering when owner differs from assignee, the full available-filters contract truth for fixed assignee scope plus tenant, overdue, reopened, and high-severity filters, tenant filter option truth, owner-only exclusion, deterministic urgency ordering with due-date and finding-ID tie-breakers, clearable tenant prefilter, applied-scope and summary-count truth, zero-visible-work and tenant-prefilter empty-state branches, and drilldown continuity |
| E.2 | `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` | Cover admin-plane workspace recovery behavior for missing workspace context, member-without-capability disclosure boundaries, protected-destination `403`, and deny-as-not-found boundaries for non-members |
| E.3 | `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php` | Cover overview signal counts, calm state, capability-safe suppression, and CTA alignment with inbox truth |
| E.4 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus the focused Pest commands above | Run formatting and the narrowest proving suites before closing implementation |

View File

@ -0,0 +1,147 @@
# Quickstart: Findings Operator Inbox V1
## Goal
Validate that `/admin/findings/my-work` gives the current user one trustworthy assigned-findings queue across visible tenants, and that `/admin` exposes a matching `Assigned to me` signal with one CTA into the inbox.
## Prerequisites
1. Start Sail if it is not already running.
2. Use a test user who is a member of one workspace and at least two tenants inside that workspace.
3. Seed or create findings for these cases:
- assigned open finding in tenant A
- assigned overdue finding in tenant B
- assigned reopened finding in tenant A
- assigned ordinary finding without a due date
- owner-only open finding where the user is not assignee
- assigned terminal finding
- assigned finding in a tenant the user is no longer entitled to inspect
- assigned finding in a tenant where the user remains a member but no longer has findings visibility capability
4. Ensure the workspace overview at `/admin` is reachable for the acting user.
## Focused Automated Verification
Run formatting first:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
Then run the focused proving set:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact \
tests/Feature/Findings/MyWorkInboxTest.php \
tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php \
tests/Feature/Dashboard/MyFindingsSignalTest.php
```
Then run the required route-alignment regression for the workspace shell CTA:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact \
tests/Feature/Filament/WorkspaceOverviewNavigationTest.php
```
## Manual Validation Pass
### 1. Workspace overview signal
Open `/admin`.
Confirm that:
- the page shows an `Assigned to me` signal,
- the signal exposes visible open count and overdue count,
- the wording stays calm only when both counts are zero,
- and the CTA is labeled `Open my findings`.
### 2. Canonical inbox route
Open `/admin/findings/my-work`.
Confirm that:
- the page title and copy use finding-first vocabulary,
- rows show tenant, finding summary, severity, lifecycle, and due urgency,
- owner context is shown when owner differs from assignee,
- the page reflects fixed assigned-to-me scope and summary counts that match the visible rows,
- and no queue-level mutation actions appear.
### 3. Assignee versus owner boundary
With fixtures where the acting user is owner-only on one finding and assignee on another:
Confirm that:
- the assignee row is visible,
- the owner-only row is absent,
- a finding where the user is both owner and assignee remains visible,
- and owner context is shown only when it differs from the assignee.
### 4. Active tenant prefilter
Set an active tenant context before opening the inbox.
Confirm that:
- the queue defaults to that tenant,
- the personal assignment scope remains fixed,
- the applied scope reflects that tenant-prefilter source correctly,
- a `Clear tenant filter` affordance is available,
- summary counts continue to match the visible rows,
- and clearing the tenant filter returns the queue to all visible tenants.
### 5. Hidden-tenant and capability suppression
Remove the acting user's entitlement from one tenant that still contains assigned findings, and remove findings visibility capability from another tenant where membership still remains.
Confirm that:
- those findings disappear from the inbox,
- they do not contribute to the workspace signal,
- neither tenant appears as a filter value or empty-state hint,
- and protected destinations still reject in-scope users without findings visibility.
### 6. Urgency ordering
With ordinary, reopened, overdue, and undated assigned findings:
Confirm that:
- overdue work appears ahead of reopened and ordinary work,
- reopened non-overdue work appears ahead of ordinary work,
- within the same urgency bucket, due-dated rows appear ahead of undated rows,
- reopened context is visible,
- and terminal findings do not appear.
### 7. Detail continuity
Open a row from the inbox.
Confirm that:
- the destination is the existing tenant finding detail route,
- the tenant scope is correct,
- and the page offers a `Back to my findings` return path.
### 8. Empty-state behavior
Validate two empty states:
- no visible assigned work anywhere
- no rows only because the active tenant prefilter narrows the queue
Confirm that:
- the zero-visible-work branch stays calm and offers a clear fallback CTA,
- the first state's CTA opens tenant findings when active tenant context exists and `/admin/choose-tenant` when it does not,
- the second state explains the tenant boundary instead of claiming there is no work anywhere,
- the second state's CTA clears the tenant prefilter back to all visible tenants,
- and neither state leaks hidden tenant information.
## Final Verification Notes
- The inbox is read-first only. Assignment and workflow mutations stay on tenant finding detail.
- The workspace overview signal must remain a signal, not a second queue.
- If a reviewer can infer hidden tenant work from counts, filter options, or empty-state copy, treat that as a release blocker.

View File

@ -0,0 +1,49 @@
# Research: Findings Operator Inbox V1
## Decision 1: Implement the inbox as an admin panel page with slug `findings/my-work`
- **Decision**: Add a new Filament admin page under the existing `AdminPanelProvider` for the canonical inbox route `/admin/findings/my-work`.
- **Rationale**: The feature is explicitly an admin-plane, workspace-scoped surface. The admin panel already owns comparable custom pages such as `FindingExceptionsQueue`, and page registration gives the correct Filament middleware, route shape, and Livewire page lifecycle without creating a second routing model.
- **Alternatives considered**:
- Reuse the tenant-local `FindingResource` list as the canonical inbox. Rejected because it keeps the operator trapped in tenant-first navigation and does not answer the cross-tenant personal-work question.
- Add a standalone controller route in `routes/web.php`. Rejected because this is a normal admin panel surface, not a one-off shell route like `/admin` home.
## Decision 2: Keep queue truth as a direct `Finding` query, not a new shared query subsystem
- **Decision**: Build the inbox from `Finding` records scoped by current workspace, visible capability-eligible tenant IDs, `assignee_user_id = current user`, and `Finding::openStatusesForQuery()`, with eager-loaded `tenant`, `ownerUser`, and `assigneeUser` relationships.
- **Rationale**: The feature has two concrete consumers, but both are narrow and local: one queue page and one workspace signal. A direct query keeps the logic readable, honors Spec 111 and Spec 219 as-is, and avoids importing a new reusable abstraction before it is clearly needed.
- **Alternatives considered**:
- Introduce a new shared findings-query service immediately. Rejected because the scope is still small and the repo guidance prefers direct implementation until a second real abstraction pressure appears.
- Mix owner and assignee semantics into one queue query. Rejected because Spec 219 explicitly separates assignee work from owner accountability.
## Decision 3: Reuse `CanonicalAdminTenantFilterState` for the default active-tenant prefilter
- **Decision**: Let the inbox synchronize its tenant filter through `CanonicalAdminTenantFilterState`, so the active tenant becomes the default prefilter and can be cleared without removing personal assignment scope.
- **Rationale**: The repo already uses this helper on admin-panel lists and monitoring pages to keep active tenant context honest and clearable. Reusing it keeps the inbox aligned with existing admin context behavior and avoids inventing a page-specific prefilter mechanism.
- **Alternatives considered**:
- Drive tenant prefiltering only through explicit query parameters. Rejected because the feature requirement is about active tenant context, not just shareable URLs.
- Hard-lock the queue to the active tenant whenever tenant context exists. Rejected because the spec requires a clear path back to all visible tenants.
## Decision 4: Add the workspace signal as a dedicated overview payload and Blade block
- **Decision**: Extend `WorkspaceOverviewBuilder` with a compact `my_findings_signal` payload and render it directly in the existing workspace overview Blade view with one explicit CTA.
- **Rationale**: The signal must show open count, overdue count, calm state, and one named CTA. The existing generic `summary_metrics` lane is optimized for one metric value plus description and does not cleanly express the contract without distorting the overview metric family.
- **Alternatives considered**:
- Encode the signal as another generic summary metric. Rejected because the metric card does not naturally expose both counts and an explicit `Open my findings` CTA.
- Add a second standalone dashboard or queue widget. Rejected because the overview only needs a small drill-in signal, not another work surface.
## Decision 5: Preserve queue-to-detail continuity through `CanonicalNavigationContext`
- **Decision**: Append a `CanonicalNavigationContext` payload when an inbox row opens `/admin/t/{tenant}/findings/{finding}` so the detail page can render `Back to my findings`.
- **Rationale**: The repo already uses `CanonicalNavigationContext` for cross-surface return links on admin and tenant detail pages. Reusing it preserves a single continuity model and keeps the return path explicit instead of relying on fragile browser history.
- **Alternatives considered**:
- Depend on the browser referer only. Rejected because it is brittle across reloads, tabs, and copied links.
- Add a new inbox-specific controller just to set session return state. Rejected because the existing navigation context already solves this problem cleanly.
## Decision 6: Prove the feature with three focused feature suites plus one route-alignment extension
- **Decision**: Add `MyWorkInboxTest`, `MyWorkInboxAuthorizationTest`, and `MyFindingsSignalTest` as the three new focused suites, and extend `WorkspaceOverviewNavigationTest` for route-alignment proof.
- **Rationale**: The user-visible risk is visibility, prioritization, tenant safety, and overview-to-inbox truth alignment. Those are best proven through focused feature coverage using the existing workspace and tenant helpers, while the inbox CTA alignment belongs in the existing route-alignment regression instead of a fourth new suite.
- **Alternatives considered**:
- Add browser coverage. Rejected because the surface is simple and already well represented by Filament/Livewire feature assertions.
- Add a unit-only seam around queue counting. Rejected because the important risk is integrated scope behavior, not isolated arithmetic.

View File

@ -0,0 +1,236 @@
# Feature Specification: Findings Operator Inbox V1
**Feature Branch**: `221-findings-operator-inbox`
**Created**: 2026-04-20
**Status**: Draft
**Input**: User description: "Findings Operator Inbox v1"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Findings can already be assigned, but the current assignee still has to reconstruct personal work from tenant-local findings lists and ad hoc filters.
- **Today's failure**: Assignment remains metadata instead of day-to-day workflow. Operators cannot answer "what is mine right now?" from one trustworthy surface, so they tenant-hop or miss overdue assigned work.
- **User-visible improvement**: One personal inbox shows the current user's open assigned findings across visible tenants, highlights urgency, and gives a direct path into the correct finding record.
- **Smallest enterprise-capable version**: A canonical read-first inbox for the current user's assigned open findings, urgency filters, tenant-safe drilldown into the existing finding detail, and one small workspace-overview signal that links into the inbox.
- **Explicit non-goals**: No owner-only accountability queue, no unassigned intake queue, no notifications or escalation, no comments or external ticketing, no team-routing logic, no bulk queue actions, and no new permission system.
- **Permanent complexity imported**: One canonical inbox page, one small workspace overview summary signal, one derived personal-assignment query contract, and focused regression coverage for visibility, context handoff, and empty-state behavior.
- **Why now**: Spec 219 made owner versus assignee semantics explicit. The next smallest findings execution slice is to make assignee-based work actually discoverable before intake, escalation, or hygiene hardening land.
- **Why not local**: A tenant-local `My assigned` filter still forces operators to guess which tenant to open first and does not create one trustworthy start-of-day queue across the workspace-visible tenant set.
- **Approval class**: Core Enterprise
- **Red flags triggered**: One mild `Many surfaces` risk because the slice touches the inbox and a workspace overview signal. The scope remains acceptable because both surfaces express the same personal-work truth and do not introduce a new meta-layer.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- `/admin/findings/my-work` as the new canonical personal findings inbox
- `/admin` as the workspace overview where the assigned-to-me signal links into the inbox
- `/admin/t/{tenant}/findings` as the existing tenant findings list fallback
- `/admin/t/{tenant}/findings/{finding}` as the existing tenant finding detail drilldown
- **Data Ownership**: Tenant-owned findings remain the only source of truth. The inbox and workspace overview signal are derived views over existing finding assignment, lifecycle, severity, due-date, and tenant-entitlement truth.
- **RBAC**: Workspace membership is required to reach the canonical inbox in the admin plane. Every visible row and count additionally requires tenant entitlement plus the existing findings view capability for the referenced tenant. Non-members or out-of-scope users remain deny-as-not-found. In-scope users without the required capability remain forbidden on protected destinations.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: The inbox always applies `assignee = current user` and open-status scope. When an active tenant context exists, the page additionally prefilters to that tenant by default while allowing the operator to clear only the tenant prefilter, not the personal assignment scope.
- **Explicit entitlement checks preventing cross-tenant leakage**: Counts, rows, tenant filter values, and drilldown links materialize only from tenants the current user may already inspect. Hidden tenants contribute nothing to counts, labels, filter values, or empty-state hints.
## 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 |
|---|---|---|---|---|---|---|
| My Findings inbox | yes | Native Filament page + existing table, filter, and empty-state primitives | Same findings workflow family as tenant findings list and finding detail | table, filter state, urgency emphasis, return path | no | Read-first queue; no new mutation family on the inbox itself |
| Workspace overview assigned-to-me signal | yes | Native Filament widget or summary primitives | Same workspace-overview summary family as other `/admin` attention signals | embedded summary, drill-in CTA | no | Small entry signal only; not a second queue |
## 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 |
|---|---|---|---|---|---|---|---|
| My Findings inbox | Primary Decision Surface | The operator starts the work session or returns to outstanding assigned findings | Tenant, severity, lifecycle status, due or overdue state, and reopened cues for the operator's assigned work | Full finding detail, evidence, audit trail, and exception context after opening the finding | Primary because this is the first dedicated queue for assignee-based execution work | Aligns daily execution around assigned findings instead of tenant hopping | Removes repeated search across tenant dashboards and tenant findings lists |
| Workspace overview assigned-to-me signal | Secondary Context Surface | The operator lands on `/admin` and needs to know whether personal follow-up exists before choosing a domain | Open assigned count, overdue count, and one CTA into the inbox | Full inbox and finding detail after drill-in | Secondary because it points to work rather than hosting it | Keeps workspace home aligned with the assignee queue | Removes opening multiple pages just to discover personal work |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| My Findings inbox | List / Table / Bulk | CRUD / List-first Resource | Open the most urgent assigned finding | Finding | required | Utility filters and fallback navigation stay outside row action noise | None on the inbox; dangerous lifecycle actions stay on finding detail | /admin/findings/my-work | /admin/t/{tenant}/findings/{finding} | Active workspace, optional active-tenant prefilter, tenant column, assigned-to-me scope | Findings / Finding | What is assigned to the current user, what is overdue, and which tenant it belongs to | Operationally this is a personal worklist, but the interaction model is list-first because row open is the only primary action and all mutation remains on the finding detail surface. |
| Workspace overview assigned-to-me signal | Utility / System | Read-only Registry / Report Surface | Open the inbox | Explicit summary CTA | forbidden | Summary strip only | none | /admin | /admin/findings/my-work | Active workspace and visible-scope counts | My findings | Whether the current user has assigned open or overdue work | Embedded summary drill-in that stays read-only and points into the canonical inbox rather than becoming its own queue surface. |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| My Findings inbox | Tenant operator or tenant manager | Decide which assigned finding to open and work next | List-first personal work surface | What is assigned to me right now across my visible tenants, and what needs attention first? | Tenant, finding summary, severity, lifecycle status, due date or overdue state, reopened cue, and owner when different from assignee | Raw evidence, run context, exception history, and full audit trail | lifecycle, due urgency, severity, responsibility role | none on the inbox itself; existing tenant detail surfaces keep their current mutation scopes | Open finding, apply filters, clear tenant prefilter | none |
| Workspace overview assigned-to-me signal | Workspace member with findings visibility | Decide whether personal findings work exists and drill into it | Summary drill-in | Do I have assigned findings work that needs attention right now? | Open assigned count, overdue count, and one CTA | none | queue presence, overdue urgency | none | Open my findings | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Assigned findings are already present in the product, but there is no single trustworthy place where the assignee can start work across visible tenants.
- **Existing structure is insufficient because**: Tenant-local findings pages and one quick filter do not answer the cross-tenant personal-work question. They force the operator to search for assigned work instead of receiving a real queue.
- **Narrowest correct implementation**: One derived inbox page and one small workspace summary signal, both powered by existing assignee, severity, due-date, lifecycle, tenant, and entitlement truth.
- **Ownership cost**: One cross-tenant query shape, one context-prefilter rule, one return-path convention, and focused regression tests for visibility and empty-state behavior.
- **Alternative intentionally rejected**: A full cross-tenant findings register, an owner-plus-assignee mixed queue, or a team workboard was rejected because those shapes are broader than the current operator problem.
- **Release truth**: Current-release truth. This slice makes the already-shipped assignment concept operational now.
### 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**: Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: The change is proven by visible operator behavior on one canonical page and one workspace summary surface. Focused feature coverage is sufficient to prove personal-queue visibility, tenant-safe filtering, context handoff, and empty-state behavior without introducing heavy-governance or browser cost.
- **New or expanded test families**: Add focused coverage for the canonical inbox page, the workspace overview signal, positive and negative authorization, owner-only versus assignee-only visibility, and active-tenant prefilter behavior.
- **Fixture / helper cost impact**: Low to moderate. Tests need one workspace, multiple visible and hidden tenants, memberships, and findings in open and terminal states with explicit owner and assignee combinations.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: global-context-shell
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit assertions that active tenant context prefilters the inbox safely, that the tenant-prefilter empty-state CTA clears back to all visible tenants, and that row drilldown preserves a return path to the queue.
- **Reviewer handoff**: Reviewers must confirm that hidden-tenant findings never leak into rows or counts, owner-only findings are excluded from the personal queue, the queue remains read-first, and the workspace overview CTA lands on the same assigned-work truth shown by the inbox.
- **Budget / baseline / trend impact**: none
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/MyFindingsSignalTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See my assigned findings in one queue (Priority: P1)
As a tenant operator, I want one personal findings queue across my visible tenants so I can start work without searching tenant by tenant.
**Why this priority**: This is the core value. If the assignee still has to reconstruct personal work from multiple tenant pages, assignment remains metadata instead of workflow.
**Independent Test**: Can be fully tested by seeding multiple visible tenants with findings where the current user is assignee, owner-only, or unrelated, then verifying that the inbox shows only open assigned work from entitled tenants.
**Acceptance Scenarios**:
1. **Given** the current user is assigned open findings across multiple visible tenants, **When** the user opens the inbox, **Then** the page shows only those open assigned findings with tenant and urgency context.
2. **Given** an active tenant context exists, **When** the user opens the inbox, **Then** the queue is prefiltered to that tenant while keeping the personal assignment scope intact.
3. **Given** the current user is owner but not assignee on an open finding, **When** the user opens the inbox, **Then** that finding does not appear in the personal queue.
---
### User Story 2 - Prioritize urgent work and drill into the right finding (Priority: P1)
As a tenant operator, I want the queue to surface overdue or otherwise urgent assigned work first and take me straight into the correct finding detail, so I can act without reconstructing tenant context.
**Why this priority**: A queue that is complete but not prioritizable still slows operators down. The first work surface must make the next click obvious.
**Independent Test**: Can be fully tested by seeding overdue, reopened, high-severity, and ordinary assigned findings, opening the inbox, and verifying both urgency ordering and drilldown behavior.
**Acceptance Scenarios**:
1. **Given** the current user has overdue, reopened, and ordinary assigned findings, **When** the inbox renders, **Then** overdue findings are presented first, reopened non-overdue findings are presented ahead of ordinary assigned work, and high severity remains a filter and emphasis cue rather than a separate mandatory sort bucket in v1.
2. **Given** the operator opens a finding from the inbox, **When** the destination page loads, **Then** it opens the existing tenant finding detail for the correct tenant and preserves a return path to the inbox.
3. **Given** no visible assigned findings exist because the active tenant prefilter excludes them, **When** the operator opens the inbox, **Then** the empty state explains that the current tenant filter is narrowing the queue and offers one clear fallback CTA that clears the tenant prefilter back to all visible tenants.
---
### User Story 3 - Discover my assigned work from the workspace overview (Priority: P2)
As a workspace member, I want a small assigned-to-me signal on the workspace overview so I can tell immediately whether personal findings work exists before I choose a tenant or open another domain.
**Why this priority**: This is the smallest entry-point improvement that makes the new queue discoverable without turning `/admin` into a second findings page.
**Independent Test**: Can be fully tested by seeding assigned and unassigned work, opening the workspace overview, and verifying that the signal matches the inbox truth and drills into the queue in one click.
**Acceptance Scenarios**:
1. **Given** the current user has visible assigned open findings, **When** the user opens the workspace overview, **Then** the assigned-to-me signal shows the open count, overdue count, and one CTA into the inbox.
2. **Given** the current user has no visible assigned open findings, **When** the user opens the workspace overview, **Then** the signal remains calm and does not imply missing or hidden work.
### Edge Cases
- A finding may still reference the current user as assignee in a tenant the user is no longer entitled to; the inbox and overview counts must not show it.
- An active tenant prefilter may produce an empty queue while other visible tenants still contain assigned work; the empty state must explain the filter boundary instead of claiming no work exists anywhere.
- A finding can move to a terminal state in another browser tab while the inbox is open; refresh behavior must remove or de-emphasize it without implying it is still active assigned work.
- A finding may have the current user as both owner and assignee; it remains visible because assignee governs inbox inclusion.
- A finding may have the current user as owner only; it remains out of scope for this personal queue until a later accountability-focused slice explicitly defines that workload.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no new long-running work, and no new `OperationRun`. It introduces a derived read surface and a summary signal only. Existing tenant findings mutations, audit logging, and workflow confirmations remain governed by their current specs and surfaces.
**Constitution alignment (RBAC-UX):** The feature operates in the admin `/admin` plane for the canonical inbox and workspace overview, with tenant entitlement enforced per referenced finding before disclosure and before drilldown to `/admin/t/{tenant}/findings/{finding}`. Non-members or out-of-scope users continue to receive `404`. When workspace membership exists but workspace context has not yet been established, the feature follows the existing chooser or resume flow instead of returning `404`. In-scope users lacking the existing findings view capability continue to receive `403` on protected destinations. No raw capability strings, role checks, or second permission system may be introduced. Global search behavior is unchanged.
**Constitution alignment (UI-FIL-001):** The inbox and overview signal must use native Filament page, table, filter, stat, badge, and empty-state primitives or existing shared UI helpers. No local status language, ad hoc color system, or custom badge markup may be introduced for urgency or queue state.
**Constitution alignment (UI-NAMING-001):** The canonical operator-facing vocabulary is `My Findings`, `Assigned to me`, `Open my findings`, and `Open finding`. The page is about finding work, not a generic task engine. Terms such as `inbox item`, `work unit`, or `queue record` must not replace the finding domain language in primary labels.
**Constitution alignment (DECIDE-001):** The inbox is a primary decision surface because it answers the assignee's first daily workflow question in one place. The workspace signal is secondary because it points into that work rather than replacing it. Default-visible content must be enough to choose the next finding without reconstructing tenant context elsewhere.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** The inbox has exactly one primary inspect model: the finding. Row click is required. There is no redundant `View` action. Utility controls such as tenant filter and clear-filter affordances stay outside the row action lane. Dangerous lifecycle actions remain on the existing finding detail instead of being promoted into the queue. The workspace overview signal remains a summary drill-in surface with one explicit CTA.
**Constitution alignment (OPSURF-001):** Default-visible content on the inbox must stay operator-first: finding summary, tenant, severity, lifecycle state, and due urgency before diagnostics. The inbox itself is read-first and does not introduce a new mutation lane. Workspace and tenant scope must remain explicit through the page title, tenant column, active-tenant prefilter state, and drilldown routing.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct reuse of the tenant-local `My assigned` filter is insufficient because it does not create one cross-tenant personal-work surface. This feature still avoids new semantic infrastructure by deriving the inbox directly from existing assignee, lifecycle, severity, due-date, and entitlement truth. Tests must prove the business consequences: visibility, prioritization, tenant safety, and drilldown continuity.
### Functional Requirements
- **FR-001**: The system MUST provide a canonical personal findings inbox at `/admin/findings/my-work` for the current user.
- **FR-002**: The inbox MUST include only findings that are in an open workflow status, are currently assigned to the current user, and belong to tenants the current user is entitled to inspect.
- **FR-003**: Findings where the current user is owner but not assignee MUST NOT appear in the inbox.
- **FR-004**: The inbox MUST show at minimum the tenant, finding summary, severity, lifecycle status, due date or overdue state, and reopened cue for each visible row.
- **FR-005**: If the finding owner differs from the assignee, the inbox MUST show that owner context without turning owner into the inclusion rule for the queue.
- **FR-006**: The inbox MUST prioritize urgent work ahead of ordinary assigned work using a deterministic rule: overdue findings first, reopened non-overdue findings next, then remaining assigned work. Within each bucket, rows with due dates sort by `due_at` ascending, rows without due dates sort last, and remaining ties sort by finding ID descending. High severity is a supported filter and emphasis cue in v1, but it does not create a separate mandatory sort bucket.
- **FR-007**: The inbox MUST expose available filters for fixed personal assignment scope, tenant, overdue state, reopened state, and high-severity work. The personal assignment scope is fixed and cannot be removed in v1, and tenant filter options MUST be limited to visible capability-eligible tenants.
- **FR-008**: When an active tenant context exists, the inbox MUST apply that tenant as a default prefilter and allow the operator to clear that tenant prefilter to return to all visible tenants. If the tenant prefilter alone causes the queue to become empty while other visible tenants still contain assigned work, the empty-state CTA for that branch MUST clear the tenant prefilter.
- **FR-009**: Opening a row from the inbox MUST navigate to the existing tenant finding detail for the correct tenant and preserve a return path back to the inbox.
- **FR-010**: The workspace overview at `/admin` MUST expose a small assigned-to-me signal that shows the current user's visible assigned open count and overdue count and links into the inbox.
- **FR-011**: Inbox rows, overview counts, tenant filter values, and inbox summary counts MUST be derived only from findings the current user is entitled and currently authorized through the existing findings-view capability to inspect and MUST NOT leak hidden or capability-blocked tenants through counts, labels, filter options, or empty-state hints. Inbox summary counts MUST reflect the currently visible queue after active filters are applied.
- **FR-012**: When the current user has no visible assigned open findings, the inbox MUST render a calm empty state that explains there is no assigned work and offers one clear CTA. When active tenant context exists, the CTA opens that tenant's findings list. When no active tenant context exists, the CTA opens `/admin/choose-tenant` so the operator can establish tenant context before opening tenant findings.
- **FR-013**: The feature MUST reuse the existing finding lifecycle semantics from Spec 111 and the owner-versus-assignee semantics from Spec 219 and MUST NOT introduce new workflow states, new owner semantics, or a second permission system.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| My Findings inbox | `/admin/findings/my-work` | `Clear tenant filter` only when an active tenant prefilter is applied | Full-row open to `/admin/t/{tenant}/findings/{finding}` | none | none | `Clear tenant filter` when the tenant prefilter excludes rows; otherwise `Open tenant findings` with active tenant context or `Choose a tenant` | n/a | n/a | no direct audit because the surface is read-first | Action Surface Contract satisfied. One inspect model only, no redundant `View`, no dangerous queue actions, and no empty groups. |
| Workspace overview assigned-to-me signal | `/admin` workspace overview | none | Explicit summary CTA `Open my findings` | none | none | none | n/a | n/a | no | Summary drill-in only; not a second work surface |
### Key Entities *(include if feature involves data)*
- **Assigned finding**: An open tenant-owned finding where the current user is the assignee and is entitled to inspect the tenant that owns the finding.
- **My Findings inbox**: A derived canonical queue over assigned findings that emphasizes urgency and tenant-safe drilldown.
- **Assigned-to-me signal**: A derived workspace overview summary that counts visible assigned open findings and overdue assigned findings for the current user.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In acceptance review, an operator can determine within 10 seconds from `/admin` whether they have overdue assigned findings and open the inbox in one click.
- **SC-002**: 100% of covered automated tests show only current-user assigned open findings from visible tenants in the inbox and overview counts.
- **SC-003**: 100% of covered automated tests exclude owner-only, terminal, or hidden-tenant findings from the personal queue and assigned-to-me signal.
- **SC-004**: From the inbox, an operator can reach the target tenant finding detail in one interaction while preserving a clear return path to the queue.
## Assumptions
- Spec 219 is the authoritative contract for owner versus assignee semantics.
- The existing tenant finding detail remains the canonical mutation surface for finding lifecycle and assignment actions.
- The workspace overview at `/admin` can host one small additional summary signal without introducing a new landing-page architecture.
## Non-Goals
- Introduce a general cross-tenant findings register for all findings
- Introduce an owner-based accountability queue
- Add unassigned intake, claim flow, or team workboard behavior
- Add notification, escalation, or stale-work automation
- Add new bulk lifecycle actions or a second mutation lane on the inbox
## Dependencies
- Spec 111, Findings Workflow + SLA, remains the source of truth for open finding lifecycle, due-date behavior, and tenant findings workflow.
- Spec 219, Finding Ownership Semantics Clarification, remains the source of truth for assignee-based work versus owner-based accountability.

View File

@ -0,0 +1,209 @@
# Tasks: Findings Operator Inbox V1
**Input**: Design documents from `/specs/221-findings-operator-inbox/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/findings-operator-inbox.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes runtime behavior on new and existing Filament/Livewire surfaces, so Pest coverage must be added in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, and `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`, with route-alignment coverage extended in `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`.
**Operations**: No new `OperationRun`, audit flow, or long-running work is introduced. The inbox and overview signal remain DB-only, read-first surfaces.
**RBAC**: The inbox lives on the admin `/admin` plane and must preserve existing chooser or resume behavior when workspace context is missing, workspace-membership `404` semantics for out-of-scope users, capability-filtered tenant-safe row, filter, and count disclosure inside the queue and overview signal, and existing `404`/`403` behavior on drilldown to `/admin/t/{tenant}/findings/{finding}`.
**UI / Surface Guardrails**: `My Findings` remains the primary decision surface. The `/admin` assigned-to-me signal remains secondary and must not become a second queue.
**Filament UI Action Surfaces**: `MyFindingsInbox` gets one primary inspect model, one conditional `Clear tenant filter` header action, no row actions, no bulk actions, and one empty-state CTA. The workspace overview signal exposes only one CTA: `Open my findings`.
**Badges**: Existing finding severity, lifecycle, and due-state semantics remain authoritative. No page-local badge mappings are introduced.
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3`, because US2 extends the same inbox surface and US3 links to the inbox truth.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] The declared surface test profile or `standard-native-filament` relief is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Inbox Scaffolding)
**Purpose**: Prepare the new admin inbox files and focused regression suites used across all stories.
- [x] T001 [P] Create the new inbox page scaffold in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T002 [P] Create the new inbox page view scaffold in `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
- [x] T003 [P] Create focused Pest scaffolding in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, and `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`
**Checkpoint**: The new surface and focused test files exist and are ready for shared implementation work.
---
## Phase 2: Foundational (Blocking Route And Scope Seams)
**Purpose**: Establish the canonical admin route, page access shell, and base workspace/tenant scoping every story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 Register `MyFindingsInbox` in `apps/platform/app/Providers/Filament/AdminPanelProvider.php` and define the page slug and admin-plane access shell in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T005 Implement workspace-membership gating, visible-tenant resolution, capability-filtered row and count disclosure, and eager-loaded base queue scoping in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T006 Add foundational admin-plane page-entry coverage for missing workspace chooser or resume behavior and non-member `404` behavior in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
**Checkpoint**: The canonical inbox route exists, the page is workspace-scoped, and tenant-safe base access rules are covered.
---
## Phase 3: User Story 1 - See My Assigned Findings In One Queue (Priority: P1) 🎯 MVP
**Goal**: Give the current user one trustworthy cross-tenant queue for visible assigned open findings.
**Independent Test**: Seed multiple visible and hidden tenants with assigned, owner-only, unrelated, and terminal findings, then verify `/admin/findings/my-work` shows only open assigned work from entitled tenants.
### Tests for User Story 1
- [x] T007 [P] [US1] Add assigned-versus-owner, owner-context rendering, open-versus-terminal, entitled-versus-capability-eligible queue visibility, full available-filters contract coverage for fixed assignee scope plus tenant, overdue, reopened, and high-severity filters, applied-scope and summary-count truth, and zero-visible-work empty-state coverage in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`
- [x] T008 [P] [US1] Add member-without-capability disclosure boundaries, hidden-tenant suppression, and protected-destination coverage in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
### Implementation for User Story 1
- [x] T009 [US1] Implement the fixed `assignee = current user` and open-status queue query in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T010 [US1] Implement tenant, finding summary, severity, lifecycle status, due-state, reopened cue, owner context, and calm empty-state rendering with branch-specific CTAs that clear the tenant prefilter when it alone excludes rows or otherwise open active-tenant findings or `/admin/choose-tenant` when no tenant context exists in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
- [x] T011 [US1] Implement the available-filters contract with fixed assignee scope, capability-safe tenant filter options, active-tenant default prefilter sync, applied-scope metadata, inbox summary counts, and the conditional `Clear tenant filter` header action in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
**Checkpoint**: User Story 1 is independently functional and the inbox answers “what is assigned to me right now?” without tenant hopping.
---
## Phase 4: User Story 2 - Prioritize Urgent Work And Drill Into The Right Finding (Priority: P1)
**Goal**: Make the queue surface urgent work first and preserve a clear return path after opening the tenant finding detail.
**Independent Test**: Seed overdue, reopened, high-severity, and ordinary assigned findings, open the inbox, verify urgency ordering and filters, then open a row and confirm tenant finding detail plus inbox return continuity.
### Tests for User Story 2
- [x] T012 [P] [US2] Add deterministic overdue-then-reopened ordering with due-date and finding-ID tie-break coverage, reopened/high-severity filters, and tenant-prefilter empty-state CTA coverage in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`
- [x] T013 [P] [US2] Add queue-to-detail continuity and protected-destination coverage in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
### Implementation for User Story 2
- [x] T014 [US2] Implement tenant, overdue, reopened, and high-severity filters plus deterministic urgency sorting of overdue first, reopened next, then ordinary findings, with due-date ascending, undated rows last, and finding-ID tie-breaks in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T015 [US2] Implement inbox row drilldown URLs with `CanonicalNavigationContext` in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T016 [US2] Preserve `Back to my findings` continuity on tenant finding detail in `apps/platform/app/Filament/Resources/FindingResource.php`
**Checkpoint**: User Story 2 is independently functional and the queue now highlights urgent work and lands on the correct finding detail with continuity preserved.
---
## Phase 5: User Story 3 - Discover My Assigned Work From The Workspace Overview (Priority: P2)
**Goal**: Make `/admin` show whether visible assigned findings work exists and link into the canonical inbox in one click.
**Independent Test**: Seed assigned and unassigned work across visible and hidden tenants, open `/admin`, and verify the assigned-to-me signal matches inbox truth, stays calm when appropriate, and drills into `/admin/findings/my-work`.
### Tests for User Story 3
- [x] T017 [P] [US3] Add open-count, overdue-count, calm-state, hidden-tenant suppression, and capability-safe count suppression coverage in `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`
- [x] T018 [P] [US3] Extend inbox CTA and route-alignment coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
### Implementation for User Story 3
- [x] T019 [US3] Add the `my_findings_signal` payload with visible capability-safe open and overdue counts to `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [x] T020 [US3] Render the `Assigned to me` summary block and `Open my findings` CTA in `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`
**Checkpoint**: User Story 3 is independently functional and the workspace overview exposes the same personal-work truth as the inbox.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish guardrail alignment, formatting, and focused validation across the full feature.
- [x] T021 Review operator-facing copy, action-surface discipline, and “signal not second queue” guardrails in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, and `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`
- [x] T022 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [x] T023 Run the focused verification workflow from `specs/221-findings-operator-inbox/quickstart.md` against `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and prepares the new page, view, and focused Pest files.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the canonical route and base access shell exist.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the recommended MVP cut.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same inbox query, filter, and drilldown surface.
- **User Story 3 (Phase 5)**: Depends on User Story 1 because the workspace overview signal must link to established inbox truth.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1**: No dependencies beyond Foundational.
- **US2**: Builds directly on the inbox surface introduced in US1.
- **US3**: Depends on the canonical inbox route and queue truth from US1, but not on the urgency and detail work from US2.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation is considered complete.
- Keep page-query and authorization behavior in `MyFindingsInbox.php` authoritative before adjusting Blade copy.
- Finish story-level verification before moving to the next priority slice.
### Parallel Opportunities
- `T001`, `T002`, and `T003` can run in parallel during Setup.
- `T007` and `T008` can run in parallel for User Story 1.
- `T012` and `T013` can run in parallel for User Story 2.
- `T017` and `T018` can run in parallel for User Story 3.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T007 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php
T008 apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T012 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php
T013 apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T017 apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php
T018 apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate the inbox against the focused US1 tests before widening the slice.
### Incremental Delivery
1. Ship US1 to establish the canonical personal queue.
2. Add US2 to prioritize work and preserve detail continuity.
3. Add US3 to make the queue discoverable from `/admin`.
4. Finish with copy review, formatting, and the focused verification pack.
### Parallel Team Strategy
1. One contributor can scaffold the page and view while another prepares the focused Pest suites.
2. After Foundational work lands, one contributor can drive inbox visibility and another can harden authorization boundaries.
3. Once US1 is stable, overview signal work can proceed separately from urgency-ordering refinements if needed.
---
## Notes
- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared.
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.