Compare commits

...

2 Commits

Author SHA1 Message Date
81bb5f42c7 feat: add findings operator inbox (#258)
Some checks failed
Main Confidence / confidence (push) Failing after 55s
## Summary
- add the canonical admin-plane `My Findings` inbox at `/admin/findings/my-work`
- add the workspace overview `Assigned to me` signal and inbox-to-detail continuity
- add focused Pest coverage plus the full Spec 221 artifact bundle

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php tests/Feature/Dashboard/MyFindingsSignalTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
- integrated-browser smoke completed against the browser-facing `tenantatlas` runtime, including seeded positive-path and negative-path checks plus fixture cleanup

## Filament v5 Guardrails
- Livewire v4.0+ compliant
- panel provider registration remains in `apps/platform/bootstrap/providers.php`
- global search behavior is unchanged; `FindingResource` already has a View page and the new inbox is a custom page, not a searchable resource
- no destructive actions were introduced on the inbox or overview signal
- no new assets were added; the existing deploy step for `cd apps/platform && php artisan filament:assets` remains unchanged
- coverage includes the new inbox page, authorization boundaries, the workspace overview signal, and the overview CTA regression

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #258
2026-04-21 09:19:54 +00:00
bd06b479e1 feat: add governance run summaries (#257)
Some checks failed
Main Confidence / confidence (push) Failing after 43s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- add the Spec 220 governance run diagnostic summary seam and wire it through the canonical operation run detail presenter
- render summary-first decision guidance for covered governance run families while keeping technical diagnostics secondary
- add focused Pest coverage, spec artifacts, and complete the integrated-browser smoke validation for canonical run detail

## Testing
- cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php
- integrated browser smoke pass on localhost:8081 covering summary-first hierarchy, zero-output runs, multi-cause runs, cross-family parity, workspace-wide visibility, and deny-as-not-found tenant safety

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #257
2026-04-20 20:46:09 +00:00
44 changed files with 6329 additions and 178 deletions

View File

@ -222,6 +222,10 @@ ## Active Technologies
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure) - Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics) - PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics)
- 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) - 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) - PHP 8.4.15 (feat/005-bulk-operations)
@ -256,9 +260,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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 - 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`
- 216-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### 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

@ -246,21 +246,10 @@ public function blockedExecutionBanner(): ?array
return null; return null;
} }
$operatorExplanation = $this->governanceOperatorExplanation();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $operatorExplanation instanceof OperatorExplanationPattern
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
]))
: ($reasonEnvelope?->toBodyLines(false) ?? [
$this->surfaceFailureDetail() ?? 'The queued operation was refused before side effects could begin.',
]);
return [ return [
'tone' => 'amber', 'tone' => 'amber',
'title' => 'Blocked by prerequisite', 'title' => 'Blocked by prerequisite',
'body' => implode(' ', array_values(array_unique($lines))), 'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.',
]; ];
} }

View File

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

View File

@ -280,16 +280,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
: null; : null;
$artifactTruth = static::artifactTruthEnvelope($record); $artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation; $operatorExplanation = $artifactTruth?->operatorExplanation;
$diagnosticSummary = OperationUxPresenter::governanceDiagnosticSummary($record);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail'); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation); $primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$decisionNextStep = $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? [
'text' => $diagnosticSummary->nextActionText,
'source' => $diagnosticSummary->nextActionCategory,
'secondaryGuidance' => $primaryNextStep['secondaryGuidance'],
]
: $primaryNextStep;
$restoreContinuation = static::restoreContinuation($record); $restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups( $supportingGroups = static::supportingGroups(
record: $record, record: $record,
factory: $factory, factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle, referencedTenantLifecycle: $referencedTenantLifecycle,
diagnosticSummary: $diagnosticSummary,
operatorExplanation: $operatorExplanation, operatorExplanation: $operatorExplanation,
reasonEnvelope: $reasonEnvelope, reasonEnvelope: $reasonEnvelope,
primaryNextStep: $primaryNextStep, primaryNextStep: $decisionNextStep,
); );
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context') $builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
@ -307,49 +316,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.', descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
)) ))
->decisionZone($factory->decisionZone( ->decisionZone($factory->decisionZone(
facts: array_values(array_filter([ facts: static::decisionFacts(
$factory->keyFact( factory: $factory,
'Execution state', record: $record,
$statusSpec->label, statusSpec: $statusSpec,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), outcomeSpec: $outcomeSpec,
), artifactTruth: $artifactTruth,
$factory->keyFact( operatorExplanation: $operatorExplanation,
'Outcome', restoreContinuation: $restoreContinuation,
$outcomeSpec->label, diagnosticSummary: $diagnosticSummary,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$primaryNextStep['text'],
$primaryNextStep['source'],
$primaryNextStep['secondaryGuidance'],
), ),
description: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.', primaryNextStep: $factory->primaryNextStep(
$decisionNextStep['text'],
$decisionNextStep['source'],
$decisionNextStep['secondaryGuidance'],
'Primary next step',
),
description: $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? 'Start here to see what happened, how reliable the resulting artifact is, what was affected, and the one next step.'
: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.',
compactCounts: $summaryLine !== null compactCounts: $summaryLine !== null
? $factory->countPresentation(summaryLine: $summaryLine) ? $factory->countPresentation(summaryLine: $summaryLine)
: null, : null,
@ -550,6 +535,7 @@ private static function supportingGroups(
OperationRun $record, OperationRun $record,
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle, ?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
?OperatorExplanationPattern $operatorExplanation, ?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope, ?ReasonResolutionEnvelope $reasonEnvelope,
array $primaryNextStep, array $primaryNextStep,
@ -559,6 +545,21 @@ private static function supportingGroups(
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope); $reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
$guidanceItems = array_values(array_filter([ $guidanceItems = array_values(array_filter([
...array_map(
static fn (array $fact): array => $factory->keyFact(
(string) ($fact['label'] ?? 'Summary detail'),
(string) ($fact['value'] ?? '—'),
is_string($fact['hint'] ?? null) ? $fact['hint'] : null,
tone: match ($fact['emphasis'] ?? null) {
'blocked' => 'danger',
'caution' => 'warning',
default => null,
},
),
$diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? array_values(array_filter($diagnosticSummary->secondaryFacts, 'is_array'))
: [],
),
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null $operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement) ? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null, : null,
@ -811,6 +812,8 @@ private static function guidanceLabel(string $source): string
private static function artifactTruthFact( private static function artifactTruthFact(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ArtifactTruthEnvelope $artifactTruth, ?ArtifactTruthEnvelope $artifactTruth,
?string $hintOverride = null,
bool $preferOverride = false,
): ?array { ): ?array {
if (! $artifactTruth instanceof ArtifactTruthEnvelope) { if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
return null; return null;
@ -823,19 +826,138 @@ private static function artifactTruthFact(
$badge = $outcome->primaryBadge; $badge = $outcome->primaryBadge;
return $factory->keyFact( return $factory->keyFact(
'Outcome', 'Artifact impact',
$outcome->primaryLabel, $outcome->primaryLabel,
$outcome->primaryReason, $preferOverride ? $hintOverride : ($hintOverride ?? $outcome->primaryReason),
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor), $factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
); );
} }
/**
* @return list<array<string, mixed>>
*/
private static function decisionFacts(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
OperationRun $record,
\App\Support\Badges\BadgeSpec $statusSpec,
\App\Support\Badges\BadgeSpec $outcomeSpec,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
mixed $restoreContinuation,
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
): array {
if (! $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary) {
return array_values(array_filter([
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$outcomeSpec->label,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
]));
}
$facts = [
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$diagnosticSummary->executionOutcomeLabel,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact(
$factory,
$artifactTruth,
static::detailHintUnlessDuplicate(
$diagnosticSummary->headline,
$artifactTruth?->primaryExplanation,
$diagnosticSummary->primaryReason,
),
true,
),
$factory->keyFact(
'Dominant cause',
$diagnosticSummary->dominantCause['label'],
$diagnosticSummary->primaryReason,
tone: in_array($diagnosticSummary->nextActionCategory, ['refresh_prerequisite_data', 'review_scope_or_ambiguous_matches'], true)
? 'warning'
: (in_array($diagnosticSummary->nextActionCategory, ['retry_later', 'no_further_action'], true) ? null : 'danger'),
),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$diagnosticSummary->primaryReason,
),
tone: match ($operatorExplanation->trustworthinessLevel->value) {
'unusable' => 'danger',
'diagnostic_only', 'limited_confidence' => 'warning',
default => 'success',
},
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
];
if (is_array($diagnosticSummary->affectedScaleCue)) {
$source = str_replace('_', ' ', (string) ($diagnosticSummary->affectedScaleCue['source'] ?? 'recorded detail'));
$facts[] = $factory->keyFact(
(string) ($diagnosticSummary->affectedScaleCue['label'] ?? 'Affected scale'),
(string) ($diagnosticSummary->affectedScaleCue['value'] ?? 'Recorded detail is available.'),
'Backed by '.$source.'.',
);
}
return array_values(array_filter($facts));
}
private static function decisionAttentionNote(OperationRun $record): ?string private static function decisionAttentionNote(OperationRun $record): ?string
{ {
return OperationUxPresenter::decisionAttentionNote($record); return OperationUxPresenter::decisionAttentionNote($record);
} }
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string private static function detailHintUnlessDuplicate(?string $hint, ?string ...$duplicates): ?string
{ {
$normalizedHint = static::normalizeDetailText($hint); $normalizedHint = static::normalizeDetailText($hint);
@ -843,8 +965,10 @@ private static function detailHintUnlessDuplicate(?string $hint, ?string $duplic
return null; return null;
} }
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) { foreach ($duplicates as $duplicate) {
return null; if ($normalizedHint === static::normalizeDetailText($duplicate)) {
return null;
}
} }
return trim($hint ?? ''); return trim($hint ?? '');

View File

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

View File

@ -76,6 +76,12 @@ public function handle(Request $request, Closure $next): Response
return $next($request); return $next($request);
} }
if ($path === '/admin/findings/my-work') {
$this->configureNavigationForRequest($panel);
return $next($request);
}
if ($path === '/admin/operations/'.$request->route('run')) { if ($path === '/admin/operations/'.$request->route('run')) {
$this->configureNavigationForRequest($panel); $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/w/')
|| str_starts_with($path, '/admin/workspaces') || str_starts_with($path, '/admin/workspaces')
|| str_starts_with($path, '/admin/operations') || 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); $this->configureNavigationForRequest($panel);
@ -251,6 +257,10 @@ private function adminPathRequiresTenantSelection(string $path): bool
return false; 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; return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
} }
} }

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use InvalidArgumentException;
final readonly class GovernanceRunDiagnosticSummary
{
/**
* @param array{label: string, value: string, source: string, confidence?: string}|null $affectedScaleCue
* @param array{
* code: ?string,
* label: string,
* explanation: string
* } $dominantCause
* @param list<array{
* code: ?string,
* label: string,
* explanation: string
* }> $secondaryCauses
* @param list<array{
* label: string,
* value: string,
* hint?: ?string,
* emphasis?: string
* }> $secondaryFacts
*/
public function __construct(
public string $headline,
public string $executionOutcomeLabel,
public string $artifactImpactLabel,
public string $primaryReason,
public ?array $affectedScaleCue,
public string $nextActionCategory,
public string $nextActionText,
public array $dominantCause,
public array $secondaryCauses = [],
public array $secondaryFacts = [],
public bool $diagnosticsAvailable = false,
) {
foreach ([
'headline' => $this->headline,
'executionOutcomeLabel' => $this->executionOutcomeLabel,
'artifactImpactLabel' => $this->artifactImpactLabel,
'primaryReason' => $this->primaryReason,
'nextActionCategory' => $this->nextActionCategory,
'nextActionText' => $this->nextActionText,
] as $field => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException("Governance run summaries require {$field}.");
}
}
if (trim((string) ($this->dominantCause['label'] ?? '')) === '' || trim((string) ($this->dominantCause['explanation'] ?? '')) === '') {
throw new InvalidArgumentException('Governance run summaries require a dominant cause label and explanation.');
}
}
/**
* @return array{
* headline: string,
* executionOutcomeLabel: string,
* artifactImpactLabel: string,
* primaryReason: string,
* affectedScaleCue: array{label: string, value: string, source: string, confidence?: string}|null,
* nextActionCategory: string,
* nextActionText: string,
* dominantCause: array{code: ?string, label: string, explanation: string},
* secondaryCauses: list<array{code: ?string, label: string, explanation: string}>,
* secondaryFacts: list<array{label: string, value: string, hint?: ?string, emphasis?: string}>,
* diagnosticsAvailable: bool
* }
*/
public function toArray(): array
{
return [
'headline' => $this->headline,
'executionOutcomeLabel' => $this->executionOutcomeLabel,
'artifactImpactLabel' => $this->artifactImpactLabel,
'primaryReason' => $this->primaryReason,
'affectedScaleCue' => $this->affectedScaleCue,
'nextActionCategory' => $this->nextActionCategory,
'nextActionText' => $this->nextActionText,
'dominantCause' => $this->dominantCause,
'secondaryCauses' => $this->secondaryCauses,
'secondaryFacts' => $this->secondaryFacts,
'diagnosticsAvailable' => $this->diagnosticsAvailable,
];
}
}

View File

@ -0,0 +1,913 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationCatalog;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
final class GovernanceRunDiagnosticSummaryBuilder
{
public function __construct(
private readonly ArtifactTruthPresenter $artifactTruthPresenter,
private readonly ReasonPresenter $reasonPresenter,
) {}
public function build(
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth = null,
?OperatorExplanationPattern $operatorExplanation = null,
?ReasonResolutionEnvelope $reasonEnvelope = null,
): ?GovernanceRunDiagnosticSummary {
if (! $run->supportsOperatorExplanation()) {
return null;
}
$artifactTruth ??= $this->artifactTruthPresenter->forOperationRun($run);
$operatorExplanation ??= $artifactTruth?->operatorExplanation;
$reasonEnvelope ??= $this->reasonPresenter->forOperationRun($run, 'run_detail');
if (! $artifactTruth instanceof ArtifactTruthEnvelope && ! $operatorExplanation instanceof OperatorExplanationPattern) {
return null;
}
$canonicalType = OperationCatalog::canonicalCode((string) $run->type);
$context = is_array($run->context) ? $run->context : [];
$counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []);
$causeCandidates = $this->rankCauseCandidates($canonicalType, $run, $artifactTruth, $operatorExplanation, $reasonEnvelope, $context);
$dominantCause = $causeCandidates[0] ?? $this->fallbackCause($artifactTruth, $operatorExplanation, $reasonEnvelope);
$secondaryCauses = array_values(array_slice($causeCandidates, 1));
$artifactImpactLabel = $this->artifactImpactLabel($artifactTruth, $operatorExplanation);
$headline = $this->headline($canonicalType, $run, $artifactTruth, $operatorExplanation, $dominantCause, $context, $counts);
$primaryReason = $this->primaryReason($dominantCause, $artifactTruth, $operatorExplanation, $reasonEnvelope);
$nextActionCategory = $this->nextActionCategory($canonicalType, $run, $reasonEnvelope, $operatorExplanation, $context);
$nextActionText = $this->nextActionText($artifactTruth, $operatorExplanation, $reasonEnvelope);
$affectedScaleCue = $this->affectedScaleCue($canonicalType, $run, $artifactTruth, $operatorExplanation, $context, $counts);
$secondaryFacts = $this->secondaryFacts($artifactTruth, $operatorExplanation, $secondaryCauses, $nextActionCategory, $nextActionText);
return new GovernanceRunDiagnosticSummary(
headline: $headline,
executionOutcomeLabel: $this->executionOutcomeLabel($run),
artifactImpactLabel: $artifactImpactLabel,
primaryReason: $primaryReason,
affectedScaleCue: $affectedScaleCue,
nextActionCategory: $nextActionCategory,
nextActionText: $nextActionText,
dominantCause: [
'code' => $dominantCause['code'] ?? null,
'label' => $dominantCause['label'],
'explanation' => $dominantCause['explanation'],
],
secondaryCauses: array_map(
static fn (array $cause): array => [
'code' => $cause['code'] ?? null,
'label' => $cause['label'],
'explanation' => $cause['explanation'],
],
$secondaryCauses,
),
secondaryFacts: $secondaryFacts,
diagnosticsAvailable: (bool) ($operatorExplanation?->diagnosticsAvailable ?? false),
);
}
private function executionOutcomeLabel(OperationRun $run): string
{
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, (string) $run->outcome);
return $spec->label !== 'Unknown'
? $spec->label
: ucfirst(str_replace('_', ' ', trim((string) $run->outcome)));
}
private function artifactImpactLabel(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): string {
if ($artifactTruth instanceof ArtifactTruthEnvelope && trim($artifactTruth->primaryLabel) !== '') {
return $artifactTruth->primaryLabel;
}
if ($operatorExplanation instanceof OperatorExplanationPattern) {
return $operatorExplanation->trustworthinessLabel();
}
return 'Result needs review';
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function headline(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $dominantCause,
array $context,
array $counts,
): string {
return match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureHeadline($artifactTruth, $context, $counts, $operatorExplanation),
'baseline.compare' => $this->baselineCompareHeadline($artifactTruth, $context, $counts, $operatorExplanation),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotHeadline($artifactTruth, $operatorExplanation),
'tenant.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation),
'tenant.review_pack.generate' => $this->reviewPackHeadline($artifactTruth, $dominantCause, $operatorExplanation),
default => $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'This governance run needs review before it can be relied on.',
};
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function baselineCaptureHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $context,
array $counts,
?OperatorExplanationPattern $operatorExplanation,
): string {
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
if ($subjectsTotal === 0) {
return 'No baseline was captured because no governed subjects were ready.';
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'The baseline capture started, but more evidence still needs to be collected.';
}
if ($gapCount > 0) {
return 'The baseline capture finished, but evidence gaps still limit the snapshot.';
}
if (($artifactTruth?->artifactExistence ?? null) === 'created_but_not_usable') {
return 'The baseline capture finished without a usable snapshot.';
}
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
return 'The baseline capture finished without producing a decision-grade snapshot.';
}
return $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The baseline capture needs review before it can be used.';
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function baselineCompareHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $context,
array $counts,
?OperatorExplanationPattern $operatorExplanation,
): string {
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
$proof = data_get($context, 'baseline_compare.coverage.proof');
$resumeToken = data_get($context, 'baseline_compare.resume_token');
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
return 'The compare finished, but ambiguous subject matching limited the result.';
}
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
return 'The compare finished, but a compare strategy failure kept the result incomplete.';
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'The compare finished, but evidence capture still needs to resume before the result is complete.';
}
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
return 'The compare finished, but no decision-grade result is available yet.';
}
if ($proof === false) {
return 'The compare finished, but missing coverage proof suppressed the normal result.';
}
return $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The compare needs follow-up before it can be treated as complete.';
}
private function evidenceSnapshotHeadline(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->freshnessState === 'stale' => 'The snapshot finished processing, but its evidence basis is already stale.',
$artifactTruth?->contentState === 'partial' => 'The snapshot finished processing, but its evidence basis is incomplete.',
$artifactTruth?->contentState === 'missing_input' => 'The snapshot finished processing without a complete evidence basis.',
default => $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The evidence snapshot needs review before it is relied on.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function reviewComposeHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $dominantCause,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale'
=> 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.',
$artifactTruth?->contentState === 'partial'
=> 'The review was generated, but required sections are still incomplete.',
$artifactTruth?->freshnessState === 'stale'
=> 'The review was generated, but it relies on stale evidence.',
default => $operatorExplanation?->headline
?? $dominantCause['explanation']
?? 'The review needs follow-up before it should guide action.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function reviewPackHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $dominantCause,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->publicationReadiness === 'blocked'
=> 'The pack did not produce a shareable artifact yet.',
$artifactTruth?->publicationReadiness === 'internal_only'
=> 'The pack finished, but it should stay internal until the source review is refreshed.',
default => $operatorExplanation?->headline
?? $dominantCause['explanation']
?? 'The review pack needs follow-up before it is shared.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function primaryReason(
array $dominantCause,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): string {
return $dominantCause['explanation']
?? $operatorExplanation?->dominantCauseExplanation
?? $reasonEnvelope?->shortExplanation
?? $artifactTruth?->primaryExplanation
?? $operatorExplanation?->reliabilityStatement
?? 'TenantPilot recorded diagnostic detail for this run.';
}
/**
* @param array<string, mixed> $context
*/
private function nextActionCategory(
string $canonicalType,
OperationRun $run,
?ReasonResolutionEnvelope $reasonEnvelope,
?OperatorExplanationPattern $operatorExplanation,
array $context,
): string {
if ($reasonEnvelope?->actionability === 'retryable_transient' || $operatorExplanation?->nextActionCategory === 'retry_later') {
return 'retry_later';
}
if (in_array($canonicalType, ['baseline.capture', 'baseline.compare'], true)) {
$resumeToken = $canonicalType === 'baseline.capture'
? data_get($context, 'baseline_capture.resume_token')
: data_get($context, 'baseline_compare.resume_token');
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'resume_capture_or_generation';
}
}
$reasonCode = (string) (data_get($context, 'baseline_compare.reason_code') ?? $reasonEnvelope?->internalCode ?? '');
if (in_array($reasonCode, [
BaselineCompareReasonCode::AmbiguousSubjects->value,
BaselineCompareReasonCode::UnsupportedSubjects->value,
], true)) {
return 'review_scope_or_ambiguous_matches';
}
if ($canonicalType === 'baseline.capture' && $this->intValue(data_get($context, 'baseline_capture.subjects_total')) === 0) {
return 'refresh_prerequisite_data';
}
if ($operatorExplanation?->nextActionCategory === 'none' || trim((string) $operatorExplanation?->nextActionText) === 'No action needed') {
return 'no_further_action';
}
if (
$reasonEnvelope?->actionability === 'prerequisite_missing'
|| in_array($canonicalType, ['tenant.evidence.snapshot.generate', 'tenant.review.compose', 'tenant.review_pack.generate'], true)
) {
return 'refresh_prerequisite_data';
}
return 'manually_validate';
}
private function nextActionText(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): string {
$text = $operatorExplanation?->nextActionText
?? $artifactTruth?->nextStepText()
?? $reasonEnvelope?->firstNextStep()?->label
?? 'No action needed';
return trim(rtrim($text, '.')).'.';
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function affectedScaleCue(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $context,
array $counts,
): ?array {
return match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureScaleCue($context, $counts),
'baseline.compare' => $this->baselineCompareScaleCue($context, $counts),
'tenant.evidence.snapshot.generate' => $this->countDescriptorScaleCue($operatorExplanation?->countDescriptors ?? [], ['Missing dimensions', 'Stale dimensions', 'Evidence dimensions']),
'tenant.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
'tenant.review_pack.generate' => $this->reviewPackScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
default => $this->summaryCountsScaleCue($counts),
};
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function baselineCaptureScaleCue(array $context, array $counts): ?array
{
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
if ($gapCount > 0) {
return [
'label' => 'Affected subjects',
'value' => "{$gapCount} governed subjects still need evidence follow-up.",
'source' => 'context',
'confidence' => 'exact',
];
}
if ($subjectsTotal >= 0) {
return [
'label' => 'Capture scope',
'value' => "{$subjectsTotal} governed subjects were in the recorded capture scope.",
'source' => 'context',
'confidence' => 'exact',
];
}
return $this->summaryCountsScaleCue($counts);
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function baselineCompareScaleCue(array $context, array $counts): ?array
{
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
$subjectsTotal = $this->intValue(data_get($context, 'baseline_compare.subjects_total'));
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
if ($gapCount > 0) {
return [
'label' => 'Affected subjects',
'value' => "{$gapCount} governed subjects still have evidence gaps.",
'source' => 'context',
'confidence' => 'exact',
];
}
if ($uncoveredTypes !== []) {
$count = count($uncoveredTypes);
return [
'label' => 'Coverage scope',
'value' => "{$count} policy types were left without proven compare coverage.",
'source' => 'context',
'confidence' => 'bounded',
];
}
if ($subjectsTotal > 0) {
return [
'label' => 'Compare scope',
'value' => "{$subjectsTotal} governed subjects were in scope for this compare run.",
'source' => 'context',
'confidence' => 'exact',
];
}
return $this->summaryCountsScaleCue($counts);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function reviewScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
{
if ($artifactTruth?->contentState === 'partial') {
$sections = $this->findCountDescriptor($countDescriptors, 'Sections');
if ($sections instanceof CountDescriptor) {
return [
'label' => 'Review sections',
'value' => "{$sections->value} sections were recorded and still need review for completeness.",
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return [
'label' => 'Review sections',
'value' => 'Required review sections are still incomplete.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
if ($artifactTruth?->freshnessState === 'stale') {
return [
'label' => 'Evidence freshness',
'value' => 'The source evidence is stale for at least part of this review.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return $this->countDescriptorScaleCue($countDescriptors, ['Sections', 'Findings']);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function reviewPackScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
{
if ($artifactTruth?->publicationReadiness === 'internal_only') {
return [
'label' => 'Sharing scope',
'value' => 'The pack is suitable for internal follow-up only in its current state.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return $this->countDescriptorScaleCue($countDescriptors, ['Reports', 'Findings']);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @param list<string> $preferredLabels
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function countDescriptorScaleCue(array $countDescriptors, array $preferredLabels): ?array
{
foreach ($preferredLabels as $label) {
$descriptor = $this->findCountDescriptor($countDescriptors, $label);
if (! $descriptor instanceof CountDescriptor || $descriptor->value <= 0) {
continue;
}
return [
'label' => $descriptor->label,
'value' => "{$descriptor->value} {$this->pluralizeDescriptor($descriptor)}.",
'source' => 'related_artifact_truth',
'confidence' => 'exact',
];
}
return null;
}
/**
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function summaryCountsScaleCue(array $counts): ?array
{
foreach (['total', 'processed', 'failed', 'items', 'finding_count'] as $key) {
$value = (int) ($counts[$key] ?? 0);
if ($value <= 0) {
continue;
}
return [
'label' => SummaryCountsNormalizer::label($key),
'value' => "{$value} recorded in the canonical run counters.",
'source' => 'summary_counts',
'confidence' => 'exact',
];
}
return null;
}
/**
* @param array<string, mixed> $context
* @return list<array{rank: int, code: ?string, label: string, explanation: string}>
*/
private function rankCauseCandidates(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
array $context,
): array {
$candidates = [];
$this->pushCandidate(
$candidates,
code: $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
label: $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel,
explanation: $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation,
rank: $this->reasonRank($reasonEnvelope, $operatorExplanation),
);
match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureCandidates($candidates, $context),
'baseline.compare' => $this->baselineCompareCandidates($candidates, $context),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotCandidates($candidates, $artifactTruth, $operatorExplanation),
'tenant.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth),
'tenant.review_pack.generate' => $this->reviewPackCandidates($candidates, $artifactTruth),
default => null,
};
usort($candidates, static function (array $left, array $right): int {
$rank = ($right['rank'] <=> $left['rank']);
if ($rank !== 0) {
return $rank;
}
return strcmp($left['label'], $right['label']);
});
return array_values(array_map(
static fn (array $candidate): array => [
'code' => $candidate['code'],
'label' => $candidate['label'],
'explanation' => $candidate['explanation'],
],
$candidates,
));
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function pushCandidate(array &$candidates, ?string $code, ?string $label, ?string $explanation, int $rank): void
{
$label = is_string($label) ? trim($label) : '';
$explanation = is_string($explanation) ? trim($explanation) : '';
if ($label === '' || $explanation === '') {
return;
}
foreach ($candidates as $candidate) {
if (($candidate['label'] ?? null) === $label && ($candidate['explanation'] ?? null) === $explanation) {
return;
}
}
$candidates[] = [
'code' => $code,
'label' => $label,
'explanation' => $explanation,
'rank' => $rank,
];
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $context
*/
private function baselineCaptureCandidates(array &$candidates, array $context): void
{
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
if ($subjectsTotal === 0) {
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
}
if ($gapCount > 0) {
$this->pushCandidate($candidates, 'baseline_capture_gaps', 'Evidence gaps remain', "{$gapCount} governed subjects still need evidence capture before the snapshot is complete.", 82);
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
$this->pushCandidate($candidates, 'baseline_capture_resume', 'Capture can resume', 'TenantPilot recorded a resume point because this capture could not finish in one pass.', 84);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $context
*/
private function baselineCompareCandidates(array &$candidates, array $context): void
{
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
$proof = data_get($context, 'baseline_compare.coverage.proof');
$resumeToken = data_get($context, 'baseline_compare.resume_token');
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
$this->pushCandidate($candidates, $reasonCode, 'Ambiguous matches', 'One or more governed subjects stayed ambiguous, so the compare result needs scope review.', 92);
}
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
$this->pushCandidate($candidates, $reasonCode, 'Compare strategy failed', 'A compare strategy failed while processing in-scope governed subjects.', 94);
}
if ($gapCount > 0) {
$this->pushCandidate($candidates, 'baseline_compare_gaps', 'Evidence gaps', "{$gapCount} governed subjects still have evidence gaps, so the compare output is incomplete.", 83);
}
if ($proof === false || $uncoveredTypes !== []) {
$count = count($uncoveredTypes);
$explanation = $count > 0
? "{$count} policy types were left without proven compare coverage."
: 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.';
$this->pushCandidate($candidates, 'coverage_unproven', 'Coverage proof missing', $explanation, 81);
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
$this->pushCandidate($candidates, 'baseline_compare_resume', 'Evidence capture needs to resume', 'The compare recorded a resume point because evidence capture did not finish in one pass.', 80);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<int, CountDescriptor> $countDescriptors
*/
private function evidenceSnapshotCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation): void
{
$countDescriptors = $operatorExplanation?->countDescriptors ?? [];
$missing = $this->findCountDescriptor($countDescriptors, 'Missing dimensions');
$stale = $this->findCountDescriptor($countDescriptors, 'Stale dimensions');
if ($missing instanceof CountDescriptor && $missing->value > 0) {
$this->pushCandidate($candidates, 'missing_dimensions', 'Missing dimensions', "{$missing->value} evidence dimensions are still missing from this snapshot.", 88);
}
if ($artifactTruth?->freshnessState === 'stale' || ($stale instanceof CountDescriptor && $stale->value > 0)) {
$value = $stale instanceof CountDescriptor && $stale->value > 0
? "{$stale->value} evidence dimensions are stale and should be refreshed."
: 'Part of the evidence basis is stale and should be refreshed before use.';
$this->pushCandidate($candidates, 'stale_evidence', 'Stale evidence basis', $value, 82);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function reviewComposeCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
{
if ($artifactTruth?->contentState === 'partial') {
$this->pushCandidate($candidates, 'review_missing_sections', 'Missing sections', 'Required review sections are still incomplete for this generated review.', 90);
}
if ($artifactTruth?->freshnessState === 'stale') {
$this->pushCandidate($candidates, 'review_stale_evidence', 'Stale evidence basis', 'The review relies on stale evidence and needs a refreshed evidence basis.', 86);
}
if ($artifactTruth?->publicationReadiness === 'blocked') {
$this->pushCandidate($candidates, 'review_blocked', 'Publication blocked', 'The review cannot move forward until its blocking prerequisites are cleared.', 95);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function reviewPackCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
{
if ($artifactTruth?->publicationReadiness === 'blocked') {
$this->pushCandidate($candidates, 'review_pack_blocked', 'Shareable pack not available', 'The pack did not produce a shareable artifact yet.', 94);
}
if ($artifactTruth?->publicationReadiness === 'internal_only') {
$this->pushCandidate($candidates, 'review_pack_internal_only', 'Internal-only outcome', 'The pack can support internal follow-up, but it should not be shared externally yet.', 80);
}
if ($artifactTruth?->freshnessState === 'stale') {
$this->pushCandidate($candidates, 'review_pack_stale_source', 'Source review is stale', 'The pack inherits stale review evidence and needs a refreshed source review.', 84);
}
if ($artifactTruth?->contentState === 'partial') {
$this->pushCandidate($candidates, 'review_pack_partial_source', 'Source review is incomplete', 'The pack inherits incomplete source review content and needs follow-up before sharing.', 86);
}
}
private function reasonRank(
?ReasonResolutionEnvelope $reasonEnvelope,
?OperatorExplanationPattern $operatorExplanation,
): int {
if ($reasonEnvelope?->actionability === 'retryable_transient') {
return 76;
}
return match ($operatorExplanation?->nextActionCategory) {
'fix_prerequisite' => 92,
'retry_later' => 76,
'none' => 40,
default => 85,
};
}
/**
* @param list<array{code: ?string, label: string, explanation: string}> $secondaryCauses
* @return list<array{
* label: string,
* value: string,
* hint?: ?string,
* emphasis?: string
* }>
*/
private function secondaryFacts(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $secondaryCauses,
string $nextActionCategory,
string $nextActionText,
): array {
$facts = [];
if ($operatorExplanation instanceof OperatorExplanationPattern) {
$facts[] = [
'label' => 'Result trust',
'value' => $operatorExplanation->trustworthinessLabel(),
'hint' => $this->deduplicateSecondaryFactHint(
$operatorExplanation->reliabilityStatement,
$operatorExplanation->dominantCauseExplanation,
$artifactTruth?->primaryExplanation,
),
'emphasis' => $this->emphasisFromTrust($operatorExplanation->trustworthinessLevel->value),
];
if ($operatorExplanation->evaluationResultLabel() !== '') {
$facts[] = [
'label' => 'Result meaning',
'value' => $operatorExplanation->evaluationResultLabel(),
'hint' => $operatorExplanation->coverageStatement,
'emphasis' => 'neutral',
];
}
}
if ($secondaryCauses !== []) {
$facts[] = [
'label' => 'Secondary causes',
'value' => implode(' · ', array_map(static fn (array $cause): string => $cause['label'], $secondaryCauses)),
'hint' => 'Additional contributing causes stay visible without replacing the dominant cause.',
'emphasis' => 'caution',
];
}
if ($artifactTruth?->relatedArtifactUrl === null && $nextActionCategory !== 'no_further_action') {
$facts[] = [
'label' => 'Related artifact access',
'value' => 'No related artifact link is available from this run.',
'emphasis' => 'neutral',
];
}
return $facts;
}
private function emphasisFromTrust(string $trust): string
{
return match ($trust) {
'unusable' => 'blocked',
'diagnostic_only', 'limited_confidence' => 'caution',
default => 'neutral',
};
}
private function deduplicateSecondaryFactHint(?string $hint, ?string ...$duplicates): ?string
{
$normalizedHint = $this->normalizeFactText($hint);
if ($normalizedHint === null) {
return null;
}
foreach ($duplicates as $duplicate) {
if ($normalizedHint === $this->normalizeFactText($duplicate)) {
return null;
}
}
return trim($hint ?? '');
}
private function fallbackCause(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): array {
return [
'code' => $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
'label' => $reasonEnvelope?->operatorLabel
?? $operatorExplanation?->dominantCauseLabel
?? $artifactTruth?->primaryLabel
?? 'Follow-up required',
'explanation' => $reasonEnvelope?->shortExplanation
?? $operatorExplanation?->dominantCauseExplanation
?? $artifactTruth?->primaryExplanation
?? 'TenantPilot recorded enough detail to keep this run out of an all-clear state.',
];
}
private function findCountDescriptor(array $countDescriptors, string $label): ?CountDescriptor
{
foreach ($countDescriptors as $descriptor) {
if ($descriptor instanceof CountDescriptor && $descriptor->label === $label) {
return $descriptor;
}
}
return null;
}
private function intValue(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function pluralizeDescriptor(CountDescriptor $descriptor): string
{
return match ($descriptor->label) {
'Missing dimensions' => 'evidence dimensions are missing',
'Stale dimensions' => 'evidence dimensions are stale',
'Evidence dimensions' => 'evidence dimensions were recorded',
'Sections' => 'sections were recorded',
'Reports' => 'reports were recorded',
'Findings' => 'findings were recorded',
default => strtolower($descriptor->label).' were recorded',
};
}
private function normalizeFactText(?string $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
if ($normalized === '') {
return null;
}
return mb_strtolower($normalized);
}
}

View File

@ -350,6 +350,16 @@ public static function governanceOperatorExplanation(OperationRun $run): ?Operat
return self::resolveGovernanceOperatorExplanation($run); return self::resolveGovernanceOperatorExplanation($run);
} }
public static function governanceDiagnosticSummary(OperationRun $run): ?GovernanceRunDiagnosticSummary
{
return self::resolveGovernanceDiagnosticSummary($run);
}
public static function governanceDiagnosticSummaryFresh(OperationRun $run): ?GovernanceRunDiagnosticSummary
{
return self::resolveGovernanceDiagnosticSummary($run, fresh: true);
}
public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern
{ {
return self::resolveGovernanceOperatorExplanation($run, fresh: true); return self::resolveGovernanceOperatorExplanation($run, fresh: true);
@ -492,6 +502,29 @@ private static function resolveGovernanceOperatorExplanation(OperationRun $run,
); );
} }
private static function resolveGovernanceDiagnosticSummary(OperationRun $run, bool $fresh = false): ?GovernanceRunDiagnosticSummary
{
if (! $run->supportsOperatorExplanation()) {
return null;
}
return self::memoizeExplanation(
run: $run,
variant: 'governance_diagnostic_summary',
resolver: fn (): ?GovernanceRunDiagnosticSummary => app(GovernanceRunDiagnosticSummaryBuilder::class)->build(
run: $run,
artifactTruth: $fresh
? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run)
: app(ArtifactTruthPresenter::class)->forOperationRun($run),
operatorExplanation: $fresh
? self::resolveGovernanceOperatorExplanation($run, fresh: true)
: self::resolveGovernanceOperatorExplanation($run),
reasonEnvelope: app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'),
),
fresh: $fresh,
);
}
private static function memoizeGuidance( private static function memoizeGuidance(
OperationRun $run, OperationRun $run,
string $variant, string $variant,

View File

@ -21,6 +21,7 @@ public function __construct(
public ?string $operatorLabel, public ?string $operatorLabel,
public ?string $shortExplanation, public ?string $shortExplanation,
public ?string $diagnosticCode, public ?string $diagnosticCode,
public ?string $actionability,
public string $trustImpact, public string $trustImpact,
public ?string $absencePattern, public ?string $absencePattern,
public array $nextSteps = [], public array $nextSteps = [],
@ -43,6 +44,7 @@ public static function fromReasonResolutionEnvelope(
operatorLabel: $reason->operatorLabel, operatorLabel: $reason->operatorLabel,
shortExplanation: $reason->shortExplanation, shortExplanation: $reason->shortExplanation,
diagnosticCode: $reason->diagnosticCode(), diagnosticCode: $reason->diagnosticCode(),
actionability: $reason->actionability,
trustImpact: $reason->trustImpact, trustImpact: $reason->trustImpact,
absencePattern: $reason->absencePattern, absencePattern: $reason->absencePattern,
nextSteps: array_values(array_map( nextSteps: array_values(array_map(
@ -79,7 +81,8 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
internalCode: $this->reasonCode ?? 'artifact_truth_reason', internalCode: $this->reasonCode ?? 'artifact_truth_reason',
operatorLabel: $this->operatorLabel ?? 'Operator attention required', operatorLabel: $this->operatorLabel ?? 'Operator attention required',
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.', shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing', actionability: $this->actionability
?? ($this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing'),
nextSteps: array_map( nextSteps: array_map(
static fn (string $label): NextStepOption => NextStepOption::instruction($label), static fn (string $label): NextStepOption => NextStepOption::instruction($label),
$this->nextSteps, $this->nextSteps,
@ -98,6 +101,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
* operatorLabel: ?string, * operatorLabel: ?string,
* shortExplanation: ?string, * shortExplanation: ?string,
* diagnosticCode: ?string, * diagnosticCode: ?string,
* actionability: ?string,
* trustImpact: string, * trustImpact: string,
* absencePattern: ?string, * absencePattern: ?string,
* nextSteps: array<int, string>, * nextSteps: array<int, string>,
@ -114,6 +118,7 @@ public function toArray(): array
'operatorLabel' => $this->operatorLabel, 'operatorLabel' => $this->operatorLabel,
'shortExplanation' => $this->shortExplanation, 'shortExplanation' => $this->shortExplanation,
'diagnosticCode' => $this->diagnosticCode, 'diagnosticCode' => $this->diagnosticCode,
'actionability' => $this->actionability,
'trustImpact' => $this->trustImpact, 'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern, 'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps, 'nextSteps' => $this->nextSteps,

View File

@ -6,10 +6,12 @@
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -40,6 +42,7 @@
use App\Support\Tenants\TenantRecoveryTriagePresentation; use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class WorkspaceOverviewBuilder 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'), 'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
]; ];
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
$zeroTenantState = null; $zeroTenantState = null;
if ($accessibleTenants->isEmpty()) { if ($accessibleTenants->isEmpty()) {
@ -168,6 +173,7 @@ public function build(Workspace $workspace, User $user): array
'workspace_id' => $workspaceId, 'workspace_id' => $workspaceId,
'workspace_name' => (string) $workspace->name, 'workspace_name' => (string) $workspace->name,
'accessible_tenant_count' => $accessibleTenants->count(), 'accessible_tenant_count' => $accessibleTenants->count(),
'my_findings_signal' => $myFindingsSignal,
'summary_metrics' => $summaryMetrics, 'summary_metrics' => $summaryMetrics,
'triage_review_progress' => $triageReviewProgress['families'], 'triage_review_progress' => $triageReviewProgress['families'],
'attention_items' => $attentionItems, 'attention_items' => $attentionItems,
@ -198,6 +204,68 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection
->get(['id', 'name', 'external_id', 'workspace_id']); ->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 * @param Collection<int, Tenant> $accessibleTenants
* @return list<array<string, mixed>> * @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

@ -11,44 +11,6 @@
<div <div
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif @if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
> >
<x-filament::section heading="Monitoring detail" class="mb-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
</div>
</div>
</x-filament::section>
@if ($contextBanner !== null) @if ($contextBanner !== null)
@php @php
$bannerClasses = match ($contextBanner['tone']) { $bannerClasses = match ($contextBanner['tone']) {
@ -117,5 +79,43 @@
@endif @endif
{{ $this->infolist }} {{ $this->infolist }}
<x-filament::section heading="Monitoring detail" class="mt-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
</div>
</div>
</x-filament::section>
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -2,6 +2,7 @@
@php @php
$workspace = $overview['workspace'] ?? ['name' => 'Workspace']; $workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
$quickActions = $overview['quick_actions'] ?? []; $quickActions = $overview['quick_actions'] ?? [];
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
$zeroTenantState = $overview['zero_tenant_state'] ?? null; $zeroTenantState = $overview['zero_tenant_state'] ?? null;
@endphp @endphp
@ -57,6 +58,49 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
</div> </div>
@endif @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)) @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"> <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"> <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

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\Reviews\ReviewRegister; use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
@ -15,6 +16,8 @@
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('returns 404 for non-members on the baseline compare explanation surface', function (): void { it('returns 404 for non-members on the baseline compare explanation surface', function (): void {
[$member, $tenant] = createUserWithTenant(role: 'owner'); [$member, $tenant] = createUserWithTenant(role: 'owner');
@ -99,3 +102,65 @@
->get(ReviewRegister::getUrl(panel: 'admin')) ->get(ReviewRegister::getUrl(panel: 'admin'))
->assertNotFound(); ->assertNotFound();
}); });
it('renders governance summary facts for entitled viewers on the canonical run detail surface', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => 'ambiguous_subjects',
'evidence_gaps' => [
'count' => 2,
],
],
],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Artifact impact')
->assertSee('Dominant cause')
->assertSee('Ambiguous matches');
});
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => 'active',
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => 'tenant.review_pack.generate',
'status' => 'completed',
'outcome' => 'succeeded',
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -79,6 +79,24 @@ protected function makePartialArtifactTruthEvidenceSnapshot(
); );
} }
protected function makeMissingArtifactTruthEvidenceSnapshot(
Tenant $tenant,
array $snapshotOverrides = [],
array $summaryOverrides = [],
): EvidenceSnapshot {
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
return $this->restateArtifactTruthEvidenceSnapshot(
$snapshot,
EvidenceCompletenessState::Missing,
array_replace([
'dimension_count' => 0,
'missing_dimensions' => 1,
'stale_dimensions' => 0,
], $summaryOverrides),
);
}
protected function makeArtifactTruthReview( protected function makeArtifactTruthReview(
Tenant $tenant, Tenant $tenant,
User $user, User $user,
@ -115,6 +133,32 @@ protected function makeArtifactTruthReview(
return TenantReview::query()->create(array_replace($defaults, $reviewOverrides)); return TenantReview::query()->create(array_replace($defaults, $reviewOverrides));
} }
protected function makePartialArtifactTruthReview(
Tenant $tenant,
User $user,
?EvidenceSnapshot $snapshot = null,
array $reviewOverrides = [],
array $summaryOverrides = [],
): TenantReview {
return $this->makeArtifactTruthReview(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
reviewOverrides: array_replace([
'status' => TenantReviewStatus::Ready->value,
'completeness_state' => TenantReviewCompletenessState::Partial->value,
], $reviewOverrides),
summaryOverrides: array_replace_recursive([
'section_state_counts' => [
'complete' => 4,
'partial' => 1,
'missing' => 1,
'stale' => 0,
],
], $summaryOverrides),
);
}
protected function makeBlockedArtifactTruthReview( protected function makeBlockedArtifactTruthReview(
Tenant $tenant, Tenant $tenant,
User $user, User $user,

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

@ -77,7 +77,7 @@ function visibleLivewireText(Testable $component): string
->assertSee('Outcome') ->assertSee('Outcome')
->assertSee('Artifact truth') ->assertSee('Artifact truth')
->assertSee('Execution failed') ->assertSee('Execution failed')
->assertSee($explanation?->headline ?? '') ->assertSee('The baseline capture finished without a usable snapshot.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Artifact not usable') ->assertSee('Artifact not usable')
@ -136,11 +136,10 @@ function visibleLivewireText(Testable $component): string
->assertSee('Result trust') ->assertSee('Result trust')
->assertSee('Primary next step') ->assertSee('Primary next step')
->assertSee('Artifact truth details') ->assertSee('Artifact truth details')
->assertSee($explanation?->headline ?? '') ->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '') ->assertSee($explanation?->nextActionText ?? '')
->assertSee('The run completed, but normal output was intentionally suppressed.')
->assertSee('Resume or rerun evidence capture before relying on this compare result.') ->assertSee('Resume or rerun evidence capture before relying on this compare result.')
->assertDontSee('Artifact next step'); ->assertDontSee('Artifact next step');
@ -206,7 +205,7 @@ function visibleLivewireText(Testable $component): string
Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '') ->assertSee('The compare finished, but a compare strategy failure kept the result incomplete.')
->assertSee($explanation?->nextActionText ?? '') ->assertSee($explanation?->nextActionText ?? '')
->assertSee('Compare strategy') ->assertSee('Compare strategy')
->assertSee('Intune Policy') ->assertSee('Intune Policy')
@ -314,7 +313,7 @@ function visibleLivewireText(Testable $component): string
Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '') ->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertDontSee('No confirmed drift in the latest baseline compare.'); ->assertDontSee('No confirmed drift in the latest baseline compare.');

View File

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -13,8 +14,11 @@
->get('/admin') ->get('/admin')
->assertOk() ->assertOk()
->assertSee('Overview') ->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(Filament::getPanel('admin')->getHomeUrl())->toBe(route('admin.home'));
expect((string) $response->getContent())->toContain('href="'.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

@ -43,7 +43,7 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Artifact truth') ->assertSee('Artifact truth')
->assertSee($explanation?->headline ?? '') ->assertSee('The snapshot finished processing, but its evidence basis is incomplete.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Partially complete') ->assertSee('Partially complete')

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
use Tests\TestCase;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
function governanceVisibleText(Testable $component): string
{
$html = $component->html();
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
}
function governanceRunViewer(TestCase $testCase, $user, Tenant $tenant, OperationRun $run): Testable
{
Filament::setTenant(null, true);
$testCase->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
return Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('renders a summary-first hierarchy for zero-output baseline compare runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => 'coverage_unproven',
'coverage' => [
'proof' => false,
],
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'errors_recorded' => 1,
],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Decision')
->assertSee('Artifact impact')
->assertSee('Dominant cause')
->assertSee('Primary next step')
->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee('Artifact truth details')
->assertSee('Monitoring detail');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'))
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Monitoring detail'))
->and($pageText)->toContain('no decision-grade result is available yet');
});
it('keeps blocked baseline capture summaries ahead of diagnostics without adding new run-detail actions', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => 'missing_capability',
'baseline_capture' => [
'subjects_total' => 0,
'gaps' => [
'count' => 0,
],
],
],
'failure_summary' => [[
'reason_code' => 'missing_capability',
'message' => 'A required capability is missing for this run.',
]],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertActionVisible('operate_hub_back_to_operations')
->assertActionVisible('refresh')
->assertSee('Blocked by prerequisite')
->assertSee('No baseline was captured')
->assertSee('Artifact impact')
->assertSee('Dominant cause');
$pageText = governanceVisibleText($component);
expect(mb_substr_count($pageText, 'No baseline was captured'))->toBe(1)
->and(mb_strpos($pageText, 'No baseline was captured'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows processing outcome separately from artifact impact for stale evidence snapshot runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
$this->makeStaleArtifactTruthEvidenceSnapshot(
tenant: $tenant,
snapshotOverrides: [
'operation_run_id' => (int) $run->getKey(),
],
);
governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Outcome')
->assertSee('Artifact impact')
->assertSee('Completed successfully')
->assertSee('The snapshot finished processing, but its evidence basis is already stale.')
->assertSee('Result trust');
});
it('preserves a dominant cause plus secondary causes for degraded review composition runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose');
$snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant);
$this->makeArtifactTruthReview(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
reviewOverrides: [
'operation_run_id' => (int) $run->getKey(),
'completeness_state' => 'partial',
],
summaryOverrides: [
'section_state_counts' => [
'complete' => 4,
'partial' => 1,
'missing' => 1,
'stale' => 0,
],
],
);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Dominant cause')
->assertSee('Missing sections')
->assertSee('Secondary causes')
->assertSee('Stale evidence basis');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes'))
->and($pageText)->toContain('stale evidence');
});

View File

@ -61,6 +61,32 @@
expect($explanation->family)->toBe(ExplanationFamily::TrustworthyResult) expect($explanation->family)->toBe(ExplanationFamily::TrustworthyResult)
->and($explanation->evaluationResult)->toBe('full_result') ->and($explanation->evaluationResult)->toBe('full_result')
->and($explanation->trustworthinessLevel)->toBe(TrustworthinessLevel::Trustworthy) ->and($explanation->trustworthinessLevel)->toBe(TrustworthinessLevel::Trustworthy)
->and($explanation->nextActionCategory)->toBe('none')
->and($explanation->nextActionText)->toBe('No action needed') ->and($explanation->nextActionText)->toBe('No action needed')
->and($explanation->coverageStatement)->toContain('sufficient'); ->and($explanation->coverageStatement)->toContain('sufficient');
}); });
it('maps retryable transient reasons into retry-later guidance', function (): void {
$reason = $this->makeExplanationReasonEnvelope([
'internalCode' => 'baseline_capture_transient_timeout',
'operatorLabel' => 'Capture paused',
'shortExplanation' => 'The capture hit a transient timeout while collecting evidence.',
'actionability' => 'retryable_transient',
'nextSteps' => [\App\Support\ReasonTranslation\NextStepOption::instruction('Retry the capture after worker capacity recovers.')],
]);
$truth = $this->makeArtifactTruthEnvelope([
'executionOutcome' => 'partially_succeeded',
'artifactExistence' => 'created_but_not_usable',
'contentState' => 'missing_input',
'actionability' => 'required',
'primaryLabel' => 'Artifact not usable',
'primaryExplanation' => 'The capture did not finish cleanly enough to produce a usable artifact.',
'nextActionLabel' => 'Retry the capture after worker capacity recovers',
], $reason);
$explanation = app(OperatorExplanationBuilder::class)->fromArtifactTruthEnvelope($truth);
expect($explanation->nextActionCategory)->toBe('retry_later')
->and($explanation->nextActionText)->toBe('Retry the capture after worker capacity recovers');
});

View File

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
use App\Support\OpsUx\SummaryCountsNormalizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
it('derives a blocked baseline capture summary with prerequisite-focused next steps', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => 'missing_capability',
'baseline_capture' => [
'subjects_total' => 0,
'gaps' => [
'count' => 0,
],
],
],
'failure_summary' => [[
'reason_code' => 'missing_capability',
'message' => 'A required capability is missing for this run.',
]],
'completed_at' => now(),
]);
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
expect($summary)->not->toBeNull()
->and($summary?->headline)->toContain('No baseline was captured')
->and($summary?->dominantCause['label'])->toBe('No governed subjects captured')
->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data')
->and($summary?->affectedScaleCue['label'])->toBe('Capture scope')
->and($summary?->affectedScaleCue['value'])->toContain('0 governed subjects');
});
it('derives an ambiguous baseline compare summary with affected scale and scope review guidance', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => 'ambiguous_subjects',
'subjects_total' => 12,
'evidence_gaps' => [
'count' => 4,
],
'coverage' => [
'proof' => false,
],
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'errors_recorded' => 2,
],
'completed_at' => now(),
]);
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
expect($summary)->not->toBeNull()
->and($summary?->headline)->toContain('ambiguous subject matching')
->and($summary?->dominantCause['label'])->toBe('Ambiguous matches')
->and($summary?->nextActionCategory)->toBe('review_scope_or_ambiguous_matches')
->and($summary?->affectedScaleCue['label'])->toBe('Affected subjects')
->and($summary?->affectedScaleCue['value'])->toContain('4 governed subjects');
});
it('keeps execution outcome separate from artifact impact for stale evidence snapshot runs', function (): void {
$tenant = Tenant::factory()->create();
[, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
$this->makeStaleArtifactTruthEvidenceSnapshot(
tenant: $tenant,
snapshotOverrides: [
'operation_run_id' => (int) $run->getKey(),
],
);
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh());
expect($summary)->not->toBeNull()
->and($summary?->executionOutcomeLabel)->toBe('Completed successfully')
->and($summary?->artifactImpactLabel)->not->toBe($summary?->executionOutcomeLabel)
->and($summary?->headline)->toContain('stale')
->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data');
});
it('derives resume capture or generation when a compare run records a resume token', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'resume_token' => 'resume-token-220',
'evidence_gaps' => [
'count' => 2,
],
],
],
'completed_at' => now(),
]);
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
expect($summary)->not->toBeNull()
->and($summary?->nextActionCategory)->toBe('resume_capture_or_generation')
->and($summary?->headline)->toContain('evidence capture still needs to resume');
});
it('keeps deterministic multi-cause ordering for degraded review composition runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose');
$snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant, [
'operation_run_id' => null,
]);
$this->makeArtifactTruthReview(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
reviewOverrides: [
'operation_run_id' => (int) $run->getKey(),
'completeness_state' => 'partial',
],
summaryOverrides: [
'section_state_counts' => [
'complete' => 4,
'partial' => 1,
'missing' => 1,
'stale' => 0,
],
],
);
$builder = app(GovernanceRunDiagnosticSummaryBuilder::class);
$first = $builder->build($run->fresh());
$second = $builder->build($run->fresh());
expect($first)->not->toBeNull()
->and($second)->not->toBeNull()
->and($first?->dominantCause['label'])->toBe('Missing sections')
->and($first?->secondaryCauses[0]['label'] ?? null)->toBe('Stale evidence basis')
->and($first?->secondaryCauses)->toEqual($second?->secondaryCauses)
->and($first?->headline)->toContain('missing sections and stale evidence');
});
it('derives no further action for publishable review pack runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review_pack.generate');
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, [
'operation_run_id' => null,
]);
$review = $this->makeArtifactTruthReview($tenant, $user, $snapshot, [
'operation_run_id' => null,
]);
$this->makeArtifactTruthReviewPack(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
review: $review,
packOverrides: [
'operation_run_id' => (int) $run->getKey(),
],
);
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh());
expect($summary)->not->toBeNull()
->and($summary?->nextActionCategory)->toBe('no_further_action')
->and($summary?->nextActionText)->toBe('No action needed.');
});
it('does not invent new summary count keys while deriving scale cues', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'summary_counts' => [
'total' => 7,
'custom_noise' => 99,
],
'context' => [
'baseline_compare' => [],
],
'completed_at' => now(),
]);
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
expect($summary)->not->toBeNull()
->and(array_keys(SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : [])))
->toBe(['total'])
->and($summary?->affectedScaleCue['source'])->toBe('summary_counts')
->and($summary?->affectedScaleCue['label'])->toBe('Total');
});

View File

@ -26,7 +26,7 @@ ### Governance & Architecture Hardening
**Active specs**: 144 **Active specs**: 144
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate) **Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate)
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations remains the next open adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane. **Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane.
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates **Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
### UI & Product Maturity Polish ### UI & Product Maturity Polish

View File

@ -5,7 +5,7 @@ # Spec Candidates
> >
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-04-20 (reconciled promoted candidates with current specs) **Last reviewed**: 2026-04-21 (added `My Work` candidate family and aligned it with existing promoted work)
--- ---
@ -42,6 +42,9 @@ ## Promoted to Spec
- Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`) - Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`)
- Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`) - Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`)
- Governance Operator Outcome Compression → Spec 214 (`governance-outcome-compression`) - 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`) - Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`) - Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`) - Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
@ -144,37 +147,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
- **Dependencies**: Baseline drift engine stable (Specs 116119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149) - **Dependencies**: Baseline drift engine stable (Specs 116119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists" - **Related specs / candidates**: Spec 101 (baseline governance), Specs 116119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
### Humanized Diagnostic Summaries for Governance Operations
- **Type**: hardening
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
- **Vehicle**: new standalone candidate
- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy.
- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional.
- **Proposed direction**:
- Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON
- Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context
- Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful
- Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success
- Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration
- Keep raw JSON and low-level context fully available, but explicitly secondary
- **Primary adoption surfaces**:
- Canonical Monitoring run-detail pages for governance operation types
- Shared tenantless canonical run viewers and run-detail templates
- Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation
- **Scope boundaries**:
- **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families
- **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages
- **Acceptance points**:
- A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON
- Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge
- Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Spec 214 (Governance Operator Outcome Compression)
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Spec 214 (Governance Operator Outcome Compression). Spec 214 improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
- **Priority**: high
> **Operator Truth Initiative — Sequencing Note** > **Operator Truth Initiative — Sequencing Note**
> >
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics. > The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
@ -185,7 +157,7 @@ ### Humanized Diagnostic Summaries for Governance Operations
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct. > 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation. > 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
> 5. **Governance Operator Outcome Compression** — now promoted to Spec 214, applying the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first while preserving diagnostics as second-layer detail. > 5. **Governance Operator Outcome Compression** — now promoted to Spec 214, applying the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first while preserving diagnostics as second-layer detail.
> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4. > 6. **Humanized Diagnostic Summaries for Governance Operations**now promoted to Spec 220 (`governance-run-summaries`), the run-detail explainability companion to compression that makes governance run detail self-explanatory using the explanation patterns established in step 4.
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — now promoted to Spec 216, extending the proven Gen 2 gate pattern to all provider-backed operations and establishing a shared result presenter as the adjacent hardening lane. > 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — now promoted to Spec 216, extending the proven Gen 2 gate pattern to all provider-backed operations and establishing a shared result presenter as the adjacent hardening lane.
> >
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane. > **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane.
@ -388,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. > 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 ### Findings Intake & Team Queue v1
- **Type**: workflow execution / team operations - **Type**: workflow execution / team operations
- **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment - **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment
@ -652,6 +600,106 @@ ### Surface Taxonomy & Workflow-First IA Classification
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory - **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
- **Priority**: high - **Priority**: high
### Personal Work IA / My Work
- **Type**: IA / workflow foundation
- **Source**: admin workspace IA discussion 2026-04-21; personal work architecture candidate pack
- **Problem**: TenantPilot now has a real assignee-facing work surface in Spec 221 (`findings-operator-inbox`), but future personal work would otherwise fragment across findings, approvals, reviews, alerts, and exception-renewal surfaces without one stable "what is my work today?" entry point.
- **Why it matters**: This is not just a navigation tweak. As TenantPilot becomes more workflow- and decision-oriented, personally addressed actionable work needs its own IA layer. Without that layer, discoverability, counts, and operator mental models drift by domain.
- **Proposed direction**:
- Add a top-level `My Work` group in the admin panel as the personal lens on domain work, not as a second monitoring tree or favorites bucket
- Allow only surfaces that are explicitly assigned to the current user or awaiting that user's concrete decision
- Keep global domain navigation canonical for browsing, reporting, and non-personal work
- Treat the dashboard as a signal and entry surface, not the durable home of personal queues
- Start with the IA contract and admission rules; do not require every future child surface to ship together
- **Admission rules**:
- Personal addressability: explicit assignee, approver, or decision owner; generic responsibility is insufficient
- Concrete next action: triage, approve, renew, close, escalate, or equivalent; reports and diagnostics alone are out
- Workspace-safe scope: rows, counts, and badges stay limited to visible, authorized workspace and tenant scope
- Personal value-add: the surface does more than deep-link to a global list by adding personal filtering, prioritization, or decision support
- No replacement of domain navigation: domain collections remain canonical outside the personal lens
- **Vehicle note**: `My Work — Assigned Findings` is already materially represented by Spec 221 (`findings-operator-inbox`) and should be treated as the first concrete child surface rather than a second open candidate.
- **Activation rule**: Introduce `My Work` as actual top-level navigation only once at least two real personal work surfaces exist or are committed near-term. Before that, the IA contract may exist without forcing a single-link top-level group.
- **Explicit non-goals**: Not a generic "My Area", not profile/settings relocation, not favorites/bookmarks, not a universal task engine, not a dashboard replacement, and not a notification center.
- **Boundary with Spec 221 (Findings Operator Inbox)**: Spec 221 defines the first concrete personal findings queue. This candidate defines the durable admin-IA rule that decides when that queue graduates into a broader personal-work group and how future personal surfaces should join it.
- **Boundary with Human-in-the-Loop Autonomous Governance / Governance Inbox**: Governance Inbox is the long-horizon cross-workflow decision cockpit with structured recommendations and controlled execution. `My Work` is the nearer-term IA layer for personally addressed queues in the existing admin workspace. It should not absorb the full governance-inbox ambition.
- **Dependencies**: Surface Taxonomy & Workflow-First IA Classification, Spec 221 (`findings-operator-inbox`), workspace/tenant scope enforcement, future assignment and approval routing semantics
- **Priority**: high
> `My Work` candidate family: keep child surfaces and cross-cutting semantics split so prioritization can land the IA contract, the next concrete personal queues, and the routing/count foundations independently instead of turning personal work into one oversized umbrella spec.
### My Work — Pending Approvals
- **Type**: workflow execution / approvals
- **Source**: personal work architecture candidate pack 2026-04-21; future approval-bearing workflows
- **Problem**: Approval work would otherwise be scattered across risk acceptance, drift governance, restore, or rollout surfaces without one trustworthy personal decision queue.
- **Why it matters**: Approval is the cleanest form of personally addressed work. If it remains buried in domain pages, operators lose the "awaiting my decision" contract.
- **Proposed direction**: Add a personal approvals queue for decisions that explicitly await the current user's approval or rejection; show decision summary, urgency, scope, and safe drilldown; keep FYI notifications and passive review signals out.
- **Explicit non-goals**: Notification center, knowledge-only acknowledgements, general automation orchestration, or inventing a full approval engine before approval-producing domains exist.
- **Dependencies**: Risk acceptance lifecycle (Spec 154), drift/change approval direction, restore or rollout approval producers, routing semantics
- **Strategic sequencing**: Strong candidate for the second real `My Work` child surface because it naturally satisfies the admission rules.
- **Priority**: high
### My Work — Assigned Reviews
- **Type**: workflow execution / review work
- **Source**: personal work architecture candidate pack 2026-04-21; governance/review responsibility gap
- **Problem**: Review work can easily remain hidden in tenant review, evidence, or governance surfaces even when a specific reviewer is responsible.
- **Why it matters**: Reviews are person-bound work, but not all reviews are findings or approvals. A dedicated personal review queue keeps governance responsibility visible without flattening everything into one findings model.
- **Proposed direction**: Add a review queue for review packs, evidence bundles, or governance review steps explicitly assigned to the current user; emphasize due state, review scope, and next action.
- **Explicit non-goals**: Generic reporting hub, passive read receipts, or turning `My Work` into a full collaboration suite.
- **Dependencies**: Review-layer maturity, evidence surfaces, assignment semantics, due-date conventions
- **Priority**: medium
### My Work — Risk Acceptance Renewals
- **Type**: workflow execution / time-bound governance
- **Source**: personal work architecture candidate pack 2026-04-21; exception-renewal follow-up
- **Problem**: Expiring risk acceptances or exceptions create person-addressed renewal work, but that work is neither standard findings triage nor generic monitoring.
- **Why it matters**: Renewal work is deadline-driven and materially important, so it needs a calm but trustworthy personal queue instead of disappearing inside exception detail pages.
- **Proposed direction**: Add a renewal queue for expiring or expired risk acceptances where the current user is owner or required approver; support renew, close, or escalate next steps.
- **Explicit non-goals**: Full exception lifecycle redesign or generic reminder infrastructure for every dated object in the product.
- **Dependencies**: Spec 154 (`finding-risk-acceptance`), due/expiry semantics, routing semantics
- **Priority**: medium
### My Work — Actionable Alerts
- **Type**: alerts / workflow execution
- **Source**: personal work architecture candidate pack 2026-04-21; action-vs-notification boundary review
- **Problem**: Some alerts represent concrete assigned follow-up, while others are only awareness signals. Without a boundary, `My Work` either becomes noisy or misses genuine action-bearing alerts.
- **Why it matters**: `My Work` must stay quiet and trustworthy. Admitting every notification would destroy the queue's meaning; admitting none would keep action-bearing alerts disconnected from work.
- **Proposed direction**: Route only alerts with explicit ownership and one clear next action into `My Work`; keep generic notifications, telemetry, and passive monitoring signals outside the group.
- **Explicit non-goals**: General notification center, chat/activity feed, or bulk alert triage system.
- **Dependencies**: Alert infrastructure, ownership semantics, escalation rules, personal count semantics
- **Priority**: medium
### My Work — Approval & Escalation Routing
- **Type**: foundation / routing semantics
- **Source**: personal work architecture candidate pack 2026-04-21; ownership and fallback analysis
- **Problem**: Personal queues become inconsistent when owner, assignee, approver, escalation target, and fallback role mean different things in each domain.
- **Why it matters**: `My Work` cannot be trustworthy without a shared answer to "why did this item land on me?" and "who gets it if no person is assigned?".
- **Proposed direction**: Define shared routing semantics for assignee versus owner versus approver, fallback-to-role behavior, no-assignee escalation, and future delegation boundaries; keep this as a governance contract, not a UI-only heuristic.
- **Explicit non-goals**: Full org-chart modeling, absence management, or automatic load balancing.
- **Dependencies**: Ownership semantics (Spec 219), findings workflow, approval-producing domains, RBAC/capability model, alerting
- **Strategic sequencing**: Foundational before `My Work` expands beyond findings into approvals, reviews, or renewals.
- **Priority**: high
### My Work — Personal Counts & Priority Semantics
- **Type**: foundation / queue semantics
- **Source**: personal work architecture candidate pack 2026-04-21; count-trust and priority-shaping analysis
- **Problem**: Once more than one personal queue exists, badges and ordering can drift, double-count, or leak hidden scope unless the inclusion and weighting rules are explicit.
- **Why it matters**: Personal counts are operator trust surfaces. If badges are noisy, inconsistent, or scope-leaky, the IA layer becomes less usable than the domain pages it was meant to simplify.
- **Proposed direction**: Define group-badge inclusion, visible-scope count rules, urgency weighting for overdue versus pending approval versus reopened work, and the relationship between workspace-wide truth and active-tenant context.
- **Explicit non-goals**: Complex cross-domain scoring engine, productivity gamification, or predictive prioritization.
- **Dependencies**: `My Work` IA, routing semantics, alerting/approval/review producers, RBAC scope enforcement
- **Strategic sequencing**: Must exist before a multi-surface `My Work` badge ships.
- **Priority**: high
### My Work — Dashboard Signals & Personal Entry Points
- **Type**: IA / entry-point semantics
- **Source**: personal work architecture candidate pack 2026-04-21; dashboard-versus-nav continuity analysis
- **Problem**: Dashboard summary cards, CTA strips, and future personal queues can easily duplicate or contradict each other unless their roles are defined together.
- **Why it matters**: The workspace dashboard should signal personal work, not become a second queue. Operators need consistent drill-in and return behavior between the dashboard and `My Work`.
- **Proposed direction**: Define which personal signals belong on `/admin`, when a CTA is enough versus when a nav point is required, and how context/filter carry-over works between dashboard signals and personal queues.
- **Explicit non-goals**: Full dashboard redesign or a second summary layer that mirrors every `My Work` list.
- **Dependencies**: Spec 221 workspace signal, `My Work` IA, dashboard surface conventions, personal count semantics
- **Priority**: medium
### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting ### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting
- **Type**: feature - **Type**: feature
- **Source**: roadmap-to-spec coverage audit 2026-03-18, 0800-future-features brainstorming (pillar #1 — MSP Portfolio & Operations), product positioning for MSP portfolio owners - **Source**: roadmap-to-spec coverage audit 2026-03-18, 0800-future-features brainstorming (pillar #1 — MSP Portfolio & Operations), product positioning for MSP portfolio owners

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Humanized Diagnostic Summaries for Governance Operations
**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
- Validation pass 1 complete.
- Required surface-governance metadata such as routes and action-matrix references are present, but the spec avoids implementation mechanics, framework instructions, and code-level solution design.

View File

@ -0,0 +1,230 @@
openapi: 3.1.0
info:
title: Governance Operation Run Summaries Contract
version: 1.0.0
description: >-
Internal reference contract for Spec 220. These routes continue to return
HTML through Filament and Livewire. The vendor media types below document
the logical summary payloads that must be derivable before rendering. This
is not a public API commitment.
paths:
/admin/operations:
get:
summary: Canonical operations list entry point
responses:
'200':
description: Rendered canonical operations list page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.governance-operations-list+json:
schema:
$ref: '#/components/schemas/GovernanceOperationsListPage'
'404':
description: Workspace context is missing or the viewer is not entitled to the canonical monitoring scope
/admin/operations/{run}:
get:
summary: Canonical governance operation run detail
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered canonical governance run-detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.governance-operation-run-detail+json:
schema:
$ref: '#/components/schemas/GovernanceOperationRunDetailPage'
'403':
description: Viewer is in scope but lacks required capability for a related action
'404':
description: Run is not visible because it does not exist or entitlement is missing
components:
schemas:
GovernanceOperationsListPage:
type: object
required:
- activeContext
- rowInspectModel
properties:
activeContext:
type: object
properties:
workspaceScope:
type: string
tenantContextActive:
type: boolean
rowInspectModel:
type: string
enum:
- row_click
canonicalDetailRoute:
type: string
example: /admin/operations/44
GovernanceOperationRunDetailPage:
type: object
required:
- runId
- canonicalOperationType
- summary
- diagnosticsAvailable
properties:
runId:
type: integer
canonicalOperationType:
type: string
enum:
- baseline.capture
- baseline.compare
- tenant.evidence.snapshot.generate
- tenant.review.compose
- tenant.review_pack.generate
artifactFamily:
type:
- string
- 'null'
enum:
- baseline_snapshot
- evidence_snapshot
- tenant_review
- review_pack
- null
summary:
$ref: '#/components/schemas/GovernanceRunDiagnosticSummary'
relatedNavigation:
type: array
items:
$ref: '#/components/schemas/RelatedNavigationLink'
diagnosticsAvailable:
type: boolean
diagnosticsSections:
type: array
items:
$ref: '#/components/schemas/DiagnosticsSection'
GovernanceRunDiagnosticSummary:
type: object
required:
- headline
- executionOutcomeLabel
- artifactImpactLabel
- primaryReason
- nextActionText
properties:
headline:
type: string
executionOutcomeLabel:
type: string
artifactImpactLabel:
type: string
primaryReason:
type: string
affectedScaleCue:
$ref: '#/components/schemas/AffectedScaleCue'
nextActionText:
type: string
dominantCause:
$ref: '#/components/schemas/DominantCauseBreakdown'
secondaryFacts:
type: array
items:
$ref: '#/components/schemas/SummaryFact'
DominantCauseBreakdown:
type: object
required:
- primaryLabel
- primaryExplanation
properties:
primaryCode:
type:
- string
- 'null'
primaryLabel:
type: string
primaryExplanation:
type: string
secondaryCauses:
type: array
items:
type: string
AffectedScaleCue:
type: object
required:
- label
- value
- source
properties:
label:
type: string
value:
type: string
source:
type: string
enum:
- summary_counts
- context
- related_artifact_truth
confidence:
type: string
enum:
- exact
- bounded
- best_available
SummaryFact:
type: object
required:
- label
- value
properties:
label:
type: string
value:
type: string
emphasis:
type: string
enum:
- neutral
- caution
- blocked
RelatedNavigationLink:
type: object
required:
- label
- visible
properties:
label:
type: string
href:
type:
- string
- 'null'
visible:
type: boolean
deniedReason:
type:
- string
- 'null'
DiagnosticsSection:
type: object
required:
- title
- kind
properties:
title:
type: string
kind:
type: string
enum:
- supporting_detail
- count_diagnostics
- failure_payload
- evidence_gap_detail
- type_specific_detail
collapsedByDefault:
type: boolean

View File

@ -0,0 +1,197 @@
# Data Model: Humanized Diagnostic Summaries for Governance Operations
## Overview
This feature does not add or modify persisted domain entities. It adds a logical derived presentation model for canonical governance operation run detail under `/admin/operations/{run}`.
The design constraint is strict:
- `OperationRun` remains the only persisted source for run lifecycle and execution truth.
- Related artifacts such as `BaselineSnapshot`, `EvidenceSnapshot`, `TenantReview`, and `ReviewPack` remain the persisted source for artifact truth where they exist.
- `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` remain the semantic inputs.
- The new summary remains fully derived and surface-specific.
## Existing Persistent Inputs
### 1. OperationRun
- Purpose: Canonical operational record for background and governance work.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `context`
- `summary_counts`
- `failure_summary`
- `started_at`
- `completed_at`
- Relationships and derived lookups used by this feature:
- workspace and tenant context
- related artifact resolution through current operation catalog and presenter logic
### 2. Related Governance Artifacts
These are not newly modeled by this feature, but they remain relevant when a run produced or references an artifact.
- `BaselineSnapshot`
- `EvidenceSnapshot`
- `TenantReview`
- `ReviewPack`
The feature only reads their already-derived truth where available.
## Existing Derived Inputs
### A. ArtifactTruthEnvelope
`ArtifactTruthPresenter` already derives `ArtifactTruthEnvelope` for `OperationRun` and related artifact records.
Important envelope dimensions already available:
- `artifactExistence`
- `contentState`
- `freshnessState`
- `publicationReadiness`
- `supportState`
- `actionability`
- `primaryLabel`
- `primaryExplanation`
- `reason`
- `diagnosticLabel`
This feature must consume that envelope instead of replacing it.
### B. OperatorExplanationPattern
`OperatorExplanationBuilder` already derives an explanation pattern containing:
- `headline`
- `evaluationResult`
- `executionOutcome`
- `trustworthinessLevel`
- `reliabilityStatement`
- `coverageStatement`
- `dominantCauseCode`
- `dominantCauseLabel`
- `dominantCauseExplanation`
- `nextActionCategory`
- `nextActionText`
- `countDescriptors`
This feature reuses that pattern as input to the new run-detail summary.
## Derived Presentation Entities
### 1. GovernanceRunDiagnosticSummary
Primary derived object for canonical run detail.
| Field | Meaning | Source |
|---|---|---|
| `headline` | One dominant first-pass statement for the run detail page | derived from `ArtifactTruthEnvelope` + `OperatorExplanationPattern` |
| `executionOutcomeLabel` | Technical execution result kept visible as a separate fact | `OperationRun.outcome` via existing badge semantics |
| `artifactImpactLabel` | What the resulting artifact means for operator action | artifact truth + explanation pattern |
| `primaryReason` | One short reason supporting the headline | dominant cause explanation or primary explanation |
| `affectedScaleCue` | One operator-readable scale cue, such as ambiguous subjects or missing sections | `summary_counts`, run `context`, or related artifact truth |
| `nextActionText` | First follow-up step the operator should see | existing explanation or next-step logic |
| `secondaryCauses[]` | Additional contributing causes preserved below the primary cause | ranked from reason/context inputs |
| `diagnosticsAvailable` | Whether deeper technical sections still exist below | derived from reason, payload, or technical sections |
Validation rules:
- Exactly one `headline` is allowed for the default-visible summary.
- `artifactImpactLabel` must stay distinct from `executionOutcomeLabel`.
- `affectedScaleCue` is optional, but when present it must be backed by numeric or enumerated persisted evidence, not freeform guesswork.
- `secondaryCauses[]` must not repeat the dominant cause.
### 2. DominantCauseBreakdown
Logical grouping of the main and supporting causes for degraded runs.
| Field | Meaning |
|---|---|
| `primaryCauseCode` | Stable internal reason or derived cause key |
| `primaryCauseLabel` | Operator-facing dominant cause label |
| `primaryCauseExplanation` | Short explanation shown in the summary area |
| `secondaryCauses[]` | Additional causes shown in supporting detail only |
| `rankingRule` | Stable ranking rule used to keep ordering deterministic |
Rules:
- Ranking must be deterministic for equivalent runs.
- The same cause class must keep the same reading direction across covered governance families.
- A run with no meaningful secondary cause data may omit the secondary list entirely.
### 3. AffectedScaleCue
Small derived object explaining what was affected and at what scale.
| Field | Meaning |
|---|---|
| `label` | Operator-facing scale label such as `Affected subjects`, `Missing sections`, or `Incomplete dimensions` |
| `value` | Human-readable count or scale statement |
| `source` | Where the cue came from: `summary_counts`, `context`, or related artifact truth |
| `confidence` | Whether the cue is exact, bounded, or best available from persisted context |
Rules:
- This object remains optional because not every run family has equally rich scale data.
- It must never introduce a new persisted count contract.
- It must not imply precision the persisted data does not support.
### 4. GovernanceRunSummaryContext
Logical context for the summary builder.
| Field | Meaning |
|---|---|
| `surface` | Always `canonical_operation_run_detail` for this spec |
| `canonicalOperationType` | Canonical operation type from `OperationCatalog` |
| `artifactFamily` | Related artifact family when one exists |
| `tenantVisibility` | Whether related tenant/artifact context is visible to the current actor |
Rules:
- This context is surface-specific and must not become a cross-product taxonomy.
- Tenant visibility rules must suppress inaccessible related labels and links.
## Covered Run Families
| Canonical Type | Primary Artifact Family | Typical Affected-Scale Source | Dominant-Cause Focus |
|---|---|---|---|
| `baseline.capture` | `baseline_snapshot` | `summary_counts`, `context.result`, baseline snapshot summary | blocked prerequisite, zero in-scope subjects, unusable snapshot result |
| `baseline.compare` | none direct, but linked baseline/evidence truth may exist | `summary_counts`, `context.baseline_compare`, evidence-gap payloads | suppressed output, ambiguous matches, evidence gaps, strategy failure |
| `tenant.evidence.snapshot.generate` | `evidence_snapshot` | evidence snapshot summary, completeness state, run counts | stale or incomplete evidence basis, blocked snapshot generation |
| `tenant.review.compose` | `tenant_review` | review summary, missing sections, related evidence truth | missing sections, stale evidence, internal-only review outcome |
| `tenant.review_pack.generate` | `review_pack` | pack summary, linked review state, generation context | internal-only or blocked pack outcome, source-review limitations |
## Derivation Rules
### Summary selection order
1. Resolve canonical operation type.
2. Resolve related artifact truth if present.
3. Resolve operator explanation pattern.
4. Derive dominant cause and supporting causes.
5. Derive affected-scale cue from existing persisted data.
6. Build one `GovernanceRunDiagnosticSummary`.
7. Render diagnostics below that summary without altering the underlying truth.
### Zero-output runs
- If a run completed technically but produced no decision-grade artifact, the summary must explicitly say so.
- Zero output must never default to a neutral or green reading.
### Multi-cause degraded runs
- One primary cause is required.
- Additional causes remain visible as supporting detail only.
- The ranking rule must be deterministic and shared across all covered run families.
### Authorization-sensitive output
- Related artifact names, tenant names, and links may only appear when entitlement checks already pass.
- The summary may remain useful without those labels by using generic operator-safe phrasing.

View File

@ -0,0 +1,299 @@
# Implementation Plan: Humanized Diagnostic Summaries for Governance Operations
**Branch**: `220-governance-run-summaries` | **Date**: 2026-04-20 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/spec.md`
**Note**: This plan keeps the work inside the existing canonical Monitoring run-detail, artifact-truth, and operator-explanation seams. The intended implementation is a bounded derived summary layer for governance operation runs, not a new persistence model, not a new lifecycle/state family, and not a new action or surface framework.
## Summary
Add one operator-first diagnostic summary to canonical governance run detail so baseline capture, baseline compare, evidence snapshot generation, tenant review composition, and review-pack generation runs explain dominant artifact impact, dominant cause, affected scale, artifact trustworthiness, and next action before raw diagnostics. The implementation will reuse `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the current enterprise-detail builders, and it will introduce one small `GovernanceRunDiagnosticSummary` value object plus builder under `App\Support\OpsUx` so the canonical detail page can express affected-scale and multi-cause ranking without inventing a broader UI framework.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
**Storage**: PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned
**Testing**: Pest v4 unit and feature tests, focused Monitoring/Filament/Authorization coverage
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
**Project Type**: Laravel monolith web application inside the `wt-plattform` monorepo
**Performance Goals**: Preserve DB-only render behavior on canonical run detail, add no render-time external calls, avoid new query breadth, and keep first-pass operator comprehension inside a 10-15 second scan window
**Constraints**: No new Graph calls, no new routes, no new `OperationRun` statuses or outcomes, no new `summary_counts` keys, no new notification surfaces, no new destructive actions, no cross-tenant leakage, and no duplication between decision summary and existing banners
**Scale/Scope**: One canonical Monitoring detail surface, five governance run families, one bounded derived summary seam, and focused regression coverage for summary ordering, multi-cause explanation, zero-output runs, and authorization safety
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament + existing Monitoring detail primitives
- **Shared-family relevance**: governance run-detail family, operator explanation family, enterprise detail family
- **State layers in scope**: page, detail, URL-query
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke
- **Exception path and spread control**: retain the existing diagnostic-detail exception on canonical run detail; do not spread it into new surfaces or action patterns
- **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 reorders explanation on existing run and artifact truth only; inventory and snapshot ownership remain unchanged |
| Read/write separation | PASS | PASS | No new writes, previews, confirmations, or audit-log paths are introduced |
| Graph contract path | PASS | PASS | No new Graph calls or contract-registry changes |
| RBAC / workspace / tenant isolation | PASS | PASS | Canonical `/admin/operations/{run}` remains tenant-safe; non-members stay `404`; in-scope capability denials remain `403` |
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` lifecycle, feedback surfaces, initiator rules, and summary-count contracts remain unchanged |
| Ops-UX summary counts | PASS | PASS | Existing flat numeric `summary_counts` stay canonical; the new summary only interprets them |
| Proportionality / no premature abstraction | PASS | PASS | One bounded run-summary helper is justified; no new framework, persistence, or state family is needed |
| Few layers / UI semantics | PASS | PASS | New logic stays downstream of `ArtifactTruthEnvelope` and `OperatorExplanationPattern`; no second truth source is introduced |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge domains remain canonical; the feature changes order and supporting copy only |
| Filament-native UI (UI-FIL-001) | PASS | PASS | Existing Filament detail page, sections, and enterprise-detail builders remain the implementation path |
| Action surface / inspect model | PASS | PASS | Canonical run detail remains the single inspect model; no new row, header, or bulk actions are introduced |
| Decision-first / OPSURF | PASS | PASS | The page remains a Tertiary Evidence / Diagnostics Surface, but its first read becomes operator-first |
| Test governance (TEST-GOV-001) | PASS | PASS | Proof stays in focused Monitoring feature coverage plus one narrow unit seam; no browser or heavy-governance expansion |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The work stays entirely within the current Filament v5 + Livewire v4 stack |
| Provider registration / global search / assets | PASS | PASS | No panel/provider changes, `OperationRunResource` stays non-searchable, and no new assets are required |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for canonical Monitoring run detail and authorization behavior; `Unit` only for the bounded run-summary builder or ranking helper if introduced
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The feature is proven by operator-visible hierarchy, dominant-cause ordering, zero-output handling, and tenant-safe canonical run detail. That needs focused surface tests plus one narrow unit seam, not browser or heavy-governance breadth.
- **Narrowest proving command(s)**:
- `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
- `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
- `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Shared fixture drift is the main risk. `BuildsGovernanceArtifactTruthFixtures` must stay opt-in, and any multi-cause seeded run helper should remain local to the Monitoring suite instead of becoming a repo-wide default.
- **Expensive defaults or shared helper growth introduced?**: no; all new scenario builders must require explicit run type, outcome, reason codes, and related artifact context
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `monitoring-state-page` coverage is required; existing `standard-native-filament` relief is not enough for summary-order assertions on the canonical detail page
- **Closing validation and reviewer handoff**: Reviewers must confirm summary-first order, no duplicate dominant-cause copy across banners and decision zone, zero-output runs staying non-green, cross-family consistency for shared cause classes, and `404` vs `403` semantics on the canonical route.
- **Budget / baseline / trend follow-up**: Low-to-moderate assertion growth within Monitoring and one new focused suite; no lane-budget follow-up expected unless helper sprawl begins
- **Review-stop questions**: Does the change stay inside current Monitoring detail seams? Did any new summary helper become broader than this surface needs? Did shared fixtures remain opt-in? Did any touched view leak inaccessible tenant or artifact hints?
- **Escalation path**: document-in-feature unless a second shared semantic layer, new persistence, or broad fixture default is proposed; then reject-or-split
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: The expected suite cost and abstraction surface stay tightly bounded to one existing canonical detail page and its current governance run families
## Project Structure
### Documentation (this feature)
```text
specs/220-governance-run-summaries/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── governance-run-summaries.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── Operations/
│ │ │ └── TenantlessOperationRunViewer.php
│ │ └── Resources/
│ │ └── OperationRunResource.php
│ └── Support/
│ ├── OpsUx/
│ │ ├── OperationUxPresenter.php
│ │ ├── SummaryCountsNormalizer.php
│ │ ├── GovernanceRunDiagnosticSummary.php
│ │ └── GovernanceRunDiagnosticSummaryBuilder.php
│ ├── ReasonTranslation/
│ │ └── ReasonPresenter.php
│ └── Ui/
│ ├── EnterpriseDetail/
│ ├── GovernanceArtifactTruth/
│ │ └── ArtifactTruthPresenter.php
│ └── OperatorExplanation/
│ ├── OperatorExplanationBuilder.php
│ └── OperatorExplanationPattern.php
├── resources/
│ └── views/
│ └── filament/
│ └── pages/
│ └── operations/
│ └── tenantless-operation-run-viewer.blade.php
└── tests/
├── Feature/
│ ├── Authorization/
│ │ └── OperatorExplanationSurfaceAuthorizationTest.php
│ ├── Monitoring/
│ │ ├── ArtifactTruthRunDetailTest.php
│ │ ├── GovernanceOperationRunSummariesTest.php
│ │ └── GovernanceRunExplanationFallbackTest.php
│ ├── Filament/
│ │ └── OperationRunBaselineTruthSurfaceTest.php
│ └── RunAuthorizationTenantIsolationTest.php
└── Unit/
├── Support/
│ ├── OpsUx/
│ │ └── GovernanceRunDiagnosticSummaryBuilderTest.php
│ └── OperatorExplanation/
│ └── OperatorExplanationBuilderTest.php
```
**Structure Decision**: Standard Laravel monolith. The work stays concentrated in the current Monitoring detail files, existing `Support/OpsUx` and UI helper seams, and focused Pest suites. No new base directory, panel, or package is needed.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Bounded run-summary helper/value object | Needed to keep dominant-cause ranking, affected-scale mapping, and summary ordering out of `OperationRunResource` and page templates | Extending the resource/page inline would bury operation-family logic in Filament schema code and make regression coverage brittle |
## Proportionality Review
- **Current operator problem**: Canonical governance run detail is still too technical for first-pass operator decisions, especially when execution succeeded but artifact usability did not, or when several degraded causes exist together.
- **Existing structure is insufficient because**: Existing badges, explanation patterns, and raw payload sections require operators to synthesize impact, trust, and next action themselves. The missing piece is a first-pass run-detail summary that ranks cause and scale for this single surface.
- **Narrowest correct implementation**: Add one run-detail-specific summary object and builder inside `Support/OpsUx`, derived entirely from `OperationRun`, `ArtifactTruthEnvelope`, `OperatorExplanationPattern`, and existing count/context payloads.
- **Ownership cost created**: One small builder/value-object pair, one local set of dominance rules, and focused Monitoring/unit tests.
- **Alternative intentionally rejected**: Page-local copy patches and ad-hoc Filament facts only. That would duplicate operation-type logic, make hierarchy drift likely, and fail to protect cross-family consistency.
- **Release truth**: Current-release truth. This plan improves an existing trust surface now rather than preparing a future platform abstraction.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/research.md`.
Key decisions:
- Keep canonical Monitoring run detail on `OperationRunResource` + `TenantlessOperationRunViewer`; do not create a second run-detail page.
- Treat `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` as the canonical semantic inputs.
- Introduce one bounded `GovernanceRunDiagnosticSummary` seam so the decision zone can express affected scale, dominant-cause ranking, and secondary-cause detail without overloading the Filament resource schema.
- Derive affected-scale cues from existing `summary_counts`, run `context`, and related artifact metadata; do not add schema or `summary_counts` contract changes.
- Keep lifecycle/context banners specialized and let the decision zone own the dominant explanation to avoid duplicated operator copy.
- Extend current Monitoring and authorization suites and keep multi-cause fixture helpers local or opt-in.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/`:
- `research.md`: implementation-seam decisions, risks, and rejected alternatives
- `data-model.md`: logical model for the derived governance run summary, dominant-cause breakdown, and affected-scale cues
- `contracts/governance-run-summaries.logical.openapi.yaml`: internal logical contract for canonical operations list/detail rendering requirements
- `quickstart.md`: focused verification workflow for manual and automated validation
Design decisions:
- No schema migration is required; all summary state remains derived.
- The primary implementation seam is canonical run detail plus a small helper under `App\Support\OpsUx`, not a new cross-domain UI framework.
- Existing Filament action topology, route shape, authorization behavior, and destructive-action semantics remain unchanged.
- `OperationUxPresenter` remains the façade for memoized governance explanation state on run detail.
- Existing technical sections such as count diagnostics, failure payloads, evidence-gap detail, and artifact-truth detail remain available but must become secondary to the new summary block.
## 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 focused and does not introduce new write paths, Graph calls, assets, or authorization semantics.
- PASS — Livewire v4.0+ and Filament v5 constraints remain unchanged, no provider registration move is required, `OperationRunResource` remains non-searchable, and no new destructive actions or assets are introduced.
## Implementation Strategy
### Phase A — Introduce One Bounded Governance Run Summary Seam
**Goal**: Derive one operator-first run-detail summary without creating a second truth source.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php` | Add a small value object carrying headline, dominant cause, affected scale, trust statement, secondary causes, and next action |
| A.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Derive the summary from `OperationRun`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `summary_counts`, and run context |
| A.3 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Reuse existing memoization to expose the summary on canonical run detail without adding a new cache family |
### Phase B — Rewire Canonical Run Detail Around The First Decision
**Goal**: Make the decision zone lead with humanized diagnostic summaries and push raw diagnostics down.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Filament/Resources/OperationRunResource.php` | Update the enterprise-detail decision zone to render the new summary, affected-scale cue, and processing-versus-artifact split ahead of technical sections |
| B.2 | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep scope, lifecycle, and restore banners specialized while removing duplicated dominant-cause copy from banner-level messaging |
| B.3 | `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` or existing enterprise-detail view partials | Ensure the default reading order is summary first, supporting facts second, diagnostics third |
### Phase C — Add Stable Rules For Covered Governance Run Families
**Goal**: Keep summary language and affected-scale cues stable across the five scoped governance families.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add baseline-capture rules for blocked prerequisite, zero-subject capture, and unusable snapshot outcomes |
| C.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add baseline-compare rules for suppressed output, ambiguous matches, evidence gaps, and strategy failures |
| C.3 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add evidence snapshot, tenant review, and review-pack generation rules using existing related artifact truth plus run context |
| C.4 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add one stable dominant-cause ranking rule so tied degraded runs do not reorder arbitrarily between renders |
### Phase D — Preserve Tenant Safety, Related Links, and Existing Action Topology
**Goal**: Improve explanation without changing route, RBAC, or action behavior.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep canonical back-link lineage, active-tenant continuity, and grouped related navigation intact |
| D.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Ensure summary output suppresses inaccessible artifact or tenant hints when related navigation is not allowed |
| D.3 | Existing authorization tests and related-link helpers | Keep `404` vs `403` semantics unchanged and verify no new mutation affordances appear |
### Phase E — Protect The Surface With Focused Regression Coverage
**Goal**: Add the smallest test set that locks summary order, multi-cause behavior, zero-output runs, and authorization safety.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` | Add unit coverage for dominant-cause ranking, affected-scale derivation, and next-step category mapping |
| E.2 | `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` | Add end-to-end run-detail coverage for multi-cause degraded runs, all-zero runs, cross-family parity, and diagnostics-secondary ordering |
| E.3 | `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` and `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php` | Update existing assertions to match final summary-first wording and remove brittle duplication gaps |
| E.4 | `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` and `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php` | Extend canonical route coverage for tenant-safe summary rendering and inaccessible related navigation |
| E.5 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus focused Pest runs | Run formatting and the narrowest proving commands before implementation close-out |
## Key Design Decisions
### D-001 — Canonical run detail remains the only detailed run-inspection surface
The feature improves the current canonical Monitoring detail page instead of creating a second run viewer or a special governance-only route.
### D-002 — Existing truth and explanation envelopes remain canonical
`ArtifactTruthEnvelope` and `OperatorExplanationPattern` remain the semantic source of truth. The new summary layer only ranks and presents them for this one surface.
### D-003 — Affected scale stays derived from existing persisted signals
`summary_counts`, run `context`, failure summaries, and related artifact truth are sufficient inputs. The plan explicitly avoids schema changes or new count contracts.
### D-004 — Banners stay specialized; the decision zone owns the main explanation
Context, lifecycle, or restore-continuation banners may still appear, but the dominant cause and next-step explanation must live in the decision zone so the page does not say the same thing twice.
### D-005 — Shared fixtures stay opt-in
Multi-cause or zero-output scenario builders should remain local to the Monitoring suite unless a second real consumer proves they belong in a shared concern.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| The new summary duplicates existing banner copy and makes the page louder instead of calmer | High | Medium | Keep banners specialized and let the decision zone own dominant explanation text |
| Dominant-cause ranking changes arbitrarily between equivalent multi-cause runs | High | Medium | Encode one explicit ranking rule and cover it with unit tests plus one multi-cause feature test |
| Affected-scale cues drift by operation family and become inconsistent | Medium | Medium | Centralize scale mapping in the builder and reuse it across all covered run families |
| Shared fixtures or helper defaults silently hide required run context | Medium | Medium | Require explicit type, outcome, reason, and related artifact context in new scenario builders |
| Summary copy leaks inaccessible tenant or artifact hints on canonical `/admin` routes | High | Low | Keep authorization tests on related links and summary rendering together and suppress inaccessible context |
## Test Strategy
- Add one new focused feature suite for governance run summaries and keep it scoped to canonical Monitoring run detail.
- Add one narrow unit suite for dominant-cause and affected-scale derivation only if a dedicated builder is introduced.
- Reuse existing Monitoring and authorization suites for regression coverage instead of creating browser or heavy-governance breadth.
- Keep `BuildsGovernanceArtifactTruthFixtures` opt-in and add any multi-cause builder locally to the Monitoring suite first.
- Preserve DB-only rendering guarantees on canonical run detail while adjusting the visible summary hierarchy.

View File

@ -0,0 +1,147 @@
# Quickstart: Humanized Diagnostic Summaries for Governance Operations
## Goal
Validate that canonical governance operation run detail now answers the first operator question with one dominant summary, one short reason, one affected-scale cue where available, and one next step, while keeping raw diagnostics secondary and preserving current authorization and navigation semantics.
## Prerequisites
1. Start Sail if it is not already running.
2. Ensure the acting user is a valid workspace member and is entitled to the target tenant where the run is tenant-bound.
3. Prepare representative runs for these cases:
- blocked baseline capture with no usable inventory basis
- baseline compare with ambiguous matches or evidence gaps
- evidence snapshot generation with stale or incomplete output
- tenant review composition with missing sections or stale evidence
- review-pack generation with internal-only or blocked outcome
- one multi-cause degraded run
- one zero-output or all-zero run that must not read as green
## Focused Automated Verification
Run formatting first:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
Then run the smallest proving set:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact \
tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php \
tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php \
tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php \
tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php \
tests/Feature/RunAuthorizationTenantIsolationTest.php \
tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php \
tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php
```
If the new focused suite is not yet isolated, run the Monitoring subset instead:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/
```
## Manual Validation Pass
### 1. Canonical run detail entry path
Open `/admin/operations` and drill into a governance run.
Confirm that:
- row navigation remains the inspect model,
- no new row or header action appears,
- and arriving from tenant context does not silently widen back to all-tenant semantics.
### 2. Baseline capture blocked by prerequisite
Open a blocked baseline-capture run.
Confirm that:
- the page leads with `no baseline was captured`-style meaning,
- the missing prerequisite appears before raw payloads,
- execution status and artifact usability are visible as separate facts,
- and raw diagnostics remain lower on the page.
### 3. Baseline compare with ambiguity or suppressed output
Open a baseline-compare run with evidence gaps, ambiguous matches, or suppressed output.
Confirm that:
- the first summary names the compare outcome and its trust limitation,
- the dominant cause is understandable without raw JSON,
- any affected-scale cue is visible when supported by stored counts or gap detail,
- and `0 findings` or zero-output does not read as an all-clear.
### 4. Evidence snapshot generation
Open a run that produced stale or incomplete evidence.
Confirm that:
- processing success does not imply trustworthy evidence,
- the page states the evidence limitation before technical payloads,
- and next-step guidance points to the right recovery action.
### 5. Tenant review composition and review-pack generation
Open one review-compose run and one review-pack-generation run.
Confirm that:
- review generation can explain missing sections or stale evidence without JSON,
- pack generation can explain internal-only or blocked shareability outcomes,
- and related artifact links remain available only when the actor is entitled to them.
### 6. Multi-cause degraded run
Open a run with two or more stored degraded causes.
Confirm that:
- one dominant cause is shown first,
- at least one secondary cause is still discoverable,
- and the ordering is stable across reloads.
### 7. Cross-family parity
Open two covered governance runs from different families that share the same dominant cause class.
Confirm that:
- the same cause class keeps the same primary reading direction,
- the next-step category stays consistent where the persisted truth supports the same operator action,
- and cross-family wording does not drift into conflicting operator guidance.
### 8. Authorization and tenant safety
Confirm that:
- non-members still receive deny-as-not-found behavior,
- in-scope members lacking capability still receive `403` where expected,
- summary text does not leak inaccessible tenant or artifact hints,
- and `OperationRun` remains non-searchable.
### 9. Ten-second scan check
Timebox the first visible scan of one blocked, one degraded, and one zero-output governance run detail page.
Confirm that within 10-15 seconds an operator can determine:
- what happened,
- whether the resulting artifact is trustworthy enough to act on,
- what was affected when the stored data supports that cue,
- and what the next step is,
without opening diagnostic sections.
## Final Verification Notes
- Keep diagnostics present but secondary.
- Do not add retry, cancel, force-fail, or other intervention controls as part of this slice.
- If a manual reviewer sees the same dominant-cause copy both in a banner and in the decision zone, treat that as a regression and tighten the summary ownership.

View File

@ -0,0 +1,49 @@
# Research: Humanized Diagnostic Summaries for Governance Operations
## Decision 1: Keep canonical governance run detail on the existing Monitoring viewer and detail resource
- **Decision**: Reuse `OperationRunResource` and `TenantlessOperationRunViewer` as the single canonical run-detail surface for Spec 220 instead of creating a new governance-only viewer.
- **Rationale**: The repo already routes canonical Monitoring run detail through these seams and already has the right RBAC, action-surface, and navigation guardrails in place. The problem is explanation order and summary quality, not missing routing or missing surface ownership.
- **Alternatives considered**:
- Create a second governance-specific run-detail page. Rejected because it would duplicate route ownership, action hierarchy, and authorization semantics for one existing surface.
- Add page-local partials only in the Blade template. Rejected because the run-detail summary needs stable derivation rules, not just another rendering layer.
## Decision 2: Treat `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` as the canonical semantic inputs
- **Decision**: Build the new summary from the existing `ArtifactTruthEnvelope`, `OperatorExplanationPattern`, and reason-translation envelopes instead of introducing a second semantic source.
- **Rationale**: The repo already derives artifact truth and operator explanation for `OperationRun` records, including governance families like `baseline.capture`, `baseline.compare`, `tenant.evidence.snapshot.generate`, `tenant.review.compose`, and `tenant.review_pack.generate`. Reusing that chain preserves existing truth ownership and keeps the new work downstream and bounded.
- **Alternatives considered**:
- Add a new persisted summary state to `operation_runs`. Rejected because the desired summary is fully derivable from current persisted truth and would create drift risk.
- Put all summary logic directly inside `OperationRunResource`. Rejected because it would bury operation-family rules inside Filament schema code and make tests brittle.
## Decision 3: Add one bounded `GovernanceRunDiagnosticSummary` seam only if affected-scale and dominant-cause rules cannot stay in the current presenter flow
- **Decision**: If the current detail seams cannot cleanly express dominant cause, affected scale, and secondary-cause breakdown, add one small value object plus builder under `App\Support\OpsUx` and expose it through `OperationUxPresenter`.
- **Rationale**: Spec 220 needs more than current badges and explanation labels. It needs one stable first-pass summary, especially for multi-cause degraded runs and all-zero runs. A small run-detail-specific helper is justified because the work is limited to one existing surface and several real operation families already consume the same route.
- **Alternatives considered**:
- Extend `ArtifactTruthPresenter` to own all run-detail ranking logic. Rejected because artifact truth is broader than this one run-detail question and should remain canonical truth, not surface-specific emphasis logic.
- Build a generic cross-product explanation framework. Rejected because the spec is explicitly scoped to canonical governance run detail.
## Decision 4: Derive affected-scale cues from existing `summary_counts`, run context, and related artifact truth
- **Decision**: Affected scale must come from existing persisted signals such as `summary_counts`, known run-context payloads, failure summaries, and related artifact summaries. No schema change or count-contract expansion is planned.
- **Rationale**: Covered operation families already persist enough context to support statements like ambiguous subject matches, missing sections, partial evidence dimensions, or zero captured subjects. The missing work is ranking and presenting those signals consistently.
- **Alternatives considered**:
- Add new operation-specific summary fields or nested count structures. Rejected because Ops-UX already constrains `summary_counts` to flat numeric keys, and the feature does not need new persistence.
- Omit affected-scale cues entirely. Rejected because the spec explicitly requires the page to explain what was affected, not just why it failed.
## Decision 5: Keep banners specialized and let the decision zone own the dominant explanation
- **Decision**: Existing canonical context, lifecycle, blocked-execution, and restore-continuation banners remain specialized. The main humanized summary must live in the decision zone so the page does not duplicate dominant-cause copy.
- **Rationale**: The current run detail already has banner-level messaging. Adding another banner or repeating the same explanation in two places would increase attention load instead of reducing it. The summary should become the first read inside the decision zone, with banners reserved for scope, stale lifecycle, and special restore continuity contexts.
- **Alternatives considered**:
- Add a new top-of-page summary banner. Rejected because it would compete with existing lifecycle and context banners.
- Remove existing banners entirely. Rejected because they already communicate valid scope or lifecycle information outside the core diagnostic summary.
## Decision 6: Extend current Monitoring and authorization suites and keep multi-cause fixtures local first
- **Decision**: Reuse existing Monitoring, Filament, and authorization suites; add one new focused `GovernanceOperationRunSummariesTest` plus one narrow unit seam if a builder is introduced. Keep multi-cause fixture builders local to the Monitoring suite unless another consumer emerges.
- **Rationale**: The repo already has substantial run-detail coverage, including hierarchy assertions, artifact-truth rendering, and `404` vs `403` semantics. The main gaps are multi-cause degraded runs, all-zero runs, and cross-family consistency. Those gaps can be covered without creating a new heavy or browser test family.
- **Alternatives considered**:
- Rely mainly on browser tests. Rejected because the current feature is better proven through existing Livewire and feature suites.
- Move multi-cause builders into shared fixture concerns immediately. Rejected because only Spec 220 currently needs those seeds and shared defaults would be risky.

View File

@ -0,0 +1,238 @@
# Feature Specification: Humanized Diagnostic Summaries for Governance Operations
**Feature Branch**: `220-governance-run-summaries`
**Created**: 2026-04-20
**Status**: Draft
**Input**: User description: "Humanized Diagnostic Summaries for Governance Operations"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Governance operation run-detail pages already carry correct outcome, reason, and artifact-truth semantics, but the first useful explanation still often lives in raw JSON or low-level diagnostic sections.
- **Today's failure**: An operator can open a run that reads `Completed with follow-up`, `Partial`, or `Blocked` and still has to infer the real business meaning: what was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is trustworthy enough to act on.
- **User-visible improvement**: Governance run detail leads with one human-readable summary that explains impact, dominant cause, artifact trustworthiness, and next action before any raw diagnostics.
- **Smallest enterprise-capable version**: Add one bounded humanized summary layer to canonical governance run detail only, reusing existing outcome taxonomy, reason translation, artifact-truth semantics, and explanation patterns without changing persistence, lifecycle ownership, or action inventory.
- **Explicit non-goals**: No operations-list redesign, no dashboard overhaul, no new persistence for summaries, no removal of raw JSON, no new remediation controls on run detail, and no generalized rewrite of every governance artifact page.
- **Permanent complexity imported**: One derived governance-run summary contract, one dominant-cause presentation rule set for multi-cause degraded runs, and focused regression coverage for cross-family consistency.
- **Why now**: The roadmap marks this as the next open adoption slice after Spec 214. Specs 156, 157, 158, 161, and 214 already established the language and truth model; leaving run detail technical would keep a core trust surface lagging behind the foundation work.
- **Why not local**: A page-local copy cleanup would recreate divergent run-detail dialects across baseline, evidence, review, and review-pack governance runs and would not reliably separate processing success from artifact usability.
- **Approval class**: Core Enterprise
- **Red flags triggered**: One red flag: a reusable guidance pattern across multiple governance run families. It remains acceptable because the scope is restricted to one existing canonical detail surface and does not add new persisted truth, new states, or a cross-product framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**: `/admin/operations`, `/admin/operations/{run}`
- **Data Ownership**: Tenant-bound governance `OperationRun` records remain tenant-owned operational artifacts exposed through the canonical Monitoring route. Related baseline snapshots can stay workspace-owned, while evidence snapshots, tenant reviews, and review packs remain tenant-owned. This feature changes interpretation and ordering on the canonical run-detail surface only.
- **RBAC**: Workspace membership is required for Monitoring access. Tenant entitlement is still required before revealing tenant-bound governance runs or related artifact links from the canonical route. Existing monitoring-view and related-artifact authorization rules remain authoritative. Non-members or non-entitled users remain deny-as-not-found. Members who can reach Monitoring but lack an existing related action permission remain authorization failures for that action.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When a user reaches Monitoring from an active tenant context, the operations list and related links continue to preserve that tenant context. Opening a governance run detail must not silently broaden the operator back to all tenants.
- **Explicit entitlement checks preventing cross-tenant leakage**: Humanized summaries, dominant-cause labels, affected-scale cues, and related artifact links are only rendered after workspace and tenant entitlement checks succeed for the referenced run. Inaccessible tenant-bound runs and related records behave as not found and must not leak artifact names, tenant names, or result 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 |
|---|---|---|---|---|---|---|
| Canonical Monitoring operation run detail for governance operations | yes | Native Filament + existing Monitoring detail primitives | shared governance run-detail family | detail, summary hierarchy, diagnostics hierarchy | yes | Existing diagnostic-surface exception remains; this slice only makes the first read operator-safe |
## 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 |
|---|---|---|---|---|---|---|---|
| Canonical Monitoring operation run detail for governance operations | Tertiary Evidence / Diagnostics Surface | After drilling in from a baseline, evidence, review, or pack workflow, the operator needs to understand what actually happened and what to do next | Dominant artifact impact, dominant cause, affected scale, processing-versus-artifact split, and next action | Raw JSON, complete reason-code detail, provider payloads, low-level counters, and full multi-cause evidence | Not primary because operators should usually arrive here after another surface already identified the case; this page is the deep explanation layer | Follows drill-in from governance artifact and Monitoring workflows instead of becoming a new queue | Removes the need to read badges and raw JSON before understanding the real problem |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Canonical Monitoring operation run detail for governance operations | Record / Detail / Actions | Canonical diagnostic detail | Open the related artifact or return to the source workflow with the correct next step | Explicit operation-run detail page | forbidden | Existing related navigation remains in header or contextual detail sections | none | /admin/operations | /admin/operations/{run} | Workspace context, active tenant context when present, related artifact type, run family | Operation runs / Operation run | Dominant artifact impact, dominant cause, affected scale, and next action before raw diagnostics | diagnostic_exception - canonical run detail remains the deepest evidence surface, so raw diagnostics stay present, but they must no longer lead the page |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Canonical Monitoring operation run detail for governance operations | Workspace manager or entitled tenant operator | Diagnose why a governance run produced a trustworthy, limited, blocked, or unusable artifact and decide the correct follow-up | Canonical detail | What happened, how much was affected, can I trust the resulting artifact, and what should I do next? | Dominant artifact-impact statement, dominant cause, affected scale, processing-versus-artifact split, next-step guidance, and related artifact context | Raw JSON, full reason-code inventory, provider payloads, low-level counters, and complete multi-cause diagnostics | execution outcome, artifact usability, completeness or reliability, dominant cause, actionability | None on this page; any linked mutations keep their original mutation scopes on their native surfaces | Open related artifact, inspect diagnostics | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Run detail is semantically correct but still too technical for first-pass operator decisions, which allows false-green or ambiguous readings on a core governance troubleshooting surface.
- **Existing structure is insufficient because**: Existing badges, reason translation, and raw diagnostic payloads still force operators to synthesize impact, trust, and next action themselves. Local copy tweaks would drift by run family and would not reliably separate execution throughput from artifact trustworthiness.
- **Narrowest correct implementation**: Add one bounded summary contract for governance operation run detail only, derived from the existing truth and explanation foundations, while preserving all diagnostics beneath it.
- **Ownership cost**: Ongoing maintenance of one shared summary mapping, one stable dominant-cause breakdown rule set, and focused regression coverage for the covered governance run families.
- **Alternative intentionally rejected**: Per-page copy patches and a broader operations redesign. The first is too weak and inconsistent; the second is unnecessary for the current operator problem.
- **Release truth**: Current-release truth. This spec makes an existing trust surface readable now instead of preparing a future architecture layer.
### 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 what operators see on the canonical Monitoring run-detail page. Focused feature coverage over seeded governance run scenarios is sufficient to prove explanation hierarchy, cause breakdown, and authorization safety without introducing browser or heavy-governance breadth.
- **New or expanded test families**: Expand Monitoring feature coverage for governance run detail across baseline capture, baseline compare, evidence snapshot generation (`tenant.evidence.snapshot.generate`), tenant review composition (`tenant.review.compose`), and review-pack generation (`tenant.review_pack.generate`). Add one positive and one negative authorization case for tenant-bound governance runs on the canonical route.
- **Fixture / helper cost impact**: Low-to-moderate. Tests can reuse existing workspace, tenant, entitlement, and `OperationRun` setup, but need explicit seeded cases where execution outcome and artifact usability diverge, plus multi-cause degraded runs.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: monitoring-state-page
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions that summary-first hierarchy appears before raw diagnostics and that multi-cause degraded runs stay human-readable.
- **Reviewer handoff**: Reviewers must confirm that run detail leads with one dominant explanation, that processing success never reads as automatic artifact success, that raw JSON remains secondary, that a positive and negative authorization case exist, and that the proof stays inside focused Monitoring feature coverage.
- **Budget / baseline / trend impact**: Low increase in Monitoring feature assertions only; no new heavy or browser baseline is expected.
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Understand the dominant problem fast (Priority: P1)
An operator opens a governance run detail page and needs to understand the dominant problem and next step without reading raw JSON.
**Why this priority**: This is the core trust outcome. If the first read remains technical, the feature has not delivered its value.
**Independent Test**: Can be fully tested by opening seeded governance runs on the canonical Monitoring detail route and verifying that an operator can identify what happened and what to do next from the default-visible summary alone.
**Acceptance Scenarios**:
1. **Given** a baseline compare run completed with follow-up because subject matching was ambiguous, **When** an operator opens the run detail page, **Then** the page states that the compare finished but the result is only partially trustworthy, names ambiguous matching as the dominant cause, and points the operator to scope review before any raw diagnostics.
2. **Given** a baseline capture run is blocked because no usable inventory basis exists, **When** an operator opens the run detail page, **Then** the page states that no baseline was captured, explains the missing prerequisite, and points to the prerequisite action before any raw JSON.
---
### User Story 2 - Separate processing success from artifact trust (Priority: P2)
An operator needs technically successful processing counts to remain visibly separate from whether the resulting artifact is usable, shareable, or decision-grade.
**Why this priority**: False-green interpretations come from execution success reading like artifact success.
**Independent Test**: Can be fully tested by reviewing governance runs where processing completed but the resulting artifact stayed stale, limited, internal-only, or otherwise not decision-grade.
**Acceptance Scenarios**:
1. **Given** an evidence snapshot generation run processed records successfully but produced a stale or incomplete snapshot, **When** an operator opens run detail, **Then** the page shows processing success separately from evidence usability and does not headline the run as unconditional success.
2. **Given** a review-pack generation run completed technically but the resulting pack is only suitable for internal follow-up, **When** an operator opens run detail, **Then** the page explains the pack outcome separately from the run completion state and names the correct follow-up.
---
### User Story 3 - Read multi-cause degraded runs without flattening (Priority: P3)
An operator needs a degraded governance run with several contributing causes to stay understandable without collapsing into one vague abstract state.
**Why this priority**: Multi-cause degraded runs are where operator trust collapses fastest if the detail page is too generic.
**Independent Test**: Can be fully tested by opening a seeded multi-cause degraded governance run and verifying that the page names one dominant cause first while preserving additional cause context in a secondary breakdown.
**Acceptance Scenarios**:
1. **Given** a tenant review generation run is limited by stale evidence and missing sections, **When** an operator opens run detail, **Then** the page shows one dominant cause with affected scale, preserves the second cause in secondary detail, and provides a next step that matches the dominant blocker.
2. **Given** a governance run contains both retryable and structural issues, **When** an operator opens run detail, **Then** the default summary distinguishes the dominant follow-up path instead of flattening all causes into one generic inspection message.
### Edge Cases
- A governance run can complete technically and still leave no decision-grade artifact. The page must explain that divergence directly instead of treating all-zero or fully processed counters as an all-clear.
- A governance run can have no persisted related artifact because input was missing or output was intentionally suppressed. The summary must explain the absence without requiring a raw payload.
- Multiple causes can have similar scale. The page must apply one stable dominant-cause rule so summary ordering does not become arbitrary between otherwise equivalent runs.
- Raw diagnostics can be unavailable, collapsed, or intentionally deferred. The first-pass summary must remain understandable from the persisted run truth alone.
- Scheduled or system-initiated governance runs can appear on the same page. The summary must stay humanized without implying that terminal user notifications or interactive start flows changed.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls, new mutation flows, or new scheduled or queued work. It changes the explanation hierarchy on the canonical Monitoring detail surface for already persisted governance runs.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded interpretation layer because direct mapping from existing outcome badges, reason labels, and raw context still forces operators to synthesize trust and next action themselves. A narrower per-family copy fix is insufficient because the same governance run families would drift apart. No new persistence, state family, or artifact truth source is added.
**Constitution alignment (TEST-GOV-001):** Proof remains in focused feature coverage for Monitoring run detail. No new heavy-governance or browser family is required. Fixture cost stays explicit and limited to seeded run scenarios where execution outcome, artifact usability, and dominant cause differ.
**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycle rules remain unchanged. The feature does not change the three feedback surfaces, does not change `OperationRun.status` or `OperationRun.outcome` ownership, and does not introduce new `summary_counts` keys or non-numeric summary values. Scheduled or system-run behavior remains unchanged, including initiator-null notification rules. New regression guards focus on run-detail explanation order and summary-count meaning, not lifecycle transitions.
**Constitution alignment (RBAC-UX):** The affected authorization plane is the workspace-admin `/admin` Monitoring plane with tenant-entitlement enforcement for tenant-bound governance runs. Non-members or non-entitled viewers continue to receive 404. Members who can reach Monitoring but lack a currently required related action permission continue to receive 403 for that action. Existing server-side authorization remains authoritative for related artifact links and any linked mutation surfaces. Global search behavior is unchanged; `OperationRun` remains non-searchable and tenant-safe.
**Constitution alignment (OPS-EX-AUTH-001):** No `/auth/*` behavior is introduced or broadened by this feature.
**Constitution alignment (BADGE-001):** Any changed status emphasis on run detail continues to use centralized outcome, reason, and artifact-truth semantics. This feature changes ordering and explanation, not badge ownership or ad-hoc color rules.
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament detail primitives, sections, infolist-style summary areas, and existing Monitoring detail components. Local replacement markup for status language is intentionally avoided. Semantic emphasis stays in shared truth primitives and summary ordering rather than page-local color or border rules.
**Constitution alignment (UI-NAMING-001):** The target object is the operation run. Primary summary language uses operator-facing terms such as completed with follow-up, blocked by prerequisite, partially trustworthy result, stale evidence basis, or internal-only pack outcome. Implementation-first terms such as raw reason-code slugs, payload keys, or support-tier labels remain secondary diagnostics only.
**Constitution alignment (DECIDE-001):** The affected surface remains a Tertiary Evidence / Diagnostics Surface. It does not become a new primary queue. Its human-in-the-loop purpose is to make one drilled-in governance case understandable without further reconstruction. Immediate visibility must include impact, dominant cause, trust direction, and next action. Raw diagnostics remain preserved but explicitly secondary.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The chosen action-surface class is record/detail because the operator is already inside one explicit run. The most likely next action is to open the related artifact or return to the source workflow with the correct next step. The one primary inspect model remains the existing operation-run detail page. There is no row click on the detail surface. Pure navigation stays in existing related links and does not compete with mutation. No destructive actions are added. Canonical routes remain `/admin/operations` and `/admin/operations/{run}`. Scope signals remain workspace context, tenant context when relevant, and related artifact family. The canonical noun remains `Operation run`.
**Constitution alignment (ACTSURF-001 - action hierarchy):** No header, row, bulk, or workbench action inventory changes are introduced. The feature must not use explanation hardening as a backdoor to add retry, cancel, force-fail, or other intervention controls.
**Constitution alignment (OPSURF-001):** Default-visible content on `/admin/operations/{run}` must stay operator-first. Diagnostics are secondary and explicitly revealed below the primary summary. Status dimensions must stay distinct: execution outcome, artifact usability, dominant cause, and next-step category. Workspace and tenant context remain visible in the existing Monitoring detail shell. Any linked mutation continues to communicate its scope on the native surface where it lives.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from canonical run truth to UI is insufficient because current badges and raw payloads still require operator interpretation. This feature adds one bounded run-summary layer and does not introduce redundant truth across models, service results, presenters, wrappers, or persisted mirrors. Tests focus on business consequences: first-pass understanding, no false-green reading, and consistent next-step guidance.
**Constitution alignment (Filament Action Surfaces):** The feature modifies a Filament-backed detail surface and therefore includes a UI Action Matrix. The Action Surface Contract remains satisfied: exactly one primary inspect model exists, redundant `View` actions remain absent, empty action groups remain absent, and no destructive placement changes occur. UI-FIL-001 is satisfied with the existing diagnostic-surface exception retained.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The affected screen remains a structured detail page. Humanized summary content must live in deliberate summary sections ahead of diagnostics, not as scattered helper text. No create or edit layout changes are introduced, and no UX-001 exemption is needed beyond the already accepted diagnostic detail nature of the page.
### Functional Requirements
- **FR-220-001**: The system MUST derive a humanized governance-run summary from existing run outcome, reason translation, artifact-truth, and explanation inputs without creating a new persisted truth source.
- **FR-220-002**: Canonical governance run detail MUST lead with exactly one dominant artifact-impact statement, one short supporting reason, one next-step category, and one affected-scale cue in the default-visible summary area.
- **FR-220-003**: Governance run detail MUST keep processing success and throughput counts visibly separate from resulting artifact usability, trustworthiness, shareability, or decision-readiness.
- **FR-220-004**: For multi-cause degraded governance runs, the detail page MUST identify one dominant cause first and preserve additional causes in a secondary breakdown instead of flattening them into one generic state.
- **FR-220-005**: Next-step guidance on governance run detail MUST distinguish at least retry later, resume capture or generation, refresh prerequisite data, review scope or ambiguous matches, manually validate, and no further action when the persisted truth supports those distinctions.
- **FR-220-006**: Raw JSON, raw reason-code inventories, provider payloads, and low-level counters MUST remain available on governance run detail but MUST not be the first explanatory block.
- **FR-220-007**: The same cause class across covered governance run families MUST render with the same primary reading direction and next-step category on canonical run detail.
- **FR-220-008**: The first implementation slice MUST cover governance runs for baseline capture, baseline compare, evidence snapshot generation (`tenant.evidence.snapshot.generate`), tenant review composition (`tenant.review.compose`), and review-pack generation (`tenant.review_pack.generate`).
- **FR-220-009**: A governance run that completed technically but produced a degraded, blocked, stale, internal-only, or otherwise non-decision-grade artifact MUST explain that divergence explicitly and MUST NOT headline as unconditional success.
- **FR-220-010**: All-zero or zero-output governance runs MUST explain why no decision-grade result exists and MUST NOT read as neutral or implicit all-clear.
- **FR-220-011**: Humanized summaries, affected-scale cues, and related artifact links on canonical Monitoring run detail MUST remain tenant-safe and must not leak inaccessible tenant context or artifact hints.
- **FR-220-012**: This feature MUST NOT introduce new `OperationRun` statuses, outcomes, reason-code families, `summary_counts` keys, notification surfaces, or run-detail intervention controls.
- **FR-220-013**: Existing action inventory on operation-run detail MUST remain unchanged; humanized summaries must not add retry, cancel, force-fail, or other mutation controls.
- **FR-220-014**: Primary summary vocabulary on governance run detail MUST use the shared operator language established by Specs 156, 157, 158, 161, and 214 rather than implementation-first labels.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Canonical Monitoring operation run detail for governance operations | `apps/platform/app/Filament/Pages/Monitoring/Operations.php`; `apps/platform/app/Filament/Resources/OperationRunResource.php` | none added | Existing explicit navigation from the operations list or related links remains the only inspect model | none added | none | n/a | Existing related-artifact navigation remains; no new action labels introduced by this feature | n/a | no new audit behavior | Action Surface Contract remains satisfied. No redundant `View` action, no empty action groups, no destructive change. Existing diagnostic exception remains, but summary-first hierarchy becomes mandatory. |
### Key Entities *(include if feature involves data)*
- **Humanized Governance Run Summary**: A derived first-pass summary for one governance operation run containing the dominant artifact impact, short reason, affected scale, and next-step direction.
- **Dominant Cause Breakdown**: A derived secondary explanation that preserves additional causes when a governance run is degraded for more than one reason.
- **Artifact Impact Statement**: The operator-facing truth about whether the resulting artifact is trustworthy, limited, blocked, internal-only, stale, or otherwise unsuitable for immediate reliance, separate from execution success.
## Assumptions & Dependencies
- Specs 156, 157, 158, 161, and 214 remain the authoritative foundations for operator vocabulary, reason translation, artifact-truth semantics, explanation patterns, and governance-surface compression.
- The canonical Monitoring run viewer from Spec 144 remains the existing detail surface and data-access contract for this slice.
- Covered governance run families already persist enough reason and outcome data to drive a first-pass summary without adding new persistence.
- This spec intentionally stays on run detail and does not pull surrounding artifact list or detail surfaces back into scope.
## Non-Goals
- Redesign the operations list, Monitoring landing page, or dashboard attention surfaces.
- Add retry, cancel, force-fail, or reconcile-now controls to run detail.
- Remove raw JSON or low-level diagnostics from the run-detail page.
- Create a new lifecycle or status model for `OperationRun`.
- Expand the slice to every non-governance run family.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-220-001**: In seeded acceptance review, an operator can determine within 15 seconds from the canonical governance run-detail page what happened, whether the resulting artifact is trustworthy enough to act on, and what the next step is without opening raw diagnostics.
- **SC-220-002**: In automated coverage, 100% of covered scenarios where execution success diverges from artifact trust show those truths as separate visible statements with no contradictory headline.
- **SC-220-003**: In automated coverage, 100% of covered multi-cause degraded governance runs show one dominant cause first and preserve at least one additional cause in secondary detail.
- **SC-220-004**: In acceptance review and regression tests, raw JSON and low-level diagnostics are never the first explanatory block on the run-detail page for any covered governance run family.

View File

@ -0,0 +1,146 @@
# Tasks: Humanized Diagnostic Summaries for Governance Operations
**Input**: Design documents from `/specs/220-governance-run-summaries/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/governance-run-summaries.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes runtime behavior on a Filament-backed Monitoring detail surface, so Pest feature and unit coverage must ship with the implementation.
**Test Governance Checklist**
- Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for this surface change.
- New tests stay in focused Monitoring and unit suites; no heavy-governance or browser family is introduced.
- Shared helpers and fixtures remain opt-in, especially `BuildsGovernanceArtifactTruthFixtures`.
- Validation commands stay limited to the focused run-detail suites listed in `specs/220-governance-run-summaries/quickstart.md`.
- The declared surface profile remains `monitoring-state-page`.
- Any budget or escalation note stays inside this feature instead of becoming a follow-up spec.
## Phase 1: Setup (Shared Test Scaffolding)
**Purpose**: Create the focused test seams and fixture hooks the implementation will use.
- [X] T001 [P] Create the focused canonical run-detail feature suite and local scenario helpers for zero-output and multi-cause runs in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
- [X] T002 [P] Create the focused summary-derivation unit suite in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
- [X] T003 [P] Extend only generic opt-in shared governance fixture builders for blocked, stale, and internal-only artifact cases in `apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared derived-summary seam that all user stories build on.
**⚠️ CRITICAL**: No user story work should start until this phase is complete.
- [X] T004 Create the derived summary value object in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php`
- [X] T005 Create the shared summary builder with canonical `OperationRun`, artifact-truth, reason, and explanation inputs in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
- [X] T006 Wire memoized governance summary access into `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
- [X] T007 [P] Add guard coverage that summary derivation preserves canonical `summary_counts` meaning and does not invent new count keys in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
- [X] T008 [P] Extend canonical operator-language assertions and explicit next-step category matrix coverage for `retry later`, `resume capture or generation`, `refresh prerequisite data`, `review scope or ambiguous matches`, `manually validate`, and `no further action` in `apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php` and `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
**Checkpoint**: The shared summary seam exists, is memoized through the current Ops UX presenter, and is guarded against count-contract drift.
---
## Phase 3: User Story 1 - Understand the dominant problem fast (Priority: P1) 🎯 MVP
**Goal**: Make the canonical governance run-detail page explain the dominant problem, affected scale, and next step before any raw diagnostics.
**Independent Test**: Open seeded baseline-capture and baseline-compare runs on `/admin/operations/{run}` and confirm the default-visible summary answers what happened and what to do next without opening diagnostic sections.
### Tests for User Story 1
- [X] T009 [P] [US1] Add feature scenarios for baseline-capture and baseline-compare summary-first hierarchy, no new header actions, and zero-output messaging in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
- [X] T010 [P] [US1] Add unit cases for dominant headline, supporting reason, affected-scale cue, and next-step selection for baseline-capture and baseline-compare runs in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Implement `baseline.capture` and `baseline.compare` summary mappings in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
- [X] T012 [US1] Expose baseline summary facts through the memoized presenter API in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
- [X] T013 [US1] Render the default-visible summary block before technical diagnostics in `apps/platform/app/Filament/Resources/OperationRunResource.php`
- [X] T014 [US1] Keep canonical context, lifecycle, and restore banners specialized without duplicating the dominant explanation in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T015 [US1] Preserve summary-first page-shell order for canonical run detail in `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
- [X] T016 [US1] Update summary fallback expectations for the new first-read hierarchy in `apps/platform/tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php`
- [X] T017 [US1] Update run-detail hierarchy assertions so diagnostics stay secondary in `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
**Checkpoint**: Baseline capture and baseline compare runs are readable from the summary block alone, with diagnostics preserved but no longer leading the page.
---
## Phase 4: User Story 2 - Separate processing success from artifact trust (Priority: P2)
**Goal**: Keep execution completion visible while clearly separating whether the resulting artifact is trustworthy, limited, stale, or internal-only.
**Independent Test**: Open seeded evidence-snapshot and review-pack runs where processing completed but the artifact is not decision-grade, and confirm the page shows those truths as separate visible statements.
### Tests for User Story 2
- [X] T018 [P] [US2] Add feature scenarios for evidence-snapshot and review-pack runs that separate processing completion from artifact trust in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
- [X] T019 [P] [US2] Add regression assertions for execution-outcome versus artifact-impact separation in `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
- [X] T020 [P] [US2] Add positive and negative authorization coverage for tenant-safe summary rendering and related links in `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
### Implementation for User Story 2
- [X] T021 [US2] Implement `tenant.evidence.snapshot.generate` and `tenant.review_pack.generate` summary mappings with distinct execution and artifact-impact facts in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
- [X] T022 [US2] Render separated execution outcome and artifact-impact facts in `apps/platform/app/Filament/Resources/OperationRunResource.php`
- [X] T023 [US2] Keep related artifact navigation and tenant-context continuity aligned with summary copy in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T024 [US2] Extend canonical route isolation assertions for deny-as-not-found and in-scope `403` behavior in `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`
**Checkpoint**: A technically completed run can no longer read like unconditional success when the artifact itself is stale, limited, or internal-only.
---
## Phase 5: User Story 3 - Read multi-cause degraded runs without flattening (Priority: P3)
**Goal**: Keep degraded governance runs understandable by showing one dominant cause first while preserving secondary causes and affected-scale context.
**Independent Test**: Open a seeded multi-cause tenant-review run on `/admin/operations/{run}` and confirm the page shows one dominant cause first, preserves secondary causes, and keeps the same ordering across reloads.
### Tests for User Story 3
- [X] T025 [P] [US3] Add feature scenarios for tenant-review multi-cause degraded runs, stable dominant-cause ordering, and cross-family parity for the same cause class across at least two covered governance families in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`
- [X] T026 [P] [US3] Add unit cases for dominant-cause ranking, secondary causes, and affected-scale confidence in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`
### Implementation for User Story 3
- [X] T027 [US3] Implement `tenant.review.compose` multi-cause summary mapping and shared ranking rules across covered governance families in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
- [X] T028 [US3] Render secondary-cause breakdown and affected-scale detail without flattening the dominant explanation in `apps/platform/app/Filament/Resources/OperationRunResource.php`
- [X] T029 [US3] Suppress inaccessible tenant and artifact hints in summary text and related-navigation branches in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
- [X] T030 [US3] Keep canonical run-detail banners and page-shell copy free of duplicated multi-cause messaging in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T031 [US3] Extend authorization surface assertions so inaccessible related context never leaks through summary or navigation output in `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
**Checkpoint**: Multi-cause degraded runs stay human-readable, deterministically ordered, and tenant-safe.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final guardrail review, formatting, focused validation, and manual smoke.
- [X] T032 [P] Review monitoring-state-page guardrail coverage, lane assignment, and fixture-cost notes against `specs/220-governance-run-summaries/plan.md` and `specs/220-governance-run-summaries/quickstart.md`
- [X] T033 [P] Format changed PHP and Blade files including `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
- [X] T034 Run the canonical proving commands for `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`, `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`, `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`, and `apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php`
- [X] T035 [P] Execute the manual smoke checks for summary-first hierarchy, zero-output runs, multi-cause runs, cross-family parity, and tenant-safe related links in `specs/220-governance-run-summaries/quickstart.md`
---
## Dependencies
- Setup tasks `T001-T003` can begin immediately.
- Foundational tasks `T004-T008` depend on setup and block all story work.
- User Story 1 depends on Phase 2 and is the MVP slice.
- User Story 2 depends on Phase 2 and the shared summary rendering established in User Story 1 because it extends the same builder and canonical detail surface.
- User Story 3 depends on Phase 2 and should follow User Story 1 because it extends the same ranking and rendering seams; it can overlap with late User Story 2 test work once the shared builder contract is stable.
- Polish tasks depend on all user stories being complete.
## Parallel Execution Examples
- **US1**: Run `T009` and `T010` together; after `T011-T012`, split `T013`, `T014`, and `T015` across different files.
- **US2**: Run `T018`, `T019`, and `T020` together; after `T021`, split `T022`, `T023`, and `T024` across resource, page, and authorization files.
- **US3**: Run `T025` and `T026` together; after `T027`, split `T028`, `T029`, and `T030` while keeping `T031` as the final authorization proof.
## Implementation Strategy
- Finish Setup and Foundational phases first so the derived summary seam and opt-in fixtures are stable.
- Deliver User Story 1 as the MVP because it provides the first operator-visible improvement on canonical run detail.
- Extend the same seam through User Story 2 to separate execution success from artifact trust across additional governance families.
- Finish with User Story 3 to lock deterministic multi-cause ranking and no-leak summary behavior.
- Close with formatting, focused proving commands, and the manual smoke pass documented in `quickstart.md`.

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.