feat(specs/257): implement governance decision convergence
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m1s

This commit is contained in:
Ahmed Darrazi 2026-04-30 00:29:41 +02:00
parent a74a6791ad
commit eb0f3155f0
20 changed files with 1712 additions and 61 deletions

View File

@ -105,14 +105,26 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_tenant_filter')
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter') ->label('Clear tenant filter')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null) ->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()), ->action(fn (): mixed => $this->clearTenantFilter());
];
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -698,6 +710,15 @@ private function navigationContext(): CanonicalNavigationContext
); );
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(array $overrides = []): string private function queueUrl(array $overrides = []): string
{ {
$resolvedTenant = array_key_exists('tenant', $overrides) $resolvedTenant = array_key_exists('tenant', $overrides)

View File

@ -97,14 +97,26 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_tenant_filter')
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter') ->label('Clear tenant filter')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null) ->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()), ->action(fn (): mixed => $this->clearTenantFilter());
];
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -640,6 +652,15 @@ private function navigationContext(): CanonicalNavigationContext
); );
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(): string private function queueUrl(): string
{ {
$tenant = $this->filteredTenant(); $tenant = $this->filteredTenant();

View File

@ -75,6 +75,8 @@ class GovernanceInbox extends Page
private ?bool $visibleAlertsFamily = null; private ?bool $visibleAlertsFamily = null;
private ?bool $visibleFindingExceptionsFamily = null;
public ?int $tenantId = null; public ?int $tenantId = null;
public ?string $family = null; public ?string $family = null;
@ -189,12 +191,11 @@ public function pageUrl(array $overrides = []): string
public function navigationContext(): CanonicalNavigationContext public function navigationContext(): CanonicalNavigationContext
{ {
return new CanonicalNavigationContext( return CanonicalNavigationContext::forGovernanceInbox(
sourceSurface: 'governance.inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId, tenantId: $this->tenantId,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $this->pageUrl(), backLinkUrl: $this->pageUrl(),
familyKey: $this->family,
); );
} }
@ -223,6 +224,7 @@ private function ensureAtLeastOneVisibleFamily(): void
if ( if (
$this->hasVisibleOperationsFamily() $this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== [] || $this->visibleFindingTenants() !== []
|| $this->hasVisibleFindingExceptionsFamily()
|| $this->reviewTenants() !== [] || $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily() || $this->hasVisibleAlertsFamily()
) { ) {
@ -266,6 +268,27 @@ private function hasVisibleAlertsFamily(): bool
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
} }
private function hasVisibleFindingExceptionsFamily(): bool
{
if (is_bool($this->visibleFindingExceptionsFamily)) {
return $this->visibleFindingExceptionsFamily;
}
if ($this->authorizedTenants() === []) {
return $this->visibleFindingExceptionsFamily = false;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->visibleFindingExceptionsFamily = false;
}
return $this->visibleFindingExceptionsFamily = app(WorkspaceCapabilityResolver::class)
->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
/** /**
* @return array<int, Tenant> * @return array<int, Tenant>
*/ */
@ -375,6 +398,7 @@ private function resolveRequestedFamily(): ?string
return in_array($family, [ return in_array($family, [
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -424,6 +448,7 @@ private function inboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(), visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(), reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(), canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(), selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family, selectedFamily: $this->family,
navigationContext: $this->navigationContext(), navigationContext: $this->navigationContext(),
@ -458,6 +483,7 @@ private function unfilteredInboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(), visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(), reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(), canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: null, selectedTenant: null,
selectedFamily: null, selectedFamily: null,
navigationContext: $this->navigationContext(), navigationContext: $this->navigationContext(),

View File

@ -208,6 +208,16 @@ protected function getHeaderActions(): array
returnActionName: 'operate_hub_return_finding_exceptions', returnActionName: 'operate_hub_return_finding_exceptions',
); );
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_filters') $actions[] = Action::make('clear_filters')
->label('Clear filters') ->label('Clear filters')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string
return null; return null;
} }
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant); return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
} }
public function selectedFindingUrl(): ?string public function selectedFindingUrl(): ?string
@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string
return null; return null;
} }
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant); return $this->appendQuery(
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
} }
public function clearSelectedException(): void public function clearSelectedException(): void
@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext
return CanonicalNavigationContext::fromRequest(request()); return CanonicalNavigationContext::fromRequest(request());
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function normalizeSelectedFindingExceptionId(): void private function normalizeSelectedFindingExceptionId(): void
{ {
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) { if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string
return 'danger'; return 'danger';
} }
/**
* @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

@ -15,6 +15,7 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -112,16 +113,28 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_filters')
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_filters')
->label(__('localization.review.clear_filters')) ->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->hasActiveFilters()) ->visible(fn (): bool => $this->hasActiveFilters())
->action(function (): void { ->action(function (): void {
$this->clearWorkspaceFilters(); $this->clearWorkspaceFilters();
}), });
];
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -348,9 +361,13 @@ private function latestReviewUrl(Tenant $tenant): ?string
return null; return null;
} }
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([ return $this->appendQuery(
self::DETAIL_CONTEXT_QUERY_KEY => 1, TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
]); array_replace(
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
$this->navigationContext()?->toQuery() ?? [],
),
);
} }
private function latestReviewPack(Tenant $tenant): ?ReviewPack private function latestReviewPack(Tenant $tenant): ?ReviewPack
@ -527,4 +544,30 @@ private function reviewPackAvailability(Tenant $tenant): string
return __('localization.review.available'); return __('localization.review.available');
} }
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
/**
* @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

@ -6,14 +6,15 @@
use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
@ -21,14 +22,12 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService; use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver; use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver; use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder final readonly class GovernanceInboxSectionBuilder
@ -41,6 +40,7 @@
private const FAMILY_ORDER = [ private const FAMILY_ORDER = [
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -71,6 +71,7 @@ public function build(
array $visibleFindingTenants, array $visibleFindingTenants,
array $reviewTenants, array $reviewTenants,
bool $canViewAlerts, bool $canViewAlerts,
bool $canViewFindingExceptions = false,
?Tenant $selectedTenant = null, ?Tenant $selectedTenant = null,
?string $selectedFamily = null, ?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null, ?CanonicalNavigationContext $navigationContext = null,
@ -113,6 +114,22 @@ public function build(
} }
if ($authorizedTenantsById !== []) { if ($authorizedTenantsById !== []) {
if ($canViewFindingExceptions) {
$findingExceptionsSection = $this->findingExceptionsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$findingExceptionsSection['key']] = $findingExceptionsSection;
$availableFamilies[] = [
'key' => $findingExceptionsSection['key'],
'label' => $findingExceptionsSection['label'],
'count' => $findingExceptionsSection['count'],
];
$familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count'];
}
$operationsSection = $this->operationsSection( $operationsSection = $this->operationsSection(
workspace: $workspace, workspace: $workspace,
authorizedTenants: $authorizedTenantsById, authorizedTenants: $authorizedTenantsById,
@ -191,6 +208,59 @@ public function build(
]; ];
} }
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function findingExceptionsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$pendingCount = (clone $baseQuery)
->where('status', FindingException::STATUS_PENDING)
->count();
$expiringCount = (clone $baseQuery)
->where('current_validity_state', FindingException::VALIDITY_EXPIRING)
->count();
$lapsedCount = (clone $baseQuery)
->where('status', '!=', FindingException::STATUS_PENDING)
->whereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
])
->count();
$entries = $this->orderedFindingExceptionsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext))
->all();
return [
'key' => 'finding_exceptions',
'label' => 'Finding exceptions',
'count' => $count,
'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount),
'dominant_action_label' => 'Open finding exceptions',
'dominant_action_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No finding exceptions match this tenant filter right now.'
: 'No finding exceptions need review right now.',
];
}
/** /**
* @param array<int, Tenant> $tenants * @param array<int, Tenant> $tenants
* @return array<int, Tenant> * @return array<int, Tenant>
@ -477,28 +547,10 @@ private function reviewFollowUpSection(
'label' => 'Review follow-up', 'label' => 'Review follow-up',
'count' => count($rawEntries), 'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount), 'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up', 'dominant_action_label' => 'Open customer review workspace',
'dominant_action_url' => $selectedTenant instanceof Tenant 'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( : $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant 'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.' ? 'No review follow-up is visible for this tenant filter right now.'
@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te
}); });
} }
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($authorizedTenants);
return FindingException::query()
->with([
'tenant',
'requester:id,name',
'owner:id,name',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
])
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where(function ($query): void {
$query
->where('status', FindingException::STATUS_PENDING)
->orWhereIn('status', [
FindingException::STATUS_EXPIRING,
FindingException::STATUS_EXPIRED,
])
->orWhereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
]);
});
}
private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
"case
when status = ? then 0
when current_validity_state = ? then 1
when current_validity_state = ? then 2
when current_validity_state = ? then 3
else 4
end asc",
[
FindingException::STATUS_PENDING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
FindingException::VALIDITY_EXPIRING,
],
)
->orderByRaw('case when review_due_at is null then 1 else 0 end asc')
->orderBy('review_due_at')
->orderByDesc('id');
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -727,6 +835,52 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext
]; ];
} }
/**
* @return array<string, mixed>
*/
private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array
{
$findingLabel = $exception->finding?->resolvedSubjectDisplayName()
?? 'Finding #'.$exception->finding_id;
$sublineParts = array_values(array_filter([
$exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null,
FindingExceptionResource::relativeTimeDescription($exception->review_due_at)
?? FindingExceptionResource::relativeTimeDescription($exception->expires_at),
is_string($exception->request_reason) && $exception->request_reason !== ''
? $exception->request_reason
: null,
]));
return [
'family_key' => 'finding_exceptions',
'source_model' => FindingException::class,
'source_key' => (string) $exception->getKey(),
'tenant_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null,
'tenant_label' => $exception->tenant?->name,
'headline' => $findingLabel,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => match (true) {
(string) $exception->status === FindingException::STATUS_PENDING => 0,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1,
(string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3,
default => 4,
},
'status_label' => $this->findingExceptionStatusLabel($exception),
'destination_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $exception->tenant?->external_id,
'exception' => (int) $exception->getKey(),
], static fn (mixed $value): bool => $value !== null && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array<string, mixed> * @return array<string, mixed>
@ -855,6 +1009,39 @@ private function alertsSummary(int $count): string
); );
} }
private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string
{
if ($count === 0) {
return 'No finding exceptions need review in the current scope.';
}
return sprintf(
'%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.',
$count,
$count === 1 ? '' : 's',
$pendingCount,
$expiringCount,
$lapsedCount,
);
}
private function findingExceptionStatusLabel(FindingException $exception): string
{
if ((string) $exception->status === FindingException::STATUS_PENDING) {
return 'Pending';
}
if (in_array((string) $exception->current_validity_state, [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
], true)) {
return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value();
}
return Str::of((string) $exception->status)->replace('_', ' ')->title()->value();
}
private function reviewSummary(int $followUpCount, int $changedCount): string private function reviewSummary(int $followUpCount, int $changedCount): string
{ {
$total = $followUpCount + $changedCount; $total = $followUpCount + $changedCount;

View File

@ -18,6 +18,7 @@ public function __construct(
public string $sourceSurface, public string $sourceSurface,
public string $canonicalRouteName, public string $canonicalRouteName,
public ?int $tenantId = null, public ?int $tenantId = null,
public ?string $familyKey = null,
public ?string $backLinkLabel = null, public ?string $backLinkLabel = null,
public ?string $backLinkUrl = null, public ?string $backLinkUrl = null,
public array $filterPayload = [], public array $filterPayload = [],
@ -56,12 +57,31 @@ public static function fromRequest(Request $request): ?self
sourceSurface: $sourceSurface, sourceSurface: $sourceSurface,
canonicalRouteName: $canonicalRouteName, canonicalRouteName: $canonicalRouteName,
tenantId: is_numeric($tenantId) ? (int) $tenantId : null, tenantId: is_numeric($tenantId) ? (int) $tenantId : null,
familyKey: is_string($payload['family_key'] ?? null) && (string) $payload['family_key'] !== ''
? (string) $payload['family_key']
: null,
backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null, backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null,
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null, backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
filterPayload: [], filterPayload: [],
); );
} }
public static function forGovernanceInbox(
string $canonicalRouteName,
?int $tenantId,
?string $familyKey,
string $backLinkUrl,
): self {
return new self(
sourceSurface: 'governance.inbox',
canonicalRouteName: $canonicalRouteName,
tenantId: $tenantId,
familyKey: $familyKey,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $backLinkUrl,
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -117,6 +137,7 @@ private function navPayload(): array
'source_surface' => $this->sourceSurface, 'source_surface' => $this->sourceSurface,
'canonical_route_name' => $this->canonicalRouteName, 'canonical_route_name' => $this->canonicalRouteName,
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'family_key' => $this->familyKey,
'back_label' => $this->backLinkLabel, 'back_label' => $this->backLinkLabel,
'back_url' => $this->backLinkUrl, 'back_url' => $this->backLinkUrl,
], static fn (mixed $value): bool => $value !== null && $value !== ''); ], static fn (mixed $value): bool => $value !== null && $value !== '');

View File

@ -18,7 +18,7 @@
</h1> </h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300"> <p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.
</p> </p>
</div> </div>

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps findings intake secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()
->for($tenant)
->create([
'workspace_id' => (int) $tenant->workspace_id,
'assignee_user_id' => null,
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'intake-from-governance',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'intake_findings',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'intake_findings',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'view' => 'needs_triage',
]))
->actingAs($user)
->test(FindingsIntakeQueue::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([$finding])
->assertSee('Shared unassigned work')
->assertDontSee('This workspace decision surface routes you');
});

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps my findings secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'assigned-from-governance',
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'assigned_findings',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'assigned_findings',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
]))
->actingAs($user)
->test(MyFindingsInbox::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([Finding::query()->where('subject_external_id', 'assigned-from-governance')->firstOrFail()])
->assertSee('Assigned to me only')
->assertDontSee('This workspace decision surface routes you');
});

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
it('launches the finding exceptions lane with tenant and family return context', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'governance-exception-lane',
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Governance convergence request',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions');
$response->assertOk()
->assertSee('Finding exceptions')
->assertSee('Open finding exceptions')
->assertSee('Governance convergence request')
->assertSee('nav%5Bfamily_key%5D=finding_exceptions', false)
->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false)
->assertSee('exception='.(string) $exception->getKey(), false)
->assertDontSee('Open my findings')
->assertDontSee('Open findings intake');
});

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
@ -46,6 +47,28 @@
->reopened() ->reopened()
->create(); ->create();
$exceptionFinding = Finding::factory()
->for($alphaTenant)
->riskAccepted()
->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'subject_external_id' => 'exception-governance-home',
]);
FindingException::query()->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'tenant_id' => (int) $alphaTenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Governance home exception review',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
OperationRun::factory() OperationRun::factory()
->forTenant($alphaTenant) ->forTenant($alphaTenant)
->create([ ->create([
@ -87,13 +110,15 @@
->assertOk() ->assertOk()
->assertSee('Assigned findings') ->assertSee('Assigned findings')
->assertSee('Findings intake') ->assertSee('Findings intake')
->assertSee('Finding exceptions')
->assertSee('Operations follow-up') ->assertSee('Operations follow-up')
->assertSee('Alert delivery failures') ->assertSee('Alert delivery failures')
->assertSee('Review follow-up') ->assertSee('Review follow-up')
->assertSee('Open my findings') ->assertSee('Open my findings')
->assertSee('Open finding exceptions')
->assertSee('Open terminal follow-up') ->assertSee('Open terminal follow-up')
->assertSee('Open alert deliveries') ->assertSee('Open alert deliveries')
->assertSee('Open review follow-up'); ->assertSee('Open customer review workspace');
}); });
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void { it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
@ -141,3 +166,47 @@
->assertSee('No failed alert deliveries match this tenant filter right now.') ->assertSee('No failed alert deliveries match this tenant filter right now.')
->assertDontSee('Open my findings'); ->assertDontSee('Open my findings');
}); });
it('omits the finding exceptions lane when the workspace capability is not visible', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$exceptionFinding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Hidden exception lane',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Assigned findings')
->assertDontSee('Finding exceptions')
->assertDontSee('Hidden exception lane');
});

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps the finding exceptions queue secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()
->for($tenant)
->riskAccepted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'exception-secondary-finding',
]);
$exception = FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Exception queue return context',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'finding_exceptions',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'finding_exceptions',
]),
);
$component = Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
'exception' => (int) $exception->getKey(),
]))
->actingAs($user)
->test(FindingExceptionsQueue::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertActionVisible('open_selected_exception')
->assertActionVisible('open_selected_finding')
->assertSee('Exception queue return context')
->assertSee('Focused review lane')
->assertDontSee('This workspace decision surface routes you');
expect($component->instance()->selectedExceptionUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey());
expect($component->instance()->selectedFindingUrl())
->toContain('nav%5Bsource_surface%5D=governance.inbox')
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey());
});

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('keeps the customer review workspace secondary when opened from the governance inbox', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now(),
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
tenantId: (int) $tenant->getKey(),
familyKey: 'review_follow_up',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
'tenant_id' => (string) $tenant->getKey(),
'family' => 'review_follow_up',
]),
);
Livewire::withQueryParams(array_replace($context->toQuery(), [
'tenant' => (string) $tenant->external_id,
]))
->actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
->assertActionVisible('return_to_governance_inbox')
->assertCanSeeTableRecords([$tenant->fresh()])
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
->assertSee(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY.'=1', false)
->assertSee('nav%5Bsource_surface%5D=governance.inbox', false)
->assertSee('nav%5Bfamily_key%5D=review_follow_up', false)
->assertDontSee('This workspace decision surface routes you');
});

View File

@ -52,3 +52,26 @@
->and($context?->backLinkLabel)->toBe('Back to backup set') ->and($context?->backLinkLabel)->toBe('Back to backup set')
->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8'); ->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8');
}); });
it('serializes governance inbox family context for secondary surface return links', function (): void {
$context = CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: 'filament.admin.pages.governance.inbox',
tenantId: 12,
familyKey: 'finding_exceptions',
backLinkUrl: '/admin/governance/inbox?tenant_id=12&family=finding_exceptions',
);
$roundTrip = CanonicalNavigationContext::fromRequest(Request::create('/admin/finding-exceptions/queue', 'GET', $context->toQuery()));
expect($context->toQuery()['nav'])
->toMatchArray([
'source_surface' => 'governance.inbox',
'tenant_id' => 12,
'family_key' => 'finding_exceptions',
'back_label' => 'Back to governance inbox',
])
->and($roundTrip?->sourceSurface)->toBe('governance.inbox')
->and($roundTrip?->tenantId)->toBe(12)
->and($roundTrip?->familyKey)->toBe('finding_exceptions')
->and($roundTrip?->backLinkUrl)->toBe('/admin/governance/inbox?tenant_id=12&family=finding_exceptions');
});

View File

@ -4,6 +4,7 @@
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
@ -54,6 +55,28 @@
'subject_external_id' => 'intake-finding', 'subject_external_id' => 'intake-finding',
]); ]);
$exceptionFinding = Finding::factory()
->for($alphaTenant)
->riskAccepted()
->create([
'workspace_id' => (int) $workspace->getKey(),
'subject_external_id' => 'exception-finding',
]);
FindingException::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $alphaTenant->getKey(),
'finding_id' => (int) $exceptionFinding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Needs approval',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
OperationRun::factory() OperationRun::factory()
->forTenant($alphaTenant) ->forTenant($alphaTenant)
->create([ ->create([
@ -129,6 +152,7 @@
visibleFindingTenants: [$alphaTenant, $bravoTenant], visibleFindingTenants: [$alphaTenant, $bravoTenant],
reviewTenants: [$alphaTenant, $bravoTenant], reviewTenants: [$alphaTenant, $bravoTenant],
canViewAlerts: true, canViewAlerts: true,
canViewFindingExceptions: true,
navigationContext: $context, navigationContext: $context,
); );
@ -136,6 +160,7 @@
->toBe([ ->toBe([
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -143,6 +168,7 @@
->and($payload['family_counts'])->toMatchArray([ ->and($payload['family_counts'])->toMatchArray([
'assigned_findings' => 1, 'assigned_findings' => 1,
'intake_findings' => 1, 'intake_findings' => 1,
'finding_exceptions' => 1,
'stale_operations' => 2, 'stale_operations' => 2,
'alert_delivery_failures' => 1, 'alert_delivery_failures' => 1,
'review_follow_up' => 2, 'review_follow_up' => 2,
@ -153,6 +179,9 @@
expect($sections['assigned_findings']['dominant_action_url']) expect($sections['assigned_findings']['dominant_action_url'])
->toContain('/admin/findings/my-work') ->toContain('/admin/findings/my-work')
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox') ->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
->and($sections['finding_exceptions']['dominant_action_label'])->toBe('Open finding exceptions')
->and($sections['finding_exceptions']['dominant_action_url'])->toContain('/admin/finding-exceptions/queue')
->and($sections['finding_exceptions']['entries'][0]['destination_url'])->toContain('exception='.(string) $sections['finding_exceptions']['entries'][0]['source_key'])
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up') ->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up') ->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed') ->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
@ -197,3 +226,63 @@
->and($payload['sections'][0]['count'])->toBe(0) ->and($payload['sections'][0]['count'])->toBe(0)
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter'); ->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
}); });
it('omits finding exceptions when the exception family is hidden or tenant scope is inaccessible', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$visibleTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Visible Tenant',
]);
$hiddenTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Hidden Tenant',
]);
$finding = Finding::factory()
->for($hiddenTenant)
->riskAccepted()
->create(['workspace_id' => (int) $workspace->getKey()]);
FindingException::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $hiddenTenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'requested_by_user_id' => (int) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Hidden request',
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
$builder = app(GovernanceInboxSectionBuilder::class);
$payloadWithoutCapability = $builder->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$visibleTenant, $hiddenTenant],
visibleFindingTenants: [],
reviewTenants: [],
canViewAlerts: false,
canViewFindingExceptions: false,
);
$payloadWithHiddenTenantOnly = $builder->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$visibleTenant],
visibleFindingTenants: [],
reviewTenants: [],
canViewAlerts: false,
canViewFindingExceptions: true,
);
expect(collect($payloadWithoutCapability['available_families'])->pluck('key')->all())
->not->toContain('finding_exceptions')
->and($payloadWithHiddenTenantOnly['family_counts']['finding_exceptions'] ?? null)->toBe(0)
->and($payloadWithHiddenTenantOnly['sections'])->toBe([]);
});

View File

@ -0,0 +1,37 @@
# Preparation Review Checklist: Governance Decision Surface Convergence v1
**Purpose**: Verify that the preparation package is repo-grounded, narrowly scoped, and ready for a later implementation loop.
**Created**: 2026-04-29
**Review outcome class**: Workflow Compression
**Workflow outcome**: approve for implementation
**Test-governance outcome**: keep
## Candidate Selection
- [x] CHK001 The selected slice is anchored to `docs/product/roadmap.md` (`Decision-Based Operating Foundations`) and the open-gap truth in `docs/product/implementation-ledger.md`.
- [x] CHK002 Already-specced candidates such as Spec 043, Spec 249, Spec 250, Spec 251, Spec 252, Spec 253, Spec 254, Spec 255, and Spec 256 are explicitly excluded from reopening.
- [x] CHK003 The package chooses the smallest repo-grounded slice: extend the existing governance inbox and specialist pages instead of inventing a new shell or workflow engine.
## Scope And Truth
- [x] CHK004 The spec, plan, and tasks all state that no new persistence, inbox-item truth, queue state, or mutation lane is introduced.
- [x] CHK005 The governance inbox remains the canonical workspace decision home, while specialist queues and the customer review workspace remain secondary-context surfaces.
- [x] CHK006 The finding-exceptions lane and review-consumption handoff are described as derived from existing repo truth rather than a new abstraction layer.
## UX And Authorization
- [x] CHK007 The package makes one dominant next action explicit for the governance home, preserves one dominant default action on each specialist surface, and keeps specialist pages from duplicating the workspace-level summary.
- [x] CHK008 `404` vs `403` semantics are explicit for workspace membership, tenant scope, and no-visible-family cases.
- [x] CHK009 Hidden tenants and hidden families are omitted from counts, labels, previews, and empty-state hints.
## Test Governance
- [x] CHK010 Planned proof stays in focused `Unit` plus `Feature` lanes only, with explicit validation commands.
- [x] CHK011 The tasks include explicit coverage for family assembly, arrival/return continuity, latest-published-review preference versus workspace fallback, duplicate-truth prevention, and read-only review integrity on the specialist pages.
- [x] CHK012 The checklist records the active review outcome class and workflow outcome instead of leaving readiness implicit.
## Readiness Outcome
- [x] CHK013 The package is ready for implementation only if analysis confirms that the scope remains bounded to existing governance, findings, monitoring, and review seams and that `CustomerReviewWorkspace` stays read-only.
- [x] CHK014 The package explicitly preserves the no-new-Graph-call, no-queue, no-`OperationRun`, and no-new-audit-stream constraints for this slice.
- [x] CHK015 Any broader dashboard-entry, cross-tenant, or workflow-engine follow-up is listed separately rather than hidden inside this slice.

View File

@ -0,0 +1,254 @@
# Implementation Plan: Governance Decision Surface Convergence v1
**Branch**: `257-governance-decision-convergence` | **Date**: 2026-04-29 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
## Summary
Tighten TenantPilot's decision-first operating model by converging onto the existing `GovernanceInbox` as the canonical workspace decision home, extending it with the still-missing finding-exceptions lane, and aligning the specialist findings, exceptions, and customer-review pages behind one truthful arrival and return model. The slice is intentionally read-only and reuses existing page, builder, and navigation seams instead of adding a new page shell, workflow state, or task engine.
Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no new asset bundle is expected.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing governance inbox and navigation helpers
**Storage**: PostgreSQL via existing findings, finding-exceptions, reviews, packs, alerts, and operation-run truth only
**Testing**: Pest v4 `Unit` plus `Feature` coverage
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
**Project Type**: Web application (Laravel monolith with Filament pages)
**Performance Goals**: derived DB-only page rendering, no new remote calls, and no queue or `OperationRun` start in v1
**Constraints**: no new persistence, no new page shell, no new mutation lane, no customer portal scope, no duplicate truth across equal-priority cards
**Scale/Scope**: 1 existing canonical page, 4 specialist page classes plus their Blade views, and 1 bounded section-builder extension
## Likely Affected Repo Surfaces
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
- `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- `apps/platform/app/Support/Badges/BadgeRenderer.php`
## UI / Filament & Livewire Fit
- Reuse the existing `GovernanceInbox` Filament page instead of introducing a new page class or a utility shell.
- Keep the governance home as a section-based read-only page. Add one derived exception lane and adjust review-consumption handoff copy and arrival context, but keep diagnostics and proof on the existing specialist or detail routes.
- Preserve specialist-page ownership. `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` remain the pages where lane-specific truth and existing safe actions live.
- Any state that must survive navigation or Livewire requests stays on public, query-backed, or existing session-backed state. Do not move convergence state into private page properties.
- No new resource, global-search result, or panel asset registration is planned.
## RBAC / Policy Fit
- Workspace membership remains the first gate for the governance home and all converged routes.
- Findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW`; existing inline safe actions such as claim remain on the owning specialist pages and continue to require their existing capabilities such as `Capabilities::TENANT_FINDINGS_ASSIGN`.
- The exception lane must reuse the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE` rather than inventing a second exception-view capability.
- The review-consumption handoff must reuse the current review and review-pack access rules instead of adding a new customer-review-workspace capability family.
- `404` applies to non-members and out-of-scope tenant targets. `403` applies only to in-scope members who still cannot see any converged family.
## Audit / Logging Fit
- The convergence layer stays read-only and should not add a new page-view audit stream.
- Existing mutations and downloads remain audited on their current owning surfaces.
- No new `OperationRun`, notification stream, or navigation-event ledger is required.
## Data & Query Fit
- Extend `GovernanceInboxSectionBuilder` rather than creating a new persistence or projection layer.
- The new exception lane must derive from existing `FindingException` truth and the current queue semantics, not from a copied workflow summary.
- Review-consumption handoff should keep using the current latest-published-review vs customer-review-workspace fallback logic.
- Family counts, previews, and empty-state decisions must be computed only after tenant and capability filtering, so hidden tenants and hidden lanes do not leak through aggregate counts.
- Keep any new family key local to the page and builder; do not introduce a new domain enum or persisted state family.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: governance decision home, specialist queues, customer-review routing, navigation continuity
- **State layers in scope**: page, URL-query, table/session restore
- **Audience modes in scope**: operator-MSP, customer-read-only on the existing review workspace
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the governance home, diagnostics-second on specialist pages, raw/support detail remains on existing detail paths only
- **Raw/support gating plan**: hidden by default on the governance home; existing gating remains on source/detail surfaces
- **One-primary-action / duplicate-truth control**: governance home keeps one dominant CTA per section; specialist pages keep their existing lane-owned primary action and must not duplicate the governance-home summary
- **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 planned
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the specialist findings, exception, and review pages listed above
- **Shared abstractions reused**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing source-page action-surface declarations
- **New abstraction introduced? why?**: at most one bounded convergence helper for arrival and return semantics if the existing navigation-context helper needs a thin extension; no new framework or registry is justified
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for page ownership and current routing, but not yet sufficient to make the governance home the single truthful start surface across the missing exception and review-consumption lanes
- **Bounded deviation / spread control**: no new shell or workflow engine; all implementation stays inside existing governance, findings, monitoring, reviews, and navigation seams
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` contract change
- **Central contract reused**: the already-existing stale-operations deep-link behavior remains unchanged
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: the governance home continues to list stale operations through the existing family, but this spec does not change that family's start or completion semantics
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing governance and navigation vocabulary only
- **Neutral platform terms / contracts preserved**: `Governance inbox`, `finding exceptions`, `customer review workspace`, `Open lane`, and `Back to governance inbox`
- **Retained provider-specific semantics and why**: none new
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before implementation preparation continues.*
- Inventory-first: PASS. All sections remain derived from existing findings, exceptions, reviews, alerts, and operation-run truth.
- Read/write separation: PASS. The convergence layer remains read-only and keeps mutations on existing source surfaces.
- Graph contract path: PASS. No new Graph or provider calls are introduced.
- Deterministic capabilities: PASS. Existing capability registries remain authoritative.
- Workspace and tenant isolation: PASS. Workspace membership remains first, and tenant/family omission happens before counts are exposed.
- RBAC-UX plane separation: PASS. Everything stays in `/admin`; no `/system` expansion.
- Destructive action discipline: PASS by non-use. No new destructive or risky actions are introduced.
- Global search: PASS. No new resource or search result is added.
- OperationRun / Ops-UX: PASS by non-use. No new run start or completion behavior exists.
- Data minimization: PASS. Default-visible content stays limited to family summaries, lane scope, and next action.
- Test governance: PASS. Proof remains in focused `Unit` and `Feature` lanes.
- Proportionality / no premature abstraction: PASS. The design extends an existing page and builder instead of introducing a new shell or engine.
- Persisted truth: PASS. No new table, artifact, or cached projection is introduced.
- Behavioral state: PASS. Any additional family key remains derived page state only.
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing page and navigation patterns are extended rather than bypassed.
- Provider boundary: PASS. No provider/platform seam widens.
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned.
**Gate evaluation**: PASS.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for section assembly and convergence routing, `Feature` for page visibility, family omission, and navigation continuity
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves derived-family assembly cheaply; feature coverage proves route access, family omission, tenant-prefilter continuity, and duplicate-truth prevention on existing pages
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse existing workspace, tenant, finding, exception, and review fixtures without widening into browser or heavy-governance families
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because return context and tenant-filter continuity are part of the contract
- **Closing validation and reviewer handoff**: rerun the focused commands above, verify the governance home stays read-only, and confirm specialist surfaces preserve lane-specific truth without duplicating the workspace summary
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
- **Review-stop questions**: lane fit, hidden fixture growth, accidental new shell, accidental new mutation lane, hidden leakage through counts
- **Escalation path**: `document-in-feature` for contained navigation-context notes; `reject-or-split` for any new shell or workflow-engine drift
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Test-governance outcome**: keep
- **Why no dedicated follow-up spec is needed**: the bounded convergence work remains feature-local unless future work demands a broader dashboard or portfolio action-center spec
## Rollout & Risk Controls
- Keep the governance inbox as the only primary start surface touched by this slice.
- Keep all specialist mutations on their existing pages.
- Do not widen the exception or review lane into new workflow state.
- Prefer extending the current section builder and navigation helper over adding a new orchestrator.
- Treat any attempt to add a second workspace summary banner on the specialist pages as out-of-scope drift.
## Project Structure
### Documentation (this feature)
```text
specs/257-governance-decision-convergence/
├── checklists/
│ └── requirements.md
├── spec.md
├── plan.md
└── tasks.md
```
This preparation package intentionally stays on the core artifacts plus the review checklist. The repo truth is already known, the slice adds no new persistence or external contract, and no extra research/data-model/contracts package is required to make the implementation bounded.
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/Pages/
│ │ ├── Findings/
│ │ │ ├── MyFindingsInbox.php
│ │ │ └── FindingsIntakeQueue.php
│ │ ├── Governance/
│ │ │ └── GovernanceInbox.php
│ │ ├── Monitoring/
│ │ │ └── FindingExceptionsQueue.php
│ │ └── Reviews/
│ │ └── CustomerReviewWorkspace.php
│ └── Support/
│ ├── GovernanceInbox/
│ │ └── GovernanceInboxSectionBuilder.php
│ ├── Navigation/
│ │ └── CanonicalNavigationContext.php
│ └── OperateHub/
│ └── OperateHubShell.php
└── resources/views/filament/pages/
├── findings/
│ ├── my-findings-inbox.blade.php
│ └── findings-intake-queue.blade.php
├── governance/
│ └── governance-inbox.blade.php
├── monitoring/
│ └── finding-exceptions-queue.blade.php
└── reviews/
└── customer-review-workspace.blade.php
```
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| One additional derived family in `GovernanceInboxSectionBuilder` | the exception lane still sits outside the canonical decision home | leaving exceptions on a standalone specialist page keeps the current fragmented start state |
| One bounded convergence contract for arrival and return context | specialist pages need a truthful way back to the same governance scope | page-local ad hoc back links would drift across surfaces and duplicate navigation logic |
## Proportionality Review
- **Current operator problem**: operators still have to decide between several repo-real specialist surfaces before they can begin work.
- **Existing structure is insufficient because**: the current governance home does not yet own all high-signal lanes and the specialist pages do not clearly behave as secondary contexts.
- **Narrowest correct implementation**: extend the existing governance inbox and navigation continuity instead of adding a new shell or persisted workflow engine.
- **Ownership cost created**: maintain one more derived family, one bounded convergence helper, and focused tests.
- **Alternative intentionally rejected**: a new action-center page or persisted cross-family work queue was rejected as unnecessary structure for current-release truth.
- **Release truth**: current-release workflow compression.
## Implementation Strategy
### Suggested MVP Scope
MVP = **User Story 1 + User Story 2 together**. The convergence slice only becomes meaningful once the governance home shows the missing lanes and the specialist surfaces preserve truthful return context.
### Incremental Delivery
1. Extend the governance inbox family assembly and page rendering.
2. Add convergence-aware arrival and return semantics on the specialist pages.
3. Tighten duplicate-truth prevention and calm secondary-context copy.
4. Finish with focused validation and formatting.
### Team Strategy
1. Settle the governance inbox family extension and navigation-context contract first.
2. Parallelize unit coverage for builder behavior and feature coverage for navigation continuity.
3. Serialize merges around the shared governance inbox and specialist page views so the decision-home language stays coherent.

View File

@ -0,0 +1,320 @@
# Feature Specification: Governance Decision Surface Convergence v1
**Feature Branch**: `257-governance-decision-convergence`
**Created**: 2026-04-29
**Status**: Ready for implementation
**Input**: User description: "Prepare the roadmap-fit Decision-Based Operating Foundations slice as Governance Decision Surface Convergence: use the existing governance inbox as the canonical workspace decision home, converge the still-missing exception and customer-review decision lanes, and keep the work read-only, repo-grounded, and free of new workflow state."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has multiple repo-real decision surfaces such as `MyFindingsInbox`, `FindingsIntakeQueue`, `GovernanceInbox`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`, but operators still have to decide where to start by hopping between specialist pages.
- **Today's failure**: The product has a decision-first foundation, but it still lacks one clearly dominant workspace decision home that converges the open exception lane and the customer-review follow-up lane into the same calm operating model.
- **User-visible improvement**: An authorized workspace operator lands on one canonical governance home, sees the remaining high-signal lanes in one place, and can open a specialized surface with preserved context and a truthful return path instead of reconstructing the workflow manually.
- **Smallest enterprise-capable version**: Reuse the existing `/admin/governance/inbox` page as the canonical decision home, extend it with a derived `finding_exceptions` family from existing queue truth, make customer-review follow-up handoff explicit through existing review workspace surfaces, and align `My Findings`, `Findings intake`, `Finding exceptions`, and `Customer Review Workspace` behind shared arrival and return context. No new page shell, no new persistence, and no new mutation lane ship in this slice.
- **Explicit non-goals**: No new portal or dashboard shell, no new persisted inbox item or task state, no new assign/claim/approve/review mutations on the convergence surface, no cross-tenant compare or promotion work, no customer-facing portfolio board, no AI prioritization, and no generic workflow framework.
- **Permanent complexity imported**: One bounded family extension inside the existing governance inbox assembly path, one small convergence contract for arrival and return context across existing pages, and focused unit plus feature coverage.
- **Why now**: `docs/product/roadmap.md` still has an open `Decision-Based Operating Foundations` lane, while `docs/product/implementation-ledger.md` identifies decision-surface fragmentation as the highest unspecced operator workflow gap after already-specced compare, commercial, and cleanup packages are removed from the queue.
- **Why not local**: Extending only `FindingExceptionsQueue` or only `CustomerReviewWorkspace` would keep the current start-state ambiguity intact and would not establish a single truthful operator entry point.
- **Approval class**: Workflow Compression
- **Red flags triggered**: One multi-surface convergence flag and one derived-family-extension flag. Defense: the slice extends existing pages and builders, introduces no new persistence, and keeps all workflow state on the existing source surfaces.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- existing canonical workspace route `/admin/governance/inbox`
- existing `/admin/findings/my-work`
- existing `/admin/findings/intake`
- existing `/admin/finding-exceptions/queue`
- existing `/admin/reviews/workspace`
- existing tenant-scoped finding and review detail routes as drill-through targets only
- **Data Ownership**:
- `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only persisted truth for their respective families
- the convergence layer remains derived from the existing `GovernanceInbox` page and supporting builders; it introduces no new inbox-item table, cache, mirror entity, or workflow state
- no new review, exception, or decision summary persistence is introduced
- **RBAC**:
- workspace membership remains the first boundary for the canonical decision home and all converged launches
- non-members and explicit out-of-scope tenant filters remain `404` deny-as-not-found boundaries
- in-scope members who can access none of the converged families receive `403`, not a silent empty shell
- findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW` and keep claim or assignment semantics on their existing pages, including `Capabilities::TENANT_FINDINGS_ASSIGN`
- the exception lane reuses the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE`; this slice must not introduce a second exception capability family
- customer-review follow-up and review-pack handoff continue to reuse existing review and pack visibility checks; this slice must not introduce a new review-workspace capability family
- the convergence surface stays read-only; all mutations remain enforced on their existing source pages and actions
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped or tenant-prefiltered specialist page, the governance inbox prefilters to that tenant and, when relevant, to the originating family. Clearing filters returns to the workspace-wide decision home instead of preserving a local specialist scope forever.
- **Explicit entitlement checks preventing cross-tenant leakage**: Broad workspace listings silently omit inaccessible tenants and hidden families from counts, labels, and previews. Explicit tenant or record targets outside visible scope resolve as not found and do not leak family counts or presence hints.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, decision-home section summaries, action links, queue empty states, back-link continuity, and badge or status reuse
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, `CustomerReviewWorkspace`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing action-surface declarations on those pages
- **Existing pattern(s) to extend**: the existing governance inbox route and section builder, tenant-prefilter state handling, canonical navigation context, calm empty-state copy on specialist pages, and existing read-first decision surfaces
- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, `ActionSurfaceDeclaration`, and existing source-page capability guards
- **Why the existing shared path is sufficient or insufficient**: the existing source pages already own the truth and the current governance inbox already owns the canonical route, but the current convergence is insufficient because finding exceptions and explicit customer-review decision continuity still sit outside the calm default operating model
- **Allowed deviation and why**: none. The slice should extend the existing governance inbox and source pages instead of introducing a second convergence shell or a generic task framework.
- **Consistency impact**: `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, `Open customer review workspace`, and `Back to governance inbox` language must stay consistent across section copy, specialist page affordances, and empty-state recovery actions.
- **Review focus**: reviewers must block any implementation that creates a new standalone convergence page, adds local specialist mutations to the governance home, or duplicates specialist proof content on the decision surface.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` start or completion behavior is introduced
- **Shared OperationRun UX contract/layer reused**: existing deep-link-only behavior on the already-present stale-operations family remains unchanged
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: the governance home still decides whether the existing stale-operations section is shown, but this spec does not widen or redefine that contract
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no new provider or platform-core boundary is widened. This slice only converges existing operator-facing decision surfaces.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Governance inbox page | yes | Native Filament page plus existing section builder | decision-home summaries, navigation entry points, badge reuse | page, URL-query, derived family state | no | Existing canonical route remains authoritative; no new shell is added |
| Findings and exception specialist queues (`MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`) | yes | Native Filament pages | arrival context, return continuity, calm secondary-context copy | page, URL-query, table/session filter restore | no | Remain specialized secondary-context surfaces; no workflow ownership moves here |
| Customer review workspace | yes | Native Filament page | review-follow-up handoff, return continuity, calm read-only context | page, URL-query, table/session filter restore | no | Remains the review-consumption surface, not a second governance home |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Governance inbox page | Primary Decision Surface | Operator decides which lane to open next across findings, exceptions, stale operations, alert failures, and review follow-up | visible family counts, top blockers, tenant scope, and one dominant next action per section | specialist queue details, review detail, alert detail, and finding detail after explicit open | Primary because it becomes the single workspace start surface instead of one of several competing starts | Aligns the product with the roadmap's decision-first operating direction | Reduces page hopping before the first action |
| Findings and exception specialist queues | Secondary Context | Operator acts inside the chosen lane after the first decision is already made | lane-specific list rows, current tenant scope, and one existing safe next action | record detail, due context, approval context, and deeper diagnostics on existing detail surfaces | Secondary because the lane is chosen at the governance home, not discovered here first | Keeps specialist work inside the existing pages without making them compete as starts | Removes the need to re-evaluate the whole workspace after every lane jump |
| Customer review workspace | Secondary Context | Operator verifies the latest customer-safe review state after a governance or review-follow-up cue | latest published review outcome, pack availability, and read-only summary | full review detail and pack download after explicit open | Secondary because it remains a read-only review-consumption surface entered from a governance cue | Preserves the customer-safe review workflow while fitting into the same decision hierarchy | Prevents the review workspace from becoming a competing attention home |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Governance inbox page | operator-MSP | family summary, tenant scope, top blockers, and section CTA | diagnostics stay on the specialist pages | raw payloads and debug detail stay on existing source pages only | `Open attention lane` | raw detail is never rendered on the decision home | the page states the current blocker once and sends the operator to the owning surface for proof |
| Findings and exception specialist queues | operator-MSP | lane-specific queue rows, due cues, status, and existing next action | specialist diagnostics and record detail remain available on existing routes | raw/support detail stays behind existing record detail affordances | existing lane-owned action only | any broad workspace summary stays off the specialist pages | specialist pages do not restate the workspace decision-home summary |
| Customer review workspace | operator-MSP, customer-read-only | latest customer-safe review status, pack availability, and read-only summary | deeper review diagnostics stay on review detail | raw JSON, provider payloads, and internal support evidence remain hidden or gated on existing detail/download paths | `Open latest review` | support-only raw evidence stays off the workspace surface | review workspace states review truth once and relies on review detail for proof |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the correct existing lane | explicit section CTA or preview-entry CTA | forbidden | section footer links and preview-entry links only | none | `/admin/governance/inbox` | existing specialist routes only | active workspace, optional tenant and family filter | Governance inbox | which lane needs attention now | none |
| Findings and exception specialist queues | List / Table / Read-only decision queue | Specialist queue | Open the owning record or use the existing inline safe shortcut | row click and existing specialist inspect path | required | existing lane-owned actions only | existing grouped destructive or risky actions remain on owning detail surfaces | `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/finding-exceptions/queue` | existing finding or exception detail routes | tenant filter, queue view, queue-specific states | Findings / Finding exceptions | lane-specific actionable truth only | none |
| Customer review workspace | List / Table / Read-only workspace report | Read-only review consumption | Open the latest published review | clickable row to latest review or explicit download/open action | required | existing read-only actions only | none | `/admin/reviews/workspace` | existing tenant review detail route | tenant filter and review availability | Customer review workspace | latest published review state and pack availability | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance inbox page | Workspace operator / MSP operator | Decide which governance lane to open next | Workspace decision hub | What needs attention now across my visible governance lanes? | family counts, top blockers, tenant scope, and section CTA | diagnostics remain on specialist pages | lane urgency, lane ownership, tenant scope | none | Open lane | none |
| Findings and exception specialist queues | Workspace operator / MSP operator | Work inside a chosen findings or exception lane | Specialist queue | What in this chosen lane needs action first? | queue rows, due or review cues, owner/assignee context, and current filter state | full record detail and deep diagnostics remain on existing detail routes | workflow status, due/overdue state, review or approval state | existing lane-owned mutations only | inspect record or existing lane action | existing queue-owned risky actions only |
| Customer review workspace | Workspace operator / readonly-capable tenant actor | Consume the latest review truth after a review-follow-up cue | Read-only review workspace | What is the latest published review state for this tenant? | latest review outcome, pack availability, and read-only summary | full review detail and pack detail after explicit open | review availability, review freshness, pack availability | none | Open latest review | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded convergence contract inside the existing governance inbox assembly and navigation-context seams
- **New enum/state/reason family?**: no new persisted family; any added family key remains derived and page-scoped
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: operators still start from multiple repo-real specialist surfaces even though the repo already has enough decision surfaces to support one calmer governance home
- **Existing structure is insufficient because**: the current governance inbox does not yet own all remaining high-signal lanes and the specialist pages do not clearly behave as secondary contexts
- **Narrowest correct implementation**: extend the existing governance inbox and current specialist pages with one more derived family and shared arrival/return semantics instead of creating a new shell or workflow engine
- **Ownership cost**: maintain one more derived section and a small navigation-context convergence layer plus focused tests
- **Alternative intentionally rejected**: a new global action center, persisted inbox-item table, and mutation-capable workflow engine were rejected as premature and structurally heavier than the current release truth requires
- **Release truth**: current-release workflow compression, not future-release platform speculation
### 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 extension of the existing governance inbox is preferred over adding a parallel decision surface.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: unit coverage proves section assembly, family ordering, and convergence routing without Filament boot cost; focused feature coverage proves visibility, tenant-filter continuity, return context, and calm secondary-surface behavior on the existing pages
- **New or expanded test families**: focused `Unit/Support/GovernanceInbox` coverage plus focused `Feature/Governance`, `Feature/Monitoring`, `Feature/Reviews`, and `Feature/Findings` convergence coverage
- **Fixture / helper cost impact**: moderate; tests need workspace membership, visible and hidden tenants, findings, exceptions, review-follow-up states, and review-workspace fixtures, but should reuse existing factories and avoid browser or heavy-governance setup
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: global-context-shell
- **Standard-native relief or required special coverage**: special coverage is required for arrival/return context and duplicate-truth prevention across the specialist pages
- **Reviewer handoff**: reviewers must confirm that the governance inbox remains the single start surface, counts omit inaccessible families, specialist pages keep one dominant action, and no new mutation lane or persistence appears
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
## Scope Boundaries
### In Scope
- reuse the existing `GovernanceInbox` page as the canonical workspace decision home
- extend the governance inbox with a derived finding-exceptions family sourced from existing queue truth
- make customer-review follow-up and customer-review-workspace handoff explicit within the same decision hierarchy
- preserve tenant and family arrival context plus truthful return links between the governance home and `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`
- keep specialist pages as calm secondary-context surfaces with no duplicate workspace-level blocker summary
### Non-Goals
- creating a new global action-center page or dashboard shell
- replacing the existing specialist pages or moving their mutations to the governance home
- adding a new persisted inbox item, queue state, or workflow engine
- changing existing finding, exception, or review lifecycle semantics
- cross-tenant compare, promotion, or portfolio execution work
- customer-facing portfolio boards or AI-driven prioritization
## Assumptions
- the existing `GovernanceInboxSectionBuilder` can accept one more derived family without turning into a generic task engine
- current `CanonicalNavigationContext` and tenant-prefilter handling are sufficient to preserve truthful return paths between the decision home and specialist pages
- `CustomerReviewWorkspace` remains the correct read-only destination for customer-safe review consumption when a published review detail is not the better direct target
## Risks
- implementation could overreach and turn the governance home into a new task engine instead of a routing surface
- the finding-exceptions family could leak hidden tenant hints if capability and tenant scoping are not applied before counts and previews are derived
- specialist-page convergence could accidentally duplicate blocker language instead of keeping the decision summary on the governance home only
## Follow-up Candidates
- wider dashboard-entry convergence once the governance home proves adoption
- portfolio-level decision convergence with cross-tenant compare after Spec 043 implementation
- any mutation consolidation only after the read-only convergence hierarchy is proven and remains bounded
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Use one canonical governance home (Priority: P1)
As a workspace operator, I want one governance home that includes the still-missing exception and review-consumption lanes so I can decide where to work next without choosing between multiple start pages first.
**Why this priority**: This is the smallest slice that completes the roadmap's decision-first operating direction without inventing new workflow state.
**Independent Test**: Seed visible findings, finding exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and section CTAs.
**Acceptance Scenarios**:
1. **Given** the actor can see findings, finding exceptions, and review follow-up for the current workspace, **When** they open the governance inbox, **Then** the page shows those lanes in one canonical surface with one dominant action per section.
2. **Given** the actor cannot see finding exceptions, **When** they open the governance inbox, **Then** the exception lane does not appear and no count or empty-state hint implies hidden work exists.
3. **Given** the actor applies a tenant-prefilter that hides all current rows, **When** they open the governance inbox, **Then** the page explains that the tenant filter is hiding other visible attention instead of falsely implying the whole workspace is calm.
---
### User Story 2 - Move into a specialist lane and back without losing context (Priority: P1)
As a workspace operator, I want to open a specialist queue or review workspace from the governance home and come back with the same tenant and family context so the governance home becomes my operating anchor instead of a one-off report.
**Why this priority**: Convergence does not help if every lane jump loses the original decision context.
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
**Acceptance Scenarios**:
1. **Given** the actor opens `My Findings` or `Findings intake` from the governance inbox, **When** the specialist queue loads, **Then** the existing queue keeps its specialist semantics while exposing a truthful return path to the governance inbox.
2. **Given** the actor opens `Finding exceptions` from the governance inbox, **When** the queue loads, **Then** the queue preserves the arrival tenant context and does not become a competing workspace start surface.
3. **Given** the actor opens `Customer Review Workspace` from a review-follow-up cue, **When** they inspect the review lane, **Then** the page stays read-only and preserves the governance-home return path.
---
### User Story 3 - Keep specialist surfaces calm and secondary (Priority: P2)
As a workspace operator, I want specialist queues and the customer review workspace to keep their own lane truth without re-explaining the whole workspace blocker summary so each page stays focused on the action I already chose.
**Why this priority**: Convergence should reduce attention load, not spread the same summary across more pages.
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps its lane-specific content while the workspace-level blocker summary remains on the governance home.
**Acceptance Scenarios**:
1. **Given** the actor opens a specialist queue from the governance home, **When** the specialist page renders, **Then** it shows only lane-specific actionable truth and not a duplicated workspace summary banner.
2. **Given** the actor opens the customer review workspace from a review-follow-up cue, **When** the page renders, **Then** it shows customer-safe review truth and not a second governance-home summary card.
### Edge Cases
- the actor can access the governance inbox but none of the converged specialist families
- the requested tenant filter is outside the actor's visible scope
- the same tenant has findings, exceptions, and review follow-up at once, but the governance home must still avoid duplicating the same blocker explanation across sections
- review follow-up exists for a tenant without a currently published review, requiring the fallback customer-review-workspace destination
- the selected tenant is calm for exceptions but not for other families, so the empty-state message must be truthful about what the filter is hiding
## Requirements *(mandatory)*
### Functional Requirements
- **FR-257-001 Canonical decision home**: The system MUST treat the existing `/admin/governance/inbox` page as the canonical workspace decision home for operator-facing governance attention.
- **FR-257-002 Exception-family convergence**: The governance inbox MUST derive a `finding_exceptions` family from existing `FindingExceptionsQueue` truth and render it without adding a new persisted inbox item or queue-state layer.
- **FR-257-003 Review-consumption handoff**: Review-follow-up cues on the governance inbox MUST route into the existing review-consumption surfaces, using the latest published review when available and the existing customer review workspace when it is the truthful fallback.
- **FR-257-004 Arrival and return continuity**: Launching `My Findings`, `Findings intake`, `Finding exceptions`, or `Customer Review Workspace` from the governance inbox MUST preserve truthful arrival and return context for the current tenant and family scope.
- **FR-257-005 Secondary-surface discipline**: Specialist queues and the customer review workspace MUST remain secondary-context surfaces and MUST NOT become competing workspace start surfaces through duplicated workspace summary banners or second primary CTAs.
- **FR-257-006 Visibility and omission semantics**: Family counts, section previews, and empty states MUST be derived only from tenants and families the actor can already see through existing capability and entitlement checks.
- **FR-257-007 Authorization semantics**: Non-members and out-of-scope tenant targets MUST resolve as `404`, while in-scope members who lack visibility to every converged family MUST receive `403`.
- **FR-257-008 No new workflow truth**: The slice MUST NOT add a new inbox-item table, a persisted convergence state, or a new cross-family mutation contract.
- **FR-257-009 Source-surface ownership**: Claim, assignment, approval, review, and pack-download behaviors MUST remain on their existing source surfaces and continue to enforce their existing capabilities there.
- **FR-257-010 Decision-first disclosure**: The governance inbox MUST show summary and next-action content only; raw payloads, review evidence, and specialist diagnostics MUST remain on the owning specialist or detail surfaces.
- **FR-257-011 Duplicate-truth prevention**: The governance inbox and the specialist surfaces MUST NOT restate the same workspace-level blocker or next-action summary as equal-priority content.
- **FR-257-012 Read-only review integrity**: The customer review workspace remains read-only in this slice and MUST NOT gain operator-only mutation controls through convergence work.
### Non-Functional Requirements
- **NFR-257-001**: The convergence layer remains DB-only and derived from existing persisted truth; it MUST NOT add Graph calls, remote calls, queues, or `OperationRun` creation.
- **NFR-257-002**: The slice MUST reuse existing Filament and shared UI primitives before any local UI framework or semantic layer is introduced.
- **NFR-257-003**: The feature MUST stay within focused `Unit` and `Feature` lanes only; browser or heavy-governance coverage is out of scope unless implementation proves a specific need.
### UX Requirements
- **UXR-257-001**: The governance inbox remains the one dominant start surface for the converged lanes.
- **UXR-257-002**: Each affected surface has exactly one dominant next action visible by default.
- **UXR-257-003**: Specialist surfaces keep lane-specific truth only and rely on explicit return links for workspace context.
### RBAC / Security Requirements
- **RBR-257-001**: The slice MUST reuse existing capability registries and MUST NOT introduce raw capability strings or role-name checks.
- **RBR-257-002**: Tenant-filter and family-filter state MUST NOT leak inaccessible tenant or family hints through counts, labels, or empty-state copy.
### Auditability / Observability Requirements
- **AOR-257-001**: The slice MUST NOT create a new page-view audit stream; existing audit ownership remains on the existing source-surface mutations and downloads.
- **AOR-257-002**: Any convergence-specific navigation or UI state remains derived and inspectable through tests rather than new runtime logging.
### Data / Truth-Source Requirements
- **DTR-257-001**: `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only source truth inputs for the decision home.
- **DTR-257-002**: Any added convergence family key remains derived page state, not persisted domain truth.
## Out of Scope
- new persistence or workflow-state layers
- new operator mutations on the governance home
- cross-tenant compare or promotion work
- customer-facing portfolio boards or customer portal changes
- AI prioritization or recommendation logic
## Acceptance Criteria
- the selected operator can open one canonical governance home that includes the missing exception lane and truthful review-consumption handoff without seeing a second competing start surface
- specialist pages preserve truthful arrival and return context when opened from the governance home
- hidden families and inaccessible tenants do not leak through counts, labels, or empty-state hints
- the customer review workspace remains read-only and customer-safe while participating in the same decision hierarchy
- no new persistence, workflow state, queue, or runtime mutation surface is introduced
## Success Criteria
- operators can explain one default start surface for governance work in the workspace
- the specialist pages feel like chosen lanes, not competing homes
- implementation can stay bounded to existing page and builder seams with no new framework layer
## Open Questions
- none

View File

@ -0,0 +1,189 @@
---
description: "Task list for Governance Decision Surface Convergence v1"
---
# Tasks: Governance Decision Surface Convergence v1
**Input**: Design documents from `specs/257-governance-decision-convergence/`
**Prerequisites**: `specs/257-governance-decision-convergence/plan.md` (required), `specs/257-governance-decision-convergence/spec.md` (required)
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage for this read-only convergence slice.
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Existing stale-operation links remain unchanged.
**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets return `404`; in-scope members with no visible family return `403`. Findings lanes reuse `Capabilities::TENANT_FINDINGS_VIEW`, existing inline safe actions keep their current capability checks such as `Capabilities::TENANT_FINDINGS_ASSIGN`, the exception lane reuses `Capabilities::FINDING_EXCEPTION_APPROVE`, and review handoff reuses existing review and pack visibility checks.
**Shared Pattern Reuse**: Reuse `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the existing specialist page action-surface contracts. No new shell, task engine, or persistence layer is allowed.
**Organization**: Tasks are grouped by user story so the governance-home extension, navigation convergence, and calm secondary-context rules remain independently testable after the shared groundwork is complete.
## Test Governance Checklist
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in focused `apps/platform/tests/Unit/Support/GovernanceInbox/`, `apps/platform/tests/Feature/Governance/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Monitoring/`, and `apps/platform/tests/Feature/Reviews/` families only.
- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or generic workflow fixtures.
- [x] Planned validation commands cover governance-home assembly, authorization, and arrival/return continuity without widening scope.
- [x] The declared surface test profile remains `global-context-shell` because arrival context and tenant-filter continuity are part of the contract.
- [x] Any broader action-center, dashboard-entry, or cross-tenant follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden implementation growth.
- [x] Test-governance outcome resolves as `keep` for this feature and does not widen the work into a heavier family.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded convergence slice, the existing governance-home seams, and the reviewer stop conditions before implementation begins.
- [x] T001 Review the bounded convergence slice in `specs/257-governance-decision-convergence/spec.md` and `specs/257-governance-decision-convergence/plan.md` together with `docs/product/roadmap.md` and `docs/product/implementation-ledger.md`.
- [x] T002 [P] Confirm the current governance-home families and summary seams in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`.
- [x] T003 [P] Confirm the specialist-page arrival, return, and filter-state seams in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Extend the shared governance-home and navigation seams that every user story depends on.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T004 [P] Define or extend the bounded family-aware arrival and return contract inside `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and any minimal supporting helper under `apps/platform/app/Support/GovernanceInbox/` without creating new persistence or a generic workflow framework.
- [x] T005 [P] Tighten family omission and access evaluation in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` so inaccessible tenants and families disappear before counts are derived and in-scope no-family access resolves as `403`.
- [x] T006 Implement the derived `finding_exceptions` family in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` using existing `FindingExceptionsQueue` truth, current queue semantics, and existing capability rules.
- [x] T007 Implement truthful review-consumption handoff logic in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` and `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` so review-follow-up entries prefer existing latest review detail and fall back to `CustomerReviewWorkspace` only when that is the honest destination.
- [x] T008 [P] Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to keep one dominant CTA per section and avoid duplicate workspace-summary cards as the new family is added.
**Checkpoint**: The governance home can derive the new family, family counts stay capability-safe, and navigation context rules are settled before story-specific work begins.
---
## Phase 3: User Story 1 - Use One Canonical Governance Home (Priority: P1)
**Goal**: Give the operator one governance home that includes the missing exception and review-consumption lanes without creating a new shell.
**Independent Test**: Seed visible findings, exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and one dominant CTA per section.
### Tests for User Story 1
- [x] T009 [P] [US1] Extend `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` to cover exception-family inclusion, family ordering, review-workspace fallback, and omission semantics for hidden tenants or families.
- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` to cover the visible exception lane, review-consumption handoff summary, tenant-filter empty-state truth, and one dominant CTA per section.
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` to cover `404` vs `403` behavior when workspace access exists but all converged family visibility is removed.
### Implementation for User Story 1
- [x] T012 [US1] Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to render the new convergence lane and family-aware summary or empty-state copy.
- [x] T013 [US1] Align governance-home copy in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to the stable vocabulary `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, and `Open customer review workspace`.
**Checkpoint**: User Story 1 is independently functional when the governance inbox truthfully shows the missing lane and routes to the existing specialist destinations.
---
## Phase 4: User Story 2 - Move Into A Specialist Lane And Back (Priority: P1)
**Goal**: Preserve tenant and family context when the operator opens a specialist page from the governance home and returns.
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
### Tests for User Story 2
- [x] T014 [P] [US2] Add or extend `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` for tenant and family arrival/return continuity across governance-home launches.
- [x] T015 [P] [US2] Add or extend `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` for governance-home arrival, preserved tenant context, and truthful `Back to governance inbox` continuity.
- [x] T016 [P] [US2] Add or extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` for governance-home arrival and return continuity on review-follow-up launches, preferred latest-published-review destination when available, fallback to `CustomerReviewWorkspace` when not, preserved read-only state, and the absence of operator-only mutation controls.
- [x] T017 [P] [US2] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php` and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php` for governance-home launch and return continuity on the findings specialist pages.
### Implementation for User Story 2
- [x] T018 [US2] Wire governance-home arrival and return context through `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`.
- [x] T019 [US2] Expose truthful return affordances without adding a second primary CTA. Repo truth: these native Filament pages expose the affordance through page header actions in `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`; no specialist Blade edits were required.
**Checkpoint**: User Story 2 is independently functional when the operator can move between the governance home and the specialist pages without losing truthful context.
---
## Phase 5: User Story 3 - Keep Specialist Surfaces Calm And Secondary (Priority: P2)
**Goal**: Ensure the specialist pages stay focused on lane-specific truth and do not duplicate the workspace-level summary once convergence context exists.
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps lane-specific content while the workspace-level blocker summary remains on the governance home only.
### Tests for User Story 3
- [x] T020 [P] [US3] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` to assert duplicate-truth prevention, one dominant default action on each specialist surface, and secondary-context copy when the pages are opened from the governance home.
### Implementation for User Story 3
- [x] T021 [US3] Keep lane-specific summaries focused and avoid duplicating workspace-level blocker text. Repo truth: page classes now add secondary return context while existing specialist Blade views stay lane-focused; regression tests assert the governance-home summary text is absent from secondary pages.
- [x] T022 [US3] Align action-surface declarations, header affordances, and empty-state recovery actions across `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so the canonical start surface remains obvious.
**Checkpoint**: User Story 3 is independently functional when specialist surfaces remain lane-specific secondary contexts instead of competing starts.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish narrow validation and reviewer close-out without widening scope.
- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`.
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`.
- [x] T025 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
- [x] T026 [P] Confirm the slice introduced no new asset registration, no new globally searchable resource, and no new mutation lane; record any bounded follow-up for broader dashboard-entry or portfolio action-center work in the active implementation notes.
- [x] T027 [P] Confirm the slice introduced no new Graph or remote calls, no queue or `OperationRun` start path, and no page-view audit or runtime logging stream; record any bounded follow-up if implementation uncovers a structural need outside this slice.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical governance-home truth.
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the governance home is not a dead-end report.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because specialist pages must already participate in the convergence flow.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and establishes the new canonical decision-home behavior.
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the new home has truthful workflow continuity.
- **US3 (P2)**: independently testable after Phase 2 and refines the specialist pages once the convergence contract exists.
### Within Each User Story
- After the shared foundational contract work in Phase 2 is complete, write the listed Pest coverage first for each user story and make it fail for the intended gap.
- Land the shared builder and navigation contract before widening Blade or copy work.
- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story.
---
## Parallel Execution Examples
### User Story 1
- T009, T010, and T011 can run in parallel before runtime edits begin.
- After the family contract settles, T012 and T013 can proceed in parallel because rendering and copy alignment touch different seams.
### User Story 2
- T014, T015, T016, and T017 can run in parallel because they cover different destinations in the convergence flow.
- After T018 settles the shared navigation contract, T019 can follow to align the Blade affordances.
### User Story 3
- T020 can start before implementation finishes because it only captures the expected secondary-context behavior.
- T021 and T022 can proceed together once the shared convergence path is stable.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 together**. The slice becomes product-meaningful only when the governance home shows the missing lanes and the specialist pages preserve truthful return context.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and US2 together.
3. Add US3 secondary-context tightening.
4. Finish with focused validation and formatting in Phase 6.
### Team Strategy
1. Settle the governance-home family extension and navigation-context contract first.
2. Parallelize unit and feature coverage inside each story before runtime edits widen.
3. Serialize merges around the governance inbox and specialist Blade views so the decision-home language stays coherent.