feat: governance inbox final operator workflow (spec 346) (#418)
Implemented the final operator workflow for the Governance Inbox. This includes refactoring the inbox page, updating finding resources, adding UI enforcement policies, updating related blade views, and adding comprehensive tests for operator workflow and scope contracts. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #418
This commit is contained in:
parent
1f3a8b5ed9
commit
8cffdbdb2c
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Pages\Governance;
|
||||
|
||||
use App\Filament\Concerns\CleansAdminTenantQueryParameter;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -12,7 +13,9 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
|
||||
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
|
||||
use App\Support\Navigation\WorkspaceHubNavigation;
|
||||
@ -33,6 +36,44 @@ class GovernanceInbox extends Page
|
||||
{
|
||||
use CleansAdminTenantQueryParameter;
|
||||
|
||||
private const int LANE_PREVIEW_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* @var list<array{key: string, label: string, description: string, empty_state: string}>
|
||||
*/
|
||||
private const array LANE_DEFINITIONS = [
|
||||
[
|
||||
'key' => 'needs_triage',
|
||||
'label' => 'Needs triage',
|
||||
'description' => 'Unassigned findings that still need a first operator path.',
|
||||
'empty_state' => 'No unassigned findings need first triage in the current scope.',
|
||||
],
|
||||
[
|
||||
'key' => 'requires_decision',
|
||||
'label' => 'Requires decision',
|
||||
'description' => 'Assigned governance work and review follow-up that still need operator judgment.',
|
||||
'empty_state' => 'No operator-decision items are open in the current scope.',
|
||||
],
|
||||
[
|
||||
'key' => 'risk_exception_review',
|
||||
'label' => 'Risk / exception review',
|
||||
'description' => 'Accepted-risk and exception records that need approval, renewal, closure, or support review.',
|
||||
'empty_state' => 'No accepted-risk or exception records need review in the current scope.',
|
||||
],
|
||||
[
|
||||
'key' => 'evidence_required',
|
||||
'label' => 'Evidence required',
|
||||
'description' => 'Governance items that are still missing the linked proof needed for follow-through.',
|
||||
'empty_state' => 'No visible governance items are blocked by missing evidence in the current scope.',
|
||||
],
|
||||
[
|
||||
'key' => 'blocked',
|
||||
'label' => 'Blocked',
|
||||
'description' => 'Technical follow-up is still blocked on failed runs or delivery issues.',
|
||||
'empty_state' => 'No blocked governance follow-up is visible in the current scope.',
|
||||
],
|
||||
];
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
|
||||
@ -49,11 +90,6 @@ class GovernanceInbox extends Page
|
||||
|
||||
protected string $view = 'filament.pages.governance.governance-inbox';
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return 'Prioritized governance decisions, owners, evidence, and follow-up actions across entitled environments.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<int, ManagedEnvironment>|null
|
||||
*/
|
||||
@ -82,7 +118,12 @@ public function getSubheading(): ?string
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $decisionWorkbench = null;
|
||||
private ?array $lanePayload = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $recentlyResolvedPayload = null;
|
||||
|
||||
private ?Workspace $workspace = null;
|
||||
|
||||
@ -94,15 +135,20 @@ public function getSubheading(): ?string
|
||||
|
||||
public ?string $family = null;
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return 'Daily operator queue for governance follow-up, accepted risk, evidence gaps, and review handoff.';
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.')
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace governance queue calm and read-only.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary menus.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and explain what operator work appears here.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface or mutation workflow.');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
@ -130,8 +176,7 @@ public function mount(): void
|
||||
public function appliedScope(): array
|
||||
{
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
$availableFamilies = collect($this->availableFamilies())
|
||||
->keyBy('key');
|
||||
$availableFamilies = collect($this->availableFamilies())->keyBy('key');
|
||||
|
||||
return [
|
||||
'workspace_label' => $this->workspace()?->name,
|
||||
@ -140,7 +185,7 @@ public function appliedScope(): array
|
||||
'family_key' => $this->family,
|
||||
'family_label' => $this->family !== null
|
||||
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
|
||||
: 'All attention',
|
||||
: 'All source families',
|
||||
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
@ -150,7 +195,10 @@ public function appliedScope(): array
|
||||
*/
|
||||
public function availableFamilies(): array
|
||||
{
|
||||
return $this->inboxPayload()['available_families'] ?? [];
|
||||
return collect($this->inboxPayload()['available_families'] ?? [])
|
||||
->map(fn (array $family): array => $this->normalizeFamily($family))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -158,40 +206,133 @@ public function availableFamilies(): array
|
||||
*/
|
||||
public function sections(): array
|
||||
{
|
||||
return $this->inboxPayload()['sections'] ?? [];
|
||||
return collect($this->inboxPayload()['sections'] ?? [])
|
||||
->map(fn (array $section): array => $this->normalizeSection($section))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* question: string,
|
||||
* selected_item: array<string, mixed>|null,
|
||||
* summary_cards: list<array{label: string, value: string, description: string}>,
|
||||
* diagnostics: array{label: string, state: string, body: string}
|
||||
* total_open_items: int,
|
||||
* headline: string,
|
||||
* counts: list<array{key: string, label: string, count: int, description: string, state: string, chip_label: string}>,
|
||||
* active_counts: list<array{key: string, label: string, count: int, description: string, state: string, chip_label: string}>,
|
||||
* clear_counts: list<array{key: string, label: string, count: int, description: string, state: string, chip_label: string}>,
|
||||
* primary_action: array{label: string, url: string}|null,
|
||||
* next_recommended_item: array<string, mixed>|null,
|
||||
* review_ready_supported: bool,
|
||||
* }
|
||||
*/
|
||||
public function decisionWorkbench(): array
|
||||
public function operatorSummary(): array
|
||||
{
|
||||
if (is_array($this->decisionWorkbench)) {
|
||||
return $this->decisionWorkbench;
|
||||
return $this->lanePayload()['summary'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function laneGroups(): array
|
||||
{
|
||||
return $this->lanePayload()['lanes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function recentlyResolved(): ?array
|
||||
{
|
||||
if (is_array($this->recentlyResolvedPayload)) {
|
||||
return $this->recentlyResolvedPayload === [] ? null : $this->recentlyResolvedPayload;
|
||||
}
|
||||
|
||||
$entries = $this->workbenchEntries();
|
||||
$selectedItem = $entries
|
||||
->sortBy([
|
||||
fn (array $entry): int => (int) ($entry['urgency_rank'] ?? 999),
|
||||
fn (array $entry): string => (string) ($entry['headline'] ?? ''),
|
||||
])
|
||||
->first();
|
||||
if (! $this->hasVisibleFindingExceptionsFamily()) {
|
||||
$this->recentlyResolvedPayload = [];
|
||||
|
||||
return $this->decisionWorkbench = [
|
||||
'question' => 'What decision clears the highest-priority item?',
|
||||
'selected_item' => is_array($selectedItem) ? $this->normalizeWorkbenchItem($selectedItem) : null,
|
||||
'summary_cards' => $this->summaryCards($entries),
|
||||
'diagnostics' => [
|
||||
'label' => 'Diagnostics',
|
||||
'state' => 'Collapsed',
|
||||
'body' => 'Source diagnostics and raw support details stay on authorized source surfaces. This workbench shows decision, evidence, and proof state only.',
|
||||
],
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->family !== null && $this->family !== 'finding_exceptions') {
|
||||
$this->recentlyResolvedPayload = [];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
$this->recentlyResolvedPayload = [];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$visibleTenants = $this->selectedTenant() instanceof ManagedEnvironment
|
||||
? [$this->selectedTenant()]
|
||||
: $this->authorizedTenants();
|
||||
|
||||
$payload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||
workspace: $workspace,
|
||||
visibleTenants: $visibleTenants,
|
||||
registerState: 'recently_closed',
|
||||
);
|
||||
|
||||
$count = (int) ($payload['counts']['recently_closed'] ?? 0);
|
||||
|
||||
if ($count === 0) {
|
||||
$this->recentlyResolvedPayload = [];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
$openUrl = DecisionRegister::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'environment_id' => $selectedTenant?->getKey(),
|
||||
'register_state' => 'recently_closed',
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
|
||||
$rows = collect($payload['rows'] ?? [])
|
||||
->take(3)
|
||||
->map(function (array $row) use ($openUrl): array {
|
||||
return [
|
||||
'title' => filled($row['tenant_name'] ?? null)
|
||||
? (string) $row['tenant_name']
|
||||
: 'Recently closed decision',
|
||||
'reason' => filled($row['closure_reason'] ?? null)
|
||||
? (string) $row['closure_reason']
|
||||
: 'Decision closed in the register.',
|
||||
'next_action_label' => 'Open decision register',
|
||||
'next_action_url' => $openUrl,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->recentlyResolvedPayload = [
|
||||
'label' => 'Recently resolved',
|
||||
'count' => $count,
|
||||
'summary' => sprintf(
|
||||
'%d recently closed decision%s remain available in the Decision Register for reference and audit follow-through.',
|
||||
$count,
|
||||
$count === 1 ? '' : 's',
|
||||
),
|
||||
'open_url' => $openUrl,
|
||||
'open_label' => 'Open recently closed decisions',
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, state: string, body: string}
|
||||
*/
|
||||
public function diagnosticsPanel(): array
|
||||
{
|
||||
return [
|
||||
'label' => 'Diagnostics / source detail',
|
||||
'state' => 'Collapsed',
|
||||
'body' => 'Raw diagnostics, payloads, and support detail stay on authorized source surfaces. This inbox keeps the operator queue, proof links, and next actions on the first screen.',
|
||||
];
|
||||
}
|
||||
|
||||
@ -203,15 +344,24 @@ public function calmEmptyState(): array
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return [
|
||||
'title' => 'This environment filter is hiding other visible attention',
|
||||
'body' => 'The current environment scope is calm, but other visible environments in this workspace still have governance attention.',
|
||||
'body' => 'The current environment scope is calm, but other visible environments in this workspace still have governance work that needs follow-up.',
|
||||
'action_label' => 'Clear environment filter',
|
||||
'action_url' => $this->pageUrl(['environment_id' => null, 'family' => null]),
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->familyFilterAloneExcludesRows()) {
|
||||
return [
|
||||
'title' => 'This source focus is hiding other governance work',
|
||||
'body' => 'The current source-family focus is calm, but other repo-backed governance items remain open in this workspace.',
|
||||
'action_label' => 'Show all source families',
|
||||
'action_url' => $this->pageUrl(['family' => null]),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'No governance decisions need attention',
|
||||
'body' => 'The current workspace scope has no repo-backed governance decisions requiring action.',
|
||||
'title' => 'No governance items need attention.',
|
||||
'body' => 'Findings, decisions, accepted-risk reviews, evidence gaps, and review follow-ups will appear here when they need operator attention.',
|
||||
'action_label' => null,
|
||||
'action_url' => null,
|
||||
];
|
||||
@ -276,81 +426,479 @@ private function workbenchEntries(): \Illuminate\Support\Collection
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeWorkbenchItem(array $item): array
|
||||
private function lanePayload(): array
|
||||
{
|
||||
return [
|
||||
'section_label' => (string) ($item['section_label'] ?? 'Governance item'),
|
||||
'environment_label' => filled($item['tenant_label'] ?? null) ? (string) $item['tenant_label'] : 'Workspace-wide',
|
||||
'title' => (string) ($item['headline'] ?? 'Governance item'),
|
||||
'status_label' => (string) ($item['status_label'] ?? 'Needs attention'),
|
||||
'decision_label' => (string) ($item['decision_label'] ?? 'Review governance item'),
|
||||
'reason_label' => (string) ($item['reason_label'] ?? 'Reason unavailable'),
|
||||
'impact_label' => (string) ($item['impact_label'] ?? 'Impact unavailable'),
|
||||
'owner_label' => (string) ($item['owner_label'] ?? 'Owner unavailable'),
|
||||
'owner_state' => (string) ($item['owner_state'] ?? 'unavailable'),
|
||||
'due_label' => (string) ($item['due_label'] ?? 'Due date unavailable'),
|
||||
'due_state' => (string) ($item['due_state'] ?? 'unavailable'),
|
||||
'evidence_label' => (string) ($item['evidence_label'] ?? 'Evidence unavailable'),
|
||||
'evidence_state' => (string) ($item['evidence_state'] ?? 'unavailable'),
|
||||
'evidence_path_label' => (string) ($item['evidence_path_label'] ?? 'Proof path unavailable'),
|
||||
'evidence_path_url' => filled($item['evidence_path_url'] ?? null) ? (string) $item['evidence_path_url'] : null,
|
||||
'exception_label' => (string) ($item['exception_label'] ?? 'Accepted-risk state unavailable'),
|
||||
'exception_state' => (string) ($item['exception_state'] ?? 'unavailable'),
|
||||
'primary_action_label' => (string) ($item['primary_action_label'] ?? 'Open source'),
|
||||
'primary_action_url' => filled($item['primary_action_url'] ?? null)
|
||||
? (string) $item['primary_action_url']
|
||||
: (filled($item['destination_url'] ?? null) ? (string) $item['destination_url'] : null),
|
||||
'source_url' => filled($item['destination_url'] ?? null) ? (string) $item['destination_url'] : null,
|
||||
if (is_array($this->lanePayload)) {
|
||||
return $this->lanePayload;
|
||||
}
|
||||
|
||||
$lanes = collect(self::LANE_DEFINITIONS)
|
||||
->mapWithKeys(fn (array $definition): array => [
|
||||
$definition['key'] => [
|
||||
...$definition,
|
||||
'anchor_id' => 'lane-'.$definition['key'],
|
||||
'count' => 0,
|
||||
'items' => [],
|
||||
],
|
||||
])
|
||||
->all();
|
||||
|
||||
foreach ($this->workbenchEntries() as $entry) {
|
||||
$operatorItem = $this->buildOperatorItem($entry);
|
||||
$laneKey = (string) $operatorItem['lane_key'];
|
||||
|
||||
if (! array_key_exists($laneKey, $lanes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lanes[$laneKey]['items'][] = $operatorItem;
|
||||
$lanes[$laneKey]['count']++;
|
||||
}
|
||||
|
||||
foreach ($lanes as $key => $lane) {
|
||||
$lanes[$key]['items'] = collect($lane['items'])
|
||||
->sortBy([
|
||||
fn (array $item): int => (int) ($item['urgency_rank'] ?? 999),
|
||||
fn (array $item): string => (string) ($item['title'] ?? ''),
|
||||
])
|
||||
->take(self::LANE_PREVIEW_LIMIT)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
$visibleLanes = collect(self::LANE_DEFINITIONS)
|
||||
->map(fn (array $definition): array => $lanes[$definition['key']])
|
||||
->filter(fn (array $lane): bool => (int) $lane['count'] > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$summaryCounts = collect(self::LANE_DEFINITIONS)
|
||||
->map(fn (array $definition): array => [
|
||||
'key' => $definition['key'],
|
||||
'label' => $definition['label'],
|
||||
'count' => (int) ($lanes[$definition['key']]['count'] ?? 0),
|
||||
'description' => $definition['description'],
|
||||
'state' => (int) ($lanes[$definition['key']]['count'] ?? 0) > 0 ? 'active' : 'clear',
|
||||
'chip_label' => (int) ($lanes[$definition['key']]['count'] ?? 0) > 0
|
||||
? (string) ((int) ($lanes[$definition['key']]['count'] ?? 0))
|
||||
: 'Clear',
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$totalOpenItems = collect($summaryCounts)->sum('count');
|
||||
$nextRecommendedItem = $this->nextRecommendedItem($visibleLanes);
|
||||
|
||||
return $this->lanePayload = [
|
||||
'lanes' => $visibleLanes,
|
||||
'summary' => [
|
||||
'total_open_items' => $totalOpenItems,
|
||||
'headline' => $totalOpenItems === 1
|
||||
? '1 open governance item needs attention'
|
||||
: sprintf('%d open governance items need attention', $totalOpenItems),
|
||||
'counts' => $summaryCounts,
|
||||
'active_counts' => collect($summaryCounts)->where('state', 'active')->values()->all(),
|
||||
'clear_counts' => collect($summaryCounts)->where('state', 'clear')->values()->all(),
|
||||
'primary_action' => $nextRecommendedItem['primary_action'] ?? null,
|
||||
'next_recommended_item' => $nextRecommendedItem,
|
||||
'review_ready_supported' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, array<string, mixed>> $entries
|
||||
* @return list<array{label: string, value: string, description: string}>
|
||||
* @param array<string, mixed> $entry
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function summaryCards(\Illuminate\Support\Collection $entries): array
|
||||
private function buildOperatorItem(array $entry): array
|
||||
{
|
||||
$totalCount = (int) ($this->inboxPayload()['total_count'] ?? 0);
|
||||
$selectedSection = $entries
|
||||
->sortBy([
|
||||
fn (array $entry): int => (int) ($entry['urgency_rank'] ?? 999),
|
||||
fn (array $entry): string => (string) ($entry['headline'] ?? ''),
|
||||
])
|
||||
->first()['section_label'] ?? 'None';
|
||||
$ownerGaps = $entries
|
||||
->filter(fn (array $entry): bool => in_array((string) ($entry['owner_state'] ?? ''), ['missing', 'unavailable'], true))
|
||||
->count();
|
||||
$evidenceGaps = $entries
|
||||
->filter(fn (array $entry): bool => in_array((string) ($entry['evidence_state'] ?? ''), ['missing', 'unavailable'], true))
|
||||
->count();
|
||||
$laneKey = $this->classifyLane($entry);
|
||||
$tenant = $this->tenantForEntry($entry);
|
||||
$primaryActionUrl = filled($entry['primary_action_url'] ?? null)
|
||||
? (string) $entry['primary_action_url']
|
||||
: (filled($entry['destination_url'] ?? null) ? (string) $entry['destination_url'] : null);
|
||||
$primaryAction = $this->normalizeAction([
|
||||
'label' => (string) ($entry['primary_action_label'] ?? 'Open source'),
|
||||
'url' => $primaryActionUrl,
|
||||
]);
|
||||
|
||||
return [
|
||||
[
|
||||
'label' => 'Visible decisions',
|
||||
'value' => (string) $totalCount,
|
||||
'description' => 'Repo-backed attention items in the current scope.',
|
||||
],
|
||||
[
|
||||
'label' => 'Priority family',
|
||||
'value' => (string) $selectedSection,
|
||||
'description' => 'Highest-ranked visible preview item.',
|
||||
],
|
||||
[
|
||||
'label' => 'Owner gaps in preview',
|
||||
'value' => (string) $ownerGaps,
|
||||
'description' => 'Preview items with missing or unavailable ownership.',
|
||||
],
|
||||
[
|
||||
'label' => 'Evidence gaps in preview',
|
||||
'value' => (string) $evidenceGaps,
|
||||
'description' => 'Preview items without linked proof in the workbench.',
|
||||
],
|
||||
'lane_key' => $laneKey,
|
||||
'lane_label' => $this->laneDefinition($laneKey)['label'],
|
||||
'title' => (string) ($entry['headline'] ?? 'Governance item'),
|
||||
'status_label' => (string) ($entry['status_label'] ?? 'Needs attention'),
|
||||
'reason_heading' => $laneKey === 'blocked' ? 'Blocker' : 'Reason',
|
||||
'reason_label' => (string) ($entry['reason_label'] ?? 'Reason unavailable'),
|
||||
'impact_label' => (string) ($entry['impact_label'] ?? 'Impact unavailable'),
|
||||
'source_label' => (string) ($entry['section_label'] ?? 'Source record'),
|
||||
'environment_label' => filled($entry['tenant_label'] ?? null)
|
||||
? (string) $entry['tenant_label']
|
||||
: 'Workspace-wide',
|
||||
'context_label' => filled($entry['subline'] ?? null)
|
||||
? (string) $entry['subline']
|
||||
: null,
|
||||
'owner_label' => (string) ($entry['owner_label'] ?? 'Owner unavailable'),
|
||||
'due_label' => (string) ($entry['due_label'] ?? 'Due date unavailable'),
|
||||
'evidence_label' => (string) ($entry['evidence_label'] ?? 'Evidence unavailable'),
|
||||
'exception_label' => (string) ($entry['exception_label'] ?? 'Accepted-risk state unavailable'),
|
||||
'primary_action' => $primaryAction,
|
||||
'secondary_actions' => $this->secondaryActionsForEntry($entry, $tenant, $primaryActionUrl),
|
||||
'linked_records' => $this->linkedRecordsForEntry($entry, $tenant),
|
||||
'urgency_rank' => (int) ($entry['urgency_rank'] ?? 999),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function classifyLane(array $entry): string
|
||||
{
|
||||
$familyKey = (string) ($entry['family_key'] ?? '');
|
||||
|
||||
return match ($familyKey) {
|
||||
'intake_findings' => 'needs_triage',
|
||||
'finding_exceptions' => 'risk_exception_review',
|
||||
'stale_operations', 'alert_delivery_failures' => 'blocked',
|
||||
'assigned_findings' => (($entry['evidence_state'] ?? null) === 'missing')
|
||||
? 'evidence_required'
|
||||
: 'requires_decision',
|
||||
'review_follow_up' => 'requires_decision',
|
||||
default => 'requires_decision',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{key: string, label: string, description: string, empty_state: string}
|
||||
*/
|
||||
private function laneDefinition(string $laneKey): array
|
||||
{
|
||||
foreach (self::LANE_DEFINITIONS as $definition) {
|
||||
if ($definition['key'] === $laneKey) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return self::LANE_DEFINITIONS[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $visibleLanes
|
||||
*/
|
||||
private function nextRecommendedItem(array $visibleLanes): ?array
|
||||
{
|
||||
foreach ($visibleLanes as $lane) {
|
||||
$items = is_array($lane['items'] ?? null) ? $lane['items'] : [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$primaryAction = $this->normalizeAction($item['primary_action'] ?? null);
|
||||
|
||||
if ($primaryAction === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'headline' => $this->recommendedActionHeadline($item, $primaryAction),
|
||||
'lane_key' => (string) ($lane['key'] ?? $item['lane_key'] ?? 'requires_decision'),
|
||||
'lane_label' => (string) ($lane['label'] ?? $item['lane_label'] ?? 'Requires decision'),
|
||||
'lane_url' => filled($lane['anchor_id'] ?? null) ? '#'.$lane['anchor_id'] : null,
|
||||
'title' => (string) ($item['title'] ?? 'Governance item'),
|
||||
'status_label' => (string) ($item['status_label'] ?? 'Needs attention'),
|
||||
'environment_label' => (string) ($item['environment_label'] ?? 'Workspace-wide'),
|
||||
'reason_heading' => (string) ($item['reason_heading'] ?? 'Reason'),
|
||||
'reason_label' => (string) ($item['reason_label'] ?? 'Reason unavailable'),
|
||||
'impact_label' => (string) ($item['impact_label'] ?? 'Impact unavailable'),
|
||||
'primary_action' => $primaryAction,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @param array{label: string, url: string} $primaryAction
|
||||
*/
|
||||
private function recommendedActionHeadline(array $item, array $primaryAction): string
|
||||
{
|
||||
$title = (string) ($item['title'] ?? 'Governance item');
|
||||
$actionLabel = (string) ($primaryAction['label'] ?? 'Open source');
|
||||
$lowerAction = Str::lower($actionLabel);
|
||||
|
||||
if (Str::startsWith($title, 'Finding #') && Str::contains($lowerAction, 'finding')) {
|
||||
$verb = trim((string) Str::of($actionLabel)->before(' finding'));
|
||||
|
||||
if ($verb !== '') {
|
||||
return Str::ucfirst($verb).' '.$title;
|
||||
}
|
||||
}
|
||||
|
||||
if (Str::startsWith($title, 'Terminal follow-up operation') && Str::contains($lowerAction, 'operation')) {
|
||||
return 'Open terminal operation proof';
|
||||
}
|
||||
|
||||
return $actionLabel.': '.$title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
* @return list<array{label: string, url: string}>
|
||||
*/
|
||||
private function secondaryActionsForEntry(array $entry, ?ManagedEnvironment $tenant, ?string $primaryActionUrl): array
|
||||
{
|
||||
$familyKey = (string) ($entry['family_key'] ?? '');
|
||||
$actions = [];
|
||||
$environmentId = is_numeric($entry['managed_environment_id'] ?? null)
|
||||
? (int) $entry['managed_environment_id']
|
||||
: null;
|
||||
|
||||
if (in_array($familyKey, ['assigned_findings', 'intake_findings', 'finding_exceptions'], true)) {
|
||||
$this->appendUniqueLink(
|
||||
$actions,
|
||||
'Open decision register',
|
||||
$this->decisionRegisterUrlFor($tenant),
|
||||
[$primaryActionUrl],
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($familyKey, ['assigned_findings', 'intake_findings', 'review_follow_up'], true)) {
|
||||
$this->appendUniqueLink(
|
||||
$actions,
|
||||
'Open evidence overview',
|
||||
$this->evidenceOverviewUrlFor($tenant),
|
||||
[$primaryActionUrl],
|
||||
);
|
||||
}
|
||||
|
||||
if ($familyKey === 'review_follow_up' && $tenant instanceof ManagedEnvironment) {
|
||||
$this->appendUniqueLink(
|
||||
$actions,
|
||||
'Open Customer Review Workspace',
|
||||
CustomerReviewWorkspace::environmentFilterUrl($tenant),
|
||||
[$primaryActionUrl],
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($familyKey, ['stale_operations', 'alert_delivery_failures', 'assigned_findings', 'intake_findings'], true) && $tenant instanceof ManagedEnvironment) {
|
||||
$this->appendUniqueLink(
|
||||
$actions,
|
||||
'Open environment',
|
||||
ManagedEnvironmentLinks::viewUrl($tenant),
|
||||
[$primaryActionUrl],
|
||||
);
|
||||
}
|
||||
|
||||
if ($familyKey === 'finding_exceptions') {
|
||||
$this->appendUniqueLink(
|
||||
$actions,
|
||||
'Open source finding',
|
||||
$entry['destination_url'] ?? null,
|
||||
[$primaryActionUrl],
|
||||
);
|
||||
}
|
||||
|
||||
if ($environmentId === null && filled($entry['destination_url'] ?? null)) {
|
||||
$this->appendUniqueLink(
|
||||
$actions,
|
||||
'Open source',
|
||||
$entry['destination_url'] ?? null,
|
||||
[$primaryActionUrl],
|
||||
);
|
||||
}
|
||||
|
||||
return array_slice($actions, 0, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
* @return list<array{label: string, url: string}>
|
||||
*/
|
||||
private function linkedRecordsForEntry(array $entry, ?ManagedEnvironment $tenant): array
|
||||
{
|
||||
$familyKey = (string) ($entry['family_key'] ?? '');
|
||||
$records = [];
|
||||
|
||||
$this->appendUniqueLink($records, 'Source record', $entry['destination_url'] ?? null);
|
||||
$this->appendUniqueLink($records, 'Evidence path', $entry['evidence_path_url'] ?? null);
|
||||
|
||||
if (in_array($familyKey, ['assigned_findings', 'intake_findings', 'finding_exceptions'], true)) {
|
||||
$this->appendUniqueLink($records, 'Decision register', $this->decisionRegisterUrlFor($tenant));
|
||||
}
|
||||
|
||||
if ($familyKey === 'review_follow_up' && $tenant instanceof ManagedEnvironment) {
|
||||
$this->appendUniqueLink($records, 'Customer Review Workspace', CustomerReviewWorkspace::environmentFilterUrl($tenant));
|
||||
}
|
||||
|
||||
if ($familyKey === 'stale_operations') {
|
||||
$this->appendUniqueLink($records, 'Operation proof', $entry['destination_url'] ?? null);
|
||||
}
|
||||
|
||||
return array_slice($records, 0, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{label: string, url: string}> $links
|
||||
* @param list<string|null> $ignoredUrls
|
||||
*/
|
||||
private function appendUniqueLink(array &$links, string $label, mixed $url, array $ignoredUrls = []): void
|
||||
{
|
||||
if (! is_string($url) || $url === '' || in_array($url, $ignoredUrls, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($links as $existingLink) {
|
||||
if (($existingLink['url'] ?? null) === $url) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $family
|
||||
* @return array{key: string, label: string, count: int}
|
||||
*/
|
||||
private function normalizeFamily(array $family): array
|
||||
{
|
||||
$key = (string) ($family['key'] ?? 'governance');
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => filled($family['label'] ?? null)
|
||||
? (string) $family['label']
|
||||
: Str::headline($key),
|
||||
'count' => (int) ($family['count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $section
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeSection(array $section): array
|
||||
{
|
||||
$key = (string) ($section['key'] ?? 'governance');
|
||||
$label = filled($section['label'] ?? null)
|
||||
? (string) $section['label']
|
||||
: Str::headline($key);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'count' => (int) ($section['count'] ?? 0),
|
||||
'summary' => filled($section['summary'] ?? null)
|
||||
? (string) $section['summary']
|
||||
: 'No summary is available for this source family.',
|
||||
'dominant_action' => $this->normalizeAction([
|
||||
'label' => (string) ($section['dominant_action_label'] ?? 'Open '.$label),
|
||||
'url' => $section['dominant_action_url'] ?? null,
|
||||
]),
|
||||
'dominant_action_label' => (string) ($section['dominant_action_label'] ?? 'Open '.$label),
|
||||
'dominant_action_url' => is_string($section['dominant_action_url'] ?? null)
|
||||
? (string) $section['dominant_action_url']
|
||||
: null,
|
||||
'entries' => collect($section['entries'] ?? [])
|
||||
->filter(fn (mixed $entry): bool => is_array($entry))
|
||||
->map(fn (array $entry): array => $this->normalizeSourceEntry($entry))
|
||||
->values()
|
||||
->all(),
|
||||
'empty_state' => filled($section['empty_state'] ?? null)
|
||||
? (string) $section['empty_state']
|
||||
: 'No source records are visible in the current scope.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeSourceEntry(array $entry): array
|
||||
{
|
||||
return [
|
||||
...$entry,
|
||||
'headline' => filled($entry['headline'] ?? null)
|
||||
? (string) $entry['headline']
|
||||
: 'Governance item',
|
||||
'status_label' => filled($entry['status_label'] ?? null)
|
||||
? (string) $entry['status_label']
|
||||
: 'Needs attention',
|
||||
'destination_url' => is_string($entry['destination_url'] ?? null)
|
||||
? (string) $entry['destination_url']
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
private function normalizeAction(mixed $action): ?array
|
||||
{
|
||||
if (! is_array($action)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $action['url'] ?? null;
|
||||
|
||||
if (! is_string($url) || $url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = filled($action['label'] ?? null)
|
||||
? (string) $action['label']
|
||||
: 'Open source';
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
private function tenantForEntry(array $entry): ?ManagedEnvironment
|
||||
{
|
||||
$environmentId = is_numeric($entry['managed_environment_id'] ?? null)
|
||||
? (int) $entry['managed_environment_id']
|
||||
: null;
|
||||
|
||||
if (! is_int($environmentId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((int) $tenant->getKey() === $environmentId) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function decisionRegisterUrlFor(?ManagedEnvironment $tenant = null): string
|
||||
{
|
||||
return DecisionRegister::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'environment_id' => $tenant?->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
private function evidenceOverviewUrlFor(?ManagedEnvironment $tenant = null): string
|
||||
{
|
||||
return route('admin.evidence.overview', array_filter([
|
||||
'environment_id' => $tenant?->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''));
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceMembership(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -668,7 +1216,20 @@ private function tenantFilterAloneExcludesRows(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->sections() !== []) {
|
||||
if ($this->laneGroups() !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private function familyFilterAloneExcludesRows(): bool
|
||||
{
|
||||
if ($this->family === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->laneGroups() !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -261,13 +261,14 @@ protected function getHeaderActions(): array
|
||||
|
||||
$selectedContextActions = [
|
||||
Action::make('clear_selected_exception')
|
||||
->label('Close details')
|
||||
->label('Exit focused review')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
->url(fn (): string => $this->queueUrl(['exception' => null])),
|
||||
|
||||
Action::make('open_selected_exception')
|
||||
->label('Open environment detail')
|
||||
->label('Open exception detail')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException)
|
||||
@ -358,7 +359,7 @@ protected function getHeaderActions(): array
|
||||
];
|
||||
|
||||
$actions[] = ActionGroup::make($selectedContextActions)
|
||||
->label('Selected context')
|
||||
->label('Focused review')
|
||||
->icon('heroicon-o-rectangle-stack')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingException() instanceof FindingException);
|
||||
|
||||
@ -498,7 +498,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Diff')
|
||||
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||
->visible(fn (Finding $record): bool => static::hasRenderableDiffSection($record))
|
||||
->schema([
|
||||
ViewEntry::make('rbac_role_definition_diff')
|
||||
->label('')
|
||||
@ -557,7 +557,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
||||
})
|
||||
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
||||
->visible(fn (Finding $record): bool => static::driftSummaryKind($record) === 'policy_scope_tags')
|
||||
->columnSpanFull(),
|
||||
|
||||
ViewEntry::make('assignments_diff')
|
||||
@ -577,7 +577,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
||||
})
|
||||
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
||||
->visible(fn (Finding $record): bool => static::driftSummaryKind($record) === 'policy_assignments')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsed()
|
||||
@ -609,6 +609,21 @@ private static function driftSummaryKind(Finding $record): string
|
||||
return is_string($summaryKind) ? trim($summaryKind) : '';
|
||||
}
|
||||
|
||||
private static function hasRenderableDiffSection(Finding $record): bool
|
||||
{
|
||||
if ($record->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match (static::driftSummaryKind($record)) {
|
||||
'policy_snapshot',
|
||||
'policy_scope_tags',
|
||||
'policy_assignments' => true,
|
||||
'rbac_role_definition' => is_array(Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition')),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static function isRbacRoleDefinitionDrift(Finding $record): bool
|
||||
{
|
||||
return static::driftSummaryKind($record) === 'rbac_role_definition'
|
||||
@ -1844,14 +1859,17 @@ public static function reopenAction(): Actions\Action
|
||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||
{
|
||||
$pageRecord = $record;
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = static::resolveWorkflowTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
static::notifyWorkflowContextUnavailable();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
||||
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different tenant')
|
||||
@ -1896,14 +1914,17 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
private static function runResponsibilityMutation(Finding $record, array $data, FindingWorkflowService $workflow): void
|
||||
{
|
||||
$pageRecord = $record;
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = static::resolveWorkflowTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
static::notifyWorkflowContextUnavailable();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
||||
|
||||
if ((int) $record->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different tenant')
|
||||
@ -1965,14 +1986,17 @@ private static function runResponsibilityMutation(Finding $record, array $data,
|
||||
*/
|
||||
private static function runExceptionRequestMutation(Finding $record, array $data, FindingExceptionService $service): void
|
||||
{
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = static::resolveWorkflowTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
static::notifyWorkflowContextUnavailable();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
||||
|
||||
try {
|
||||
$createdException = $service->request($record, $tenant, $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
@ -2002,15 +2026,17 @@ private static function runExceptionRequestMutation(Finding $record, array $data
|
||||
*/
|
||||
private static function runExceptionRenewalMutation(Finding $record, array $data, FindingExceptionService $service): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = static::resolveWorkflowTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
static::notifyWorkflowContextUnavailable();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$renewedException = $service->renew(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
|
||||
$renewedException = $service->renew(static::resolveCurrentFindingExceptionOrFail($record, $tenant), $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Renewal request failed')
|
||||
@ -2038,15 +2064,17 @@ private static function runExceptionRenewalMutation(Finding $record, array $data
|
||||
*/
|
||||
private static function runExceptionRevocationMutation(Finding $record, array $data, FindingExceptionService $service): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = static::resolveWorkflowTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
static::notifyWorkflowContextUnavailable();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$revokedException = $service->revoke(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
|
||||
$revokedException = $service->revoke(static::resolveCurrentFindingExceptionOrFail($record, $tenant), $user, $data);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Exception revocation failed')
|
||||
@ -2078,9 +2106,17 @@ private static function freshWorkflowStatus(Finding $record): string
|
||||
return (string) static::freshWorkflowRecord($record)->status;
|
||||
}
|
||||
|
||||
private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record): Finding
|
||||
private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record, ?ManagedEnvironment $tenant = null): Finding
|
||||
{
|
||||
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||
$resolvedRecord = $tenant instanceof ManagedEnvironment
|
||||
? static::resolveTenantOwnedRecordOrFail(
|
||||
$record instanceof Model ? $record->getKey() : $record,
|
||||
parent::getEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
||||
->withSubjectDisplayName(),
|
||||
$tenant,
|
||||
)
|
||||
: static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
|
||||
|
||||
if (! $resolvedRecord instanceof Finding) {
|
||||
abort(404);
|
||||
@ -2089,9 +2125,37 @@ private static function resolveProtectedFindingRecordOrFail(Finding|int|string $
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
private static function currentFindingException(Finding $record): ?FindingException
|
||||
private static function resolveWorkflowTenantForRecord(Finding $record): ?ManagedEnvironment
|
||||
{
|
||||
$finding = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenantId = $record->managed_environment_id;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->find((int) $tenantId);
|
||||
}
|
||||
|
||||
private static function notifyWorkflowContextUnavailable(): void
|
||||
{
|
||||
Notification::make()
|
||||
->title('Workflow action unavailable')
|
||||
->body('Reload the environment-scoped finding page and try again.')
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
|
||||
private static function currentFindingException(Finding $record, ?ManagedEnvironment $tenant = null): ?FindingException
|
||||
{
|
||||
$finding = static::resolveProtectedFindingRecordOrFail($record, $tenant);
|
||||
|
||||
return static::resolvedFindingException($finding);
|
||||
}
|
||||
@ -2126,9 +2190,9 @@ private static function resolvedFindingException(Finding $finding): ?FindingExce
|
||||
return $exception;
|
||||
}
|
||||
|
||||
private static function resolveCurrentFindingExceptionOrFail(Finding $record): FindingException
|
||||
private static function resolveCurrentFindingExceptionOrFail(Finding $record, ?ManagedEnvironment $tenant = null): FindingException
|
||||
{
|
||||
$exception = static::currentFindingException($record);
|
||||
$exception = static::currentFindingException($record, $tenant);
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
throw new InvalidArgumentException('This finding does not have an exception to manage.');
|
||||
|
||||
@ -248,8 +248,8 @@ private function findingExceptionsSection(
|
||||
FindingExceptionsQueue::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant' => $selectedTenant?->external_id,
|
||||
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
|
||||
'environment_id' => $selectedTenant?->getKey(),
|
||||
], static fn (mixed $value): bool => is_numeric($value)),
|
||||
),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
@ -440,18 +440,16 @@ private function alertsSection(
|
||||
'summary' => $this->alertsSummary($count),
|
||||
'dominant_action_label' => 'Open alert deliveries',
|
||||
'dominant_action_url' => $this->appendQuery(
|
||||
AlertDeliveryResource::getUrl(panel: 'admin'),
|
||||
array_replace_recursive(
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
AlertDeliveryResource::getUrl(
|
||||
'index',
|
||||
array_filter([
|
||||
'environment_id' => $selectedTenant instanceof ManagedEnvironment
|
||||
? (int) $selectedTenant->getKey()
|
||||
: null,
|
||||
'tableFilters' => array_filter([
|
||||
'status' => ['value' => AlertDelivery::STATUS_FAILED],
|
||||
]),
|
||||
], static fn (mixed $value): bool => $value !== null),
|
||||
panel: 'admin',
|
||||
),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'entries' => $entries,
|
||||
'empty_state' => $selectedTenant instanceof ManagedEnvironment
|
||||
@ -937,7 +935,7 @@ private function findingExceptionEntry(FindingException $exception, ?CanonicalNa
|
||||
FindingExceptionsQueue::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant' => $exception->tenant?->external_id,
|
||||
'environment_id' => $exception->tenant?->getKey(),
|
||||
'exception' => (int) $exception->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
),
|
||||
@ -963,7 +961,7 @@ private function findingExceptionEntry(FindingException $exception, ?CanonicalNa
|
||||
FindingExceptionsQueue::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant' => $exception->tenant?->external_id,
|
||||
'environment_id' => $exception->tenant?->getKey(),
|
||||
'exception' => (int) $exception->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
),
|
||||
@ -976,7 +974,7 @@ private function findingExceptionEntry(FindingException $exception, ?CanonicalNa
|
||||
FindingExceptionsQueue::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant' => $exception->tenant?->external_id,
|
||||
'environment_id' => $exception->tenant?->getKey(),
|
||||
'exception' => (int) $exception->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
),
|
||||
|
||||
@ -650,14 +650,10 @@ private function resolveTenantWithRecord(?Model $record = null): ?ManagedEnviron
|
||||
return $record;
|
||||
}
|
||||
|
||||
// If a record has an eagerly-loaded `tenant` relation, prefer it.
|
||||
// This avoids relying on Filament::getTenant() for list pages that are not tenant-scoped.
|
||||
if ($record instanceof Model && method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) {
|
||||
$relatedTenant = $record->getRelation('tenant');
|
||||
$recordTenant = $this->resolveTenantFromOwnedRecord($record);
|
||||
|
||||
if ($relatedTenant instanceof ManagedEnvironment) {
|
||||
return $relatedTenant;
|
||||
}
|
||||
if ($recordTenant instanceof ManagedEnvironment) {
|
||||
return $recordTenant;
|
||||
}
|
||||
|
||||
if ($this->action instanceof Action) {
|
||||
@ -667,12 +663,10 @@ private function resolveTenantWithRecord(?Model $record = null): ?ManagedEnviron
|
||||
return $actionRecord;
|
||||
}
|
||||
|
||||
if ($actionRecord instanceof Model && method_exists($actionRecord, 'relationLoaded') && $actionRecord->relationLoaded('tenant')) {
|
||||
$relatedTenant = $actionRecord->getRelation('tenant');
|
||||
$actionRecordTenant = $this->resolveTenantFromOwnedRecord($actionRecord);
|
||||
|
||||
if ($relatedTenant instanceof ManagedEnvironment) {
|
||||
return $relatedTenant;
|
||||
}
|
||||
if ($actionRecordTenant instanceof ManagedEnvironment) {
|
||||
return $actionRecordTenant;
|
||||
}
|
||||
}
|
||||
|
||||
@ -685,9 +679,40 @@ private function resolveTenantWithRecord(?Model $record = null): ?ManagedEnviron
|
||||
if ($resolved instanceof ManagedEnvironment) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$resolvedTenant = $this->resolveTenantFromOwnedRecord($resolved);
|
||||
|
||||
if ($resolvedTenant instanceof ManagedEnvironment) {
|
||||
return $resolvedTenant;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: use Filament's current tenant
|
||||
return Filament::getTenant();
|
||||
}
|
||||
|
||||
private function resolveTenantFromOwnedRecord(mixed $record): ?ManagedEnvironment
|
||||
{
|
||||
if (! $record instanceof Model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) {
|
||||
$relatedTenant = $record->getRelation('tenant');
|
||||
|
||||
if ($relatedTenant instanceof ManagedEnvironment) {
|
||||
return $relatedTenant;
|
||||
}
|
||||
}
|
||||
|
||||
$tenantId = $record->getAttribute('managed_environment_id');
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->find((int) $tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,329 +1,602 @@
|
||||
<x-filament-panels::page>
|
||||
@php
|
||||
$scope = $this->appliedScope();
|
||||
$sections = $this->sections();
|
||||
$summary = $this->operatorSummary();
|
||||
$lanes = $this->laneGroups();
|
||||
$emptyState = $this->calmEmptyState();
|
||||
$workbench = $this->decisionWorkbench();
|
||||
$selectedItem = $workbench['selected_item'] ?? null;
|
||||
$diagnostics = $workbench['diagnostics'] ?? [];
|
||||
$recentlyResolved = $this->recentlyResolved();
|
||||
$sections = $this->sections();
|
||||
$diagnostics = $this->diagnosticsPanel();
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-3" data-testid="governance-inbox-secondary-filters">
|
||||
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
@if (filled($scope['workspace_label'] ?? null))
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
Workspace: {{ $scope['workspace_label'] }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
Scope: {{ $scope['family_label'] ?? 'All attention' }}
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
Visible items: {{ $scope['total_count'] ?? 0 }}
|
||||
</span>
|
||||
|
||||
@if (filled($scope['tenant_label'] ?? null))
|
||||
<span class="inline-flex items-center rounded-md bg-warning-50 px-2.5 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
|
||||
Environment: {{ $scope['tenant_label'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($this->hasTenantPrefilter())
|
||||
@include('filament.partials.workspace-hub-environment-filter-chip', [
|
||||
'label' => $scope['tenant_label'] ?? null,
|
||||
'clearUrl' => $this->pageUrl(['environment_id' => null, 'family' => null]),
|
||||
])
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap gap-2" data-testid="governance-inbox-family-filters">
|
||||
<a
|
||||
href="{{ $this->pageUrl(['family' => null]) }}"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font-medium transition {{ $this->family === null ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
|
||||
>
|
||||
All attention
|
||||
<span class="rounded-md bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $scope['total_count'] ?? 0 }}</span>
|
||||
</a>
|
||||
|
||||
@foreach ($this->availableFamilies() as $family)
|
||||
<a
|
||||
href="{{ $this->pageUrl(['family' => $family['key']]) }}"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font-medium transition {{ $this->isActiveFamily($family['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
|
||||
>
|
||||
{{ $family['label'] }}
|
||||
<span class="rounded-md bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $family['count'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]" data-testid="governance-inbox-decision-workbench">
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900" data-testid="governance-inbox-priority-card">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
Decision workbench
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ $workbench['question'] }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@if ($selectedItem !== null)
|
||||
<span class="inline-flex w-fit items-center rounded-lg bg-warning-50 px-2.5 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
|
||||
{{ $selectedItem['status_label'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($selectedItem === null)
|
||||
<div class="mt-5 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-5 dark:border-gray-700 dark:bg-gray-950/40" data-testid="governance-inbox-empty-decision-state">
|
||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $emptyState['title'] }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $emptyState['body'] }}
|
||||
</p>
|
||||
|
||||
@if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null))
|
||||
<div class="mt-4">
|
||||
<x-filament::button tag="a" color="gray" href="{{ $emptyState['action_url'] }}">
|
||||
{{ $emptyState['action_label'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-5 space-y-5">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ $selectedItem['section_label'] }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
Environment: {{ $selectedItem['environment_label'] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||
{{ $selectedItem['title'] }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $selectedItem['decision_label'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Reason</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">{{ $selectedItem['reason_label'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Impact</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">{{ $selectedItem['impact_label'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Owner</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">{{ $selectedItem['owner_label'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Due</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">{{ $selectedItem['due_label'] }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Evidence</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">{{ $selectedItem['evidence_label'] }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Accepted risk</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">{{ $selectedItem['exception_label'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filled($selectedItem['primary_action_url'] ?? null))
|
||||
<div class="flex flex-col gap-2 rounded-lg border border-gray-200 p-3 dark:border-gray-800 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Primary next action</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">{{ $selectedItem['primary_action_label'] }}</p>
|
||||
</div>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="{{ $selectedItem['primary_action_url'] }}"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
>
|
||||
{{ $selectedItem['primary_action_label'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<aside class="rounded-lg border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900" data-testid="governance-inbox-decision-detail">
|
||||
<div class="space-y-4">
|
||||
<section
|
||||
class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
data-testid="governance-inbox-operator-summary"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Decision summary</p>
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $selectedItem['title'] ?? 'No selected decision' }}
|
||||
<p class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
Open governance work
|
||||
</p>
|
||||
<h2 class="text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ $summary['headline'] }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@if ($selectedItem !== null)
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Impact</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">{{ $selectedItem['impact_label'] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Evidence path</p>
|
||||
@if (filled($selectedItem['evidence_path_url'] ?? null))
|
||||
<a href="{{ $selectedItem['evidence_path_url'] }}" class="mt-1 inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:underline dark:text-primary-300">
|
||||
{{ $selectedItem['evidence_path_label'] }}
|
||||
<x-filament::icon icon="heroicon-m-arrow-top-right-on-square" class="h-4 w-4" />
|
||||
</a>
|
||||
@else
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">{{ $selectedItem['evidence_path_label'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Accepted risk</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">{{ $selectedItem['exception_label'] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Owner / due</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">{{ $selectedItem['owner_label'] }} · {{ $selectedItem['due_label'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filled($selectedItem['primary_action_url'] ?? null))
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Primary next action</p>
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
color="gray"
|
||||
href="{{ $selectedItem['primary_action_url'] }}"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
class="w-full"
|
||||
>
|
||||
{{ $selectedItem['primary_action_label'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Impact</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">Unavailable</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Owner / due</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">Unavailable</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Evidence path</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">Unavailable</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Accepted risk</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">Unavailable</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Primary next action</p>
|
||||
<p class="mt-1 text-gray-800 dark:text-gray-100">No action available</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<details class="rounded-lg border border-gray-200 p-3 dark:border-gray-800" data-testid="governance-inbox-diagnostics">
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ $diagnostics['label'] ?? 'Diagnostics' }} · {{ $diagnostics['state'] ?? 'Collapsed' }}
|
||||
</summary>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $diagnostics['body'] ?? 'Diagnostics are not default-visible.' }}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@if ($sections !== [])
|
||||
<div class="space-y-4" data-testid="governance-inbox-queue-context">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-lg font-semibold text-gray-950 dark:text-white">Queue context</h2>
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Secondary source-family context remains available after the priority decision.
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
The inbox turns current findings, accepted-risk records, evidence gaps, review follow-up, and blocked operations into one operator queue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@foreach ($sections as $section)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300" data-testid="governance-inbox-summary-context">
|
||||
@if (filled($scope['workspace_label'] ?? null))
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Workspace: {{ $scope['workspace_label'] }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Source focus: {{ $scope['family_label'] ?? 'All source families' }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if (filled($scope['tenant_label'] ?? null))
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Environment: {{ $scope['tenant_label'] }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($this->hasTenantPrefilter())
|
||||
<div class="mt-4">
|
||||
@include('filament.partials.workspace-hub-environment-filter-chip', [
|
||||
'label' => $scope['tenant_label'] ?? null,
|
||||
'clearUrl' => $this->pageUrl(['environment_id' => null, 'family' => null]),
|
||||
])
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (is_array($summary['next_recommended_item'] ?? null))
|
||||
@php
|
||||
$nextItem = $summary['next_recommended_item'];
|
||||
$nextLaneColor = match ($nextItem['lane_key'] ?? null) {
|
||||
'needs_triage', 'risk_exception_review', 'evidence_required' => 'warning',
|
||||
'blocked' => 'danger',
|
||||
'requires_decision' => 'info',
|
||||
default => 'gray',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div
|
||||
class="mt-4 rounded-xl border border-primary-200 bg-primary-50/60 p-4 dark:border-primary-800/70 dark:bg-primary-950/20"
|
||||
data-testid="governance-inbox-next-action"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0 space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-primary-700 dark:text-primary-300">
|
||||
Next recommended action
|
||||
</p>
|
||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $nextItem['headline'] }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs font-medium">
|
||||
<x-filament::badge :color="$nextLaneColor" size="sm">
|
||||
{{ $nextItem['lane_label'] }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $nextItem['status_label'] }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $nextItem['environment_label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
{{ $nextItem['reason_heading'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $nextItem['reason_label'] }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Impact
|
||||
</dt>
|
||||
<dd class="mt-1 leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $nextItem['impact_label'] }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-col gap-2 sm:flex-row lg:flex-col">
|
||||
<x-filament::button tag="a" href="{{ $nextItem['primary_action']['url'] }}" icon="heroicon-o-arrow-top-right-on-square">
|
||||
{{ $nextItem['primary_action']['label'] }}
|
||||
</x-filament::button>
|
||||
|
||||
@if (filled($nextItem['lane_url'] ?? null))
|
||||
<x-filament::button tag="a" color="gray" href="{{ $nextItem['lane_url'] }}">
|
||||
View lane
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (($summary['active_counts'] ?? []) !== [])
|
||||
<div class="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3" data-testid="governance-inbox-summary-active-counts">
|
||||
@foreach ($summary['active_counts'] as $countCard)
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-950/40">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
{{ $countCard['label'] }}
|
||||
</p>
|
||||
<x-filament::badge color="gray" size="xs">
|
||||
{{ $countCard['count'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-5 text-gray-600 dark:text-gray-300">
|
||||
{{ $countCard['description'] }}
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (($summary['clear_counts'] ?? []) !== [])
|
||||
<div class="mt-3 flex flex-wrap gap-2" data-testid="governance-inbox-summary-clear-counts">
|
||||
@foreach ($summary['clear_counts'] as $countCard)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $countCard['label'] }}
|
||||
·
|
||||
{{ $countCard['chip_label'] }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@if ($lanes === [])
|
||||
<section
|
||||
class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-6 dark:border-gray-700 dark:bg-gray-950/40"
|
||||
data-testid="governance-inbox-empty-state"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||
{{ $emptyState['title'] }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $emptyState['body'] }}
|
||||
</p>
|
||||
|
||||
@if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null))
|
||||
<div class="mt-4">
|
||||
<x-filament::button tag="a" color="gray" href="{{ $emptyState['action_url'] }}">
|
||||
{{ $emptyState['action_label'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@else
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-testid="governance-inbox-lanes"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-gray-950 dark:text-white">Primary inbox lanes</h2>
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Active operator work stays grouped by the next path needed to clear it. Supporting source context stays below the fold.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@foreach ($lanes as $lane)
|
||||
<section
|
||||
id="{{ $lane['anchor_id'] }}"
|
||||
class="scroll-mt-24 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm target:ring-2 target:ring-primary-500 target:ring-offset-2 target:ring-offset-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:target:ring-offset-gray-950 sm:p-5"
|
||||
data-testid="governance-inbox-lane-{{ $lane['key'] }}"
|
||||
>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">{{ $section['label'] }}</h3>
|
||||
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ $section['count'] }}
|
||||
</span>
|
||||
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">{{ $lane['label'] }}</h3>
|
||||
<x-filament::badge color="gray" size="xs">
|
||||
{{ $lane['count'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $lane['description'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<x-filament::button tag="a" color="gray" href="{{ $section['dominant_action_url'] }}">
|
||||
{{ $section['dominant_action_label'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
@if ($section['count'] === 0)
|
||||
<div class="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-950/50 dark:text-gray-300">
|
||||
{{ $section['empty_state'] }}
|
||||
@if ($lane['count'] === 0)
|
||||
<div class="mt-4 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-950/50 dark:text-gray-300">
|
||||
{{ $lane['empty_state'] }}
|
||||
</div>
|
||||
@else
|
||||
<ul class="mt-4 grid gap-3">
|
||||
@foreach ($section['entries'] as $entry)
|
||||
<li class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
|
||||
@foreach ($lane['items'] as $item)
|
||||
<li
|
||||
class="rounded-xl border border-gray-200 p-3 dark:border-gray-800 sm:p-4"
|
||||
data-testid="governance-inbox-item-{{ $lane['key'] }}-{{ $loop->iteration }}"
|
||||
>
|
||||
@php
|
||||
$itemLaneColor = match ($item['lane_key'] ?? $lane['key'] ?? null) {
|
||||
'needs_triage', 'risk_exception_review', 'evidence_required' => 'warning',
|
||||
'blocked' => 'danger',
|
||||
'requires_decision' => 'info',
|
||||
default => 'gray',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-1.5">
|
||||
@if (filled($entry['tenant_label'] ?? null))
|
||||
<div class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['tenant_label'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="{{ $entry['destination_url'] }}" class="text-sm font-semibold text-gray-950 hover:text-primary-600 dark:text-white dark:hover:text-primary-300">
|
||||
{{ $entry['headline'] }}
|
||||
</a>
|
||||
|
||||
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ $entry['status_label'] }}
|
||||
</span>
|
||||
<x-filament::badge :color="$itemLaneColor" size="sm">
|
||||
{{ $item['lane_label'] }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $item['status_label'] }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $item['environment_label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (filled($entry['subline'] ?? null))
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $entry['subline'] }}</p>
|
||||
<h4 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||
{{ $item['title'] }}
|
||||
</h4>
|
||||
|
||||
@if (filled($item['context_label'] ?? null))
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $item['context_label'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-filament::button tag="a" color="gray" size="sm" href="{{ $entry['destination_url'] }}">
|
||||
Open source
|
||||
</x-filament::button>
|
||||
@if (is_array($item['primary_action'] ?? null))
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="{{ $item['primary_action']['url'] }}"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
>
|
||||
{{ $item['primary_action']['label'] }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<dt class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
{{ $item['reason_heading'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $item['reason_label'] }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<dt class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Impact
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $item['impact_label'] }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<details class="mt-3 rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<summary class="cursor-pointer text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
More context
|
||||
</summary>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Source
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $item['source_label'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Owner / due
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $item['owner_label'] }} · {{ $item['due_label'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Evidence
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $item['evidence_label'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Accepted risk / decision
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-800 dark:text-gray-100">
|
||||
{{ $item['exception_label'] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (($item['linked_records'] ?? []) !== [] || ($item['secondary_actions'] ?? []) !== [])
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
@if (($item['linked_records'] ?? []) !== [])
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Linked records
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($item['linked_records'] as $record)
|
||||
<x-filament::link
|
||||
href="{{ $record['url'] }}"
|
||||
color="gray"
|
||||
icon="heroicon-m-arrow-top-right-on-square"
|
||||
icon-position="after"
|
||||
size="sm"
|
||||
>
|
||||
{{ $record['label'] }}
|
||||
</x-filament::link>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (($item['secondary_actions'] ?? []) !== [])
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
Secondary actions
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($item['secondary_actions'] as $action)
|
||||
<x-filament::link
|
||||
href="{{ $action['url'] }}"
|
||||
icon="heroicon-m-arrow-top-right-on-square"
|
||||
icon-position="after"
|
||||
size="sm"
|
||||
>
|
||||
{{ $action['label'] }}
|
||||
</x-filament::link>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</details>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($recentlyResolved !== null)
|
||||
<details
|
||||
class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
data-testid="governance-inbox-recently-resolved"
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ $recentlyResolved['label'] }} · {{ $recentlyResolved['count'] }}
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $recentlyResolved['summary'] }}
|
||||
</p>
|
||||
|
||||
@if (($recentlyResolved['rows'] ?? []) !== [])
|
||||
<ul class="grid gap-3">
|
||||
@foreach ($recentlyResolved['rows'] as $row)
|
||||
<li class="rounded-xl border border-gray-200 p-4 dark:border-gray-800">
|
||||
<p class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row['title'] }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $row['reason'] }}</p>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
<x-filament::button tag="a" color="gray" href="{{ $recentlyResolved['open_url'] }}">
|
||||
{{ $recentlyResolved['open_label'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
|
||||
<details
|
||||
id="source-detail"
|
||||
class="scroll-mt-24 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
data-testid="governance-inbox-source-detail"
|
||||
@if ($this->family !== null) open @endif
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
Source detail
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">Source-family context</h2>
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Existing family slices remain available for drill-through and source truth checks, but they no longer dominate the first screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2" data-testid="governance-inbox-family-filters">
|
||||
<a
|
||||
href="{{ $this->pageUrl(['family' => null]) }}#source-detail"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font-medium transition {{ $this->family === null ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
|
||||
@if ($this->family === null) aria-current="page" @endif
|
||||
>
|
||||
All source families
|
||||
<x-filament::badge color="gray" size="xs">
|
||||
{{ $scope['total_count'] ?? 0 }}
|
||||
</x-filament::badge>
|
||||
</a>
|
||||
|
||||
@foreach ($this->availableFamilies() as $family)
|
||||
<a
|
||||
href="{{ $this->pageUrl(['family' => $family['key']]) }}#source-detail"
|
||||
class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font-medium transition {{ $this->isActiveFamily($family['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
|
||||
@if ($this->isActiveFamily($family['key'])) aria-current="page" @endif
|
||||
>
|
||||
{{ $family['label'] }}
|
||||
<x-filament::badge color="gray" size="xs">
|
||||
{{ $family['count'] }}
|
||||
</x-filament::badge>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($sections !== [])
|
||||
<div class="space-y-4">
|
||||
@foreach ($sections as $section)
|
||||
<div class="rounded-xl border border-gray-200 p-4 dark:border-gray-800">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">{{ $section['label'] }}</h3>
|
||||
<x-filament::badge color="gray" size="xs">
|
||||
{{ $section['count'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
|
||||
</div>
|
||||
|
||||
@if (is_array($section['dominant_action'] ?? null))
|
||||
<x-filament::button tag="a" color="gray" size="sm" href="{{ $section['dominant_action']['url'] }}">
|
||||
{{ $section['dominant_action']['label'] }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($section['count'] === 0)
|
||||
<div class="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-950/50 dark:text-gray-300">
|
||||
{{ $section['empty_state'] }}
|
||||
</div>
|
||||
@else
|
||||
<ul class="mt-4 grid gap-3">
|
||||
@foreach ($section['entries'] as $entry)
|
||||
<li class="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-1.5">
|
||||
@if (filled($entry['tenant_label'] ?? null))
|
||||
<div class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['tenant_label'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if (filled($entry['destination_url'] ?? null))
|
||||
<a
|
||||
href="{{ $entry['destination_url'] }}"
|
||||
class="text-sm font-semibold text-gray-950 hover:text-primary-600 dark:text-white dark:hover:text-primary-300"
|
||||
>
|
||||
{{ $entry['headline'] }}
|
||||
</a>
|
||||
@else
|
||||
<span class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $entry['headline'] }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $entry['status_label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (filled($entry['subline'] ?? null))
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $entry['subline'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($entry['destination_url'] ?? null))
|
||||
<x-filament::button tag="a" color="gray" size="sm" href="{{ $entry['destination_url'] }}">
|
||||
Open source
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details
|
||||
class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
data-testid="governance-inbox-diagnostics"
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ $diagnostics['label'] }} · {{ $diagnostics['state'] }}
|
||||
</summary>
|
||||
|
||||
<p class="mt-4 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ $diagnostics['body'] }}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const scrollToGovernanceInboxTarget = () => {
|
||||
const hash = window.location.hash;
|
||||
|
||||
if (hash === '#source-detail') {
|
||||
const sourceDetail = document.getElementById('source-detail');
|
||||
|
||||
if (! sourceDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
sourceDetail.open = true;
|
||||
window.setTimeout(() => sourceDetail.scrollIntoView({ block: 'start' }), 50);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! hash.startsWith('#lane-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.getElementById(hash.slice(1));
|
||||
|
||||
if (! target) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => target.scrollIntoView({ block: 'start' }), 50);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', scrollToGovernanceInboxTarget, { once: true });
|
||||
document.addEventListener('livewire:navigated', scrollToGovernanceInboxTarget);
|
||||
window.addEventListener('hashchange', scrollToGovernanceInboxTarget);
|
||||
scrollToGovernanceInboxTarget();
|
||||
})();
|
||||
</script>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
@if ($selectedException)
|
||||
<x-filament::section heading="Focused review lane">
|
||||
<x-slot name="description">
|
||||
Selection-bound decisions now define the active work lane. Scope, filters, and drilldowns stay visible without competing with the current review step.
|
||||
The selected exception defines the focused review context. Approval decisions appear only while the request is pending.
|
||||
</x-slot>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,26rem)]">
|
||||
@ -39,24 +39,28 @@
|
||||
<div class="grid gap-4">
|
||||
<div class="rounded-2xl border border-primary-200 bg-primary-50/80 p-4 shadow-sm dark:border-primary-500/30 dark:bg-primary-500/10">
|
||||
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">
|
||||
Decision lane
|
||||
@if ($selectedException->isPending())
|
||||
Decision lane
|
||||
@else
|
||||
Governance context
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-primary-800 dark:text-primary-200">
|
||||
@if ($selectedException->isPending())
|
||||
Approve exception and Reject exception are the only promoted next steps while this request remains pending.
|
||||
@else
|
||||
This exception is no longer decision-ready. Use the selected context group to close details or drill into related records.
|
||||
This accepted-risk record is already active, so approval decisions are not shown here. Use Exit focused review or open the exception/finding detail for follow-up.
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Related drilldown
|
||||
Related records
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Open environment detail and Open finding stay available for context, but they no longer share the same semantic lane as the review decision.
|
||||
Open exception detail and Open finding stay available for context, but they remain separate from approval or rejection decisions.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -183,7 +183,7 @@ function spec194RelativeRedirect(string $redirect): string
|
||||
->waitForText('Focused review lane')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Selection-bound decisions now define the active work lane.')
|
||||
->assertSee('The selected exception defines the focused review context.')
|
||||
->assertSee('Approve exception')
|
||||
->assertSee('Reject exception');
|
||||
|
||||
|
||||
@ -17,32 +17,25 @@
|
||||
visit(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Governance Inbox')
|
||||
->assertSee('Prioritized governance decisions, owners, evidence, and follow-up actions across entitled environments.')
|
||||
->assertSee('Daily operator queue for governance follow-up, accepted risk, evidence gaps, and review handoff.')
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('What decision clears the highest-priority item?')
|
||||
->assertSee('Decision workbench')
|
||||
->assertSee('Decision summary')
|
||||
->assertSee('Finding #2')
|
||||
->assertSee('Open governance work')
|
||||
->assertSee('Primary inbox lanes')
|
||||
->assertSee('Needs triage')
|
||||
->assertSee('Requires decision')
|
||||
->assertSee('Evidence required')
|
||||
->assertSee('Risk / exception review')
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Reason')
|
||||
->assertSee('The finding reopened after a previous resolution path.')
|
||||
->assertSee('Impact')
|
||||
->assertSee('Medium drift')
|
||||
->assertSee('Owner')
|
||||
->assertSee('Owner missing')
|
||||
->assertSee('Due')
|
||||
->assertSee('In 14 days')
|
||||
->assertSee('Evidence missing')
|
||||
->assertSee('Evidence path')
|
||||
->assertSee('Source record only')
|
||||
->assertSee('Accepted risk')
|
||||
->assertSee('Primary next action')
|
||||
->assertSee('Triage finding')
|
||||
->assertSee('No accepted risk')
|
||||
->assertSee('Queue context')
|
||||
->assertSee('Environment')
|
||||
->assertSee('Next recommended action')
|
||||
->assertSee('More context')
|
||||
->assertSee('Source detail')
|
||||
->assertSee($environmentA->name)
|
||||
->assertSee($environmentB->name)
|
||||
->assertDontSee('No governance decisions need attention')
|
||||
->assertDontSee('No governance items need attention.')
|
||||
->assertDontSee('tenant filter')
|
||||
->assertDontSee('current tenant')
|
||||
->assertDontSee('entitled tenant')
|
||||
@ -53,25 +46,16 @@
|
||||
->assertDontSee('debug metadata should stay hidden')
|
||||
->assertScript('document.querySelector("[data-testid=\"governance-inbox-diagnostics\"]")?.open === false', true)
|
||||
->assertScript('(() => {
|
||||
const grid = document.querySelector("[data-testid=\"governance-inbox-decision-workbench\"]");
|
||||
const workbench = document.querySelector("[data-testid=\"governance-inbox-priority-card\"]");
|
||||
const detail = document.querySelector("[data-testid=\"governance-inbox-decision-detail\"]");
|
||||
const summary = document.querySelector("[data-testid=\"governance-inbox-operator-summary\"]");
|
||||
const lanes = document.querySelector("[data-testid=\"governance-inbox-lanes\"]");
|
||||
const sourceDetail = document.querySelector("[data-testid=\"governance-inbox-source-detail\"]");
|
||||
|
||||
if (! grid || ! workbench || ! detail) {
|
||||
if (! summary || ! lanes || ! sourceDetail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const children = Array.from(grid.children);
|
||||
const workbenchBox = workbench.getBoundingClientRect();
|
||||
const detailBox = detail.getBoundingClientRect();
|
||||
|
||||
return window.innerWidth >= 1024
|
||||
&& grid.classList.contains("lg:grid-cols-[minmax(0,1fr)_22rem]")
|
||||
&& detail.tagName === "ASIDE"
|
||||
&& children.indexOf(workbench) !== -1
|
||||
&& children.indexOf(detail) > children.indexOf(workbench)
|
||||
&& detailBox.left > workbenchBox.right
|
||||
&& Math.abs(detailBox.top - workbenchBox.top) <= 8;
|
||||
return summary.getBoundingClientRect().top < lanes.getBoundingClientRect().top
|
||||
&& lanes.getBoundingClientRect().top < sourceDetail.getBoundingClientRect().top;
|
||||
})()', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
@ -91,7 +75,7 @@
|
||||
]))
|
||||
->waitForText('Environment filter:')
|
||||
->assertSee('Environment filter: '.$environmentA->name)
|
||||
->assertSee('What decision clears the highest-priority item?')
|
||||
->assertSee('Open governance work')
|
||||
->assertSee($environmentA->name)
|
||||
->assertDontSee($environmentB->name)
|
||||
->assertScript('document.querySelector("[data-testid=\"governance-inbox-diagnostics\"]")?.open === false', true)
|
||||
@ -129,17 +113,20 @@
|
||||
spec327CopyBrowserScreenshot('governance-inbox--after-reload');
|
||||
});
|
||||
|
||||
it('Spec327 smokes governance inbox diagnostics disclosure and secondary queue', function (): void {
|
||||
it('Spec327 smokes governance inbox secondary disclosures', function (): void {
|
||||
[$user, $environmentA] = spec327GovernanceInboxFixture();
|
||||
spec327AuthenticateGovernanceInboxBrowser($this, $user, $environmentA);
|
||||
|
||||
visit(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->waitForText('Queue context')
|
||||
->assertSee('Assigned findings')
|
||||
->waitForText('Source detail')
|
||||
->assertSee('Source detail')
|
||||
->assertScript('document.querySelector("[data-testid=\"governance-inbox-diagnostics\"]")?.open === false', true)
|
||||
->click('[data-testid="governance-inbox-diagnostics"] summary')
|
||||
->assertScript('document.querySelector("[data-testid=\"governance-inbox-diagnostics\"]")?.open === true', true)
|
||||
->assertSee('Source diagnostics and raw support details stay on authorized source surfaces')
|
||||
->assertSee('Raw diagnostics, payloads, and support detail stay on authorized source surfaces')
|
||||
->click('[data-testid="governance-inbox-source-detail"] summary')
|
||||
->assertScript('document.querySelector("[data-testid=\"governance-inbox-source-detail\"]")?.open === true', true)
|
||||
->assertSee('Source-family context')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('internal exception should stay hidden')
|
||||
->assertNoJavaScriptErrors()
|
||||
|
||||
@ -0,0 +1,461 @@
|
||||
<?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\FindingExceptionDecision;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentTriageReview;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewFingerprint;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
it('Spec346 smokes the governance inbox summary-first hierarchy and lane scanability', function (): void {
|
||||
[$user, $environmentA, $environmentB] = spec346GovernanceInboxBrowserFixture();
|
||||
spec346AuthenticateGovernanceInboxBrowser($this, $user, $environmentA);
|
||||
|
||||
$page = visit(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Governance Inbox')
|
||||
->assertSee('Daily operator queue for governance follow-up, accepted risk, evidence gaps, and review handoff.')
|
||||
->assertSee('Open governance work')
|
||||
->assertSee('Next recommended action')
|
||||
->assertSee('Triage Finding #')
|
||||
->assertSee('Triage finding')
|
||||
->assertSee('View lane')
|
||||
->assertSee('Primary inbox lanes')
|
||||
->assertSee('Needs triage')
|
||||
->assertSee('Requires decision')
|
||||
->assertSee('Risk / exception review')
|
||||
->assertSee('Evidence required')
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Recently resolved')
|
||||
->assertSee('More context')
|
||||
->assertSee('Source detail')
|
||||
->assertDontSee('Primary next action')
|
||||
->assertDontSee('Decision workbench')
|
||||
->assertDontSee('Queue context')
|
||||
->assertScript('document.querySelector("[data-testid=\"governance-inbox-diagnostics\"]")?.open === false', true)
|
||||
->assertScript('document.querySelectorAll("[data-testid^=\"governance-inbox-item-\"] details[open]").length === 0', true)
|
||||
->assertScript('(() => {
|
||||
const summary = document.querySelector("[data-testid=\"governance-inbox-operator-summary\"]");
|
||||
const nextAction = document.querySelector("[data-testid=\"governance-inbox-next-action\"]");
|
||||
const lanes = document.querySelector("[data-testid=\"governance-inbox-lanes\"]");
|
||||
const sourceDetail = document.querySelector("[data-testid=\"governance-inbox-source-detail\"]");
|
||||
|
||||
if (! summary || ! nextAction || ! lanes || ! sourceDetail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return summary.getBoundingClientRect().top < nextAction.getBoundingClientRect().top
|
||||
&& nextAction.getBoundingClientRect().top < lanes.getBoundingClientRect().top
|
||||
&& lanes.getBoundingClientRect().top < sourceDetail.getBoundingClientRect().top;
|
||||
})()', true)
|
||||
->assertScript('(() => {
|
||||
const nextAction = document.querySelector("[data-testid=\"governance-inbox-next-action\"]");
|
||||
const button = nextAction?.querySelector("a[href]");
|
||||
|
||||
if (! nextAction || ! button) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = nextAction.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
|
||||
return rect.top >= 0
|
||||
&& rect.top < window.innerHeight
|
||||
&& buttonRect.top < window.innerHeight
|
||||
&& nextAction.textContent.includes("Next recommended action")
|
||||
&& nextAction.textContent.includes("Triage Finding #");
|
||||
})()', true)
|
||||
->assertScript('document.documentElement.scrollWidth <= window.innerWidth', true)
|
||||
->assertSee($environmentA->name)
|
||||
->assertSee($environmentB->name)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page->screenshot(true, spec346GovernanceInboxScreenshot('governance-inbox--summary-first'));
|
||||
|
||||
spec346CopyBrowserScreenshot('governance-inbox--summary-first');
|
||||
|
||||
$page->resize(390, 844);
|
||||
$page->script('window.scrollTo(0, 0);');
|
||||
$page
|
||||
->assertScript('document.documentElement.scrollWidth <= window.innerWidth', true)
|
||||
->assertScript('(() => {
|
||||
const nextAction = document.querySelector("[data-testid=\"governance-inbox-next-action\"]");
|
||||
const firstItem = document.querySelector("[data-testid^=\"governance-inbox-item-\"]");
|
||||
|
||||
if (! nextAction || ! firstItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextRect = nextAction.getBoundingClientRect();
|
||||
|
||||
return nextRect.top >= 0
|
||||
&& nextRect.top < window.innerHeight
|
||||
&& nextAction.textContent.includes("Triage Finding #")
|
||||
&& firstItem.textContent.includes("Reason")
|
||||
&& firstItem.textContent.includes("Impact")
|
||||
&& firstItem.textContent.includes("More context");
|
||||
})()', true)
|
||||
->screenshot(true, spec346GovernanceInboxScreenshot('governance-inbox--mobile-density'));
|
||||
|
||||
spec346CopyBrowserScreenshot('governance-inbox--mobile-density');
|
||||
|
||||
$hashPage = visit(GovernanceInbox::getUrl(panel: 'admin').'#lane-needs_triage')
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Needs triage');
|
||||
|
||||
$hashPage->script('async () => { await new Promise((resolve) => setTimeout(resolve, 250)); return true; }');
|
||||
$hashPage
|
||||
->assertScript('(() => {
|
||||
const lane = document.getElementById("lane-needs_triage");
|
||||
|
||||
if (! lane) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = lane.getBoundingClientRect();
|
||||
|
||||
return window.location.hash === "#lane-needs_triage"
|
||||
&& window.scrollY > 0
|
||||
&& rect.top >= 0
|
||||
&& rect.top < Math.min(window.innerHeight * 0.35, 260);
|
||||
})()', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec346GovernanceInboxScreenshot('governance-inbox--hash-lane-needs-triage'));
|
||||
|
||||
spec346CopyBrowserScreenshot('governance-inbox--hash-lane-needs-triage');
|
||||
}
|
||||
);
|
||||
|
||||
it('Spec346 smokes the filtered governance inbox URL contract', function (): void {
|
||||
[$user, $environmentA, $environmentB] = spec346GovernanceInboxBrowserFixture();
|
||||
spec346AuthenticateGovernanceInboxBrowser($this, $user, $environmentA);
|
||||
|
||||
$page = visit(GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||
'environment_id' => (int) $environmentA->getKey(),
|
||||
'family' => 'stale_operations',
|
||||
]))
|
||||
->waitForText('Environment filter:')
|
||||
->assertSee('Environment filter: '.$environmentA->name)
|
||||
->assertSee('Next recommended action')
|
||||
->assertSee('Open terminal operation proof')
|
||||
->assertSee('Blocked')
|
||||
->assertDontSee($environmentB->name)
|
||||
->assertScript('window.location.search.includes("environment_id='.(int) $environmentA->getKey().'")', true)
|
||||
->assertScript('window.location.search.includes("family=stale_operations")', true)
|
||||
->assertScript('! window.location.search.includes("tenant=")', true)
|
||||
->assertScript('! window.location.search.includes("tenant_id=")', true)
|
||||
->assertScript('! window.location.search.includes("managed_environment_id=")', true)
|
||||
->assertScript('! window.location.search.includes("tenant_scope=")', true)
|
||||
->assertScript('! window.location.search.includes("tableFilters")', true)
|
||||
->assertScript('(() => {
|
||||
const sourceDetail = document.querySelector("[data-testid=\"governance-inbox-source-detail\"]");
|
||||
const activeFamily = sourceDetail?.querySelector("a[aria-current=\"page\"]");
|
||||
|
||||
return sourceDetail?.open === true
|
||||
&& activeFamily?.textContent.includes("Operations follow-up")
|
||||
&& activeFamily?.href.includes("family=stale_operations")
|
||||
&& activeFamily?.hash === "#source-detail";
|
||||
})()', true)
|
||||
->assertScript('(() => {
|
||||
const active = document.querySelector("[data-testid=\"governance-inbox-summary-active-counts\"]");
|
||||
const clear = document.querySelector("[data-testid=\"governance-inbox-summary-clear-counts\"]");
|
||||
|
||||
if (! active || ! clear) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return active.textContent.includes("Blocked")
|
||||
&& clear.textContent.includes("Needs triage")
|
||||
&& clear.textContent.includes("Requires decision")
|
||||
&& clear.textContent.includes("Risk / exception review")
|
||||
&& clear.textContent.includes("Evidence required")
|
||||
&& (clear.textContent.match(/Clear/g) || []).length >= 4
|
||||
&& document.querySelector("[data-testid=\"governance-inbox-lane-needs_triage\"]") === null
|
||||
&& document.querySelector("[data-testid=\"governance-inbox-lane-blocked\"]") !== null;
|
||||
})()', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
$page->screenshot(true, spec346GovernanceInboxScreenshot('governance-inbox--filtered'));
|
||||
|
||||
spec346CopyBrowserScreenshot('governance-inbox--filtered');
|
||||
}
|
||||
);
|
||||
|
||||
it('Spec346 smokes a representative primary action hop and returns to the same inbox scope', function (): void {
|
||||
[$user, $environmentA] = spec346GovernanceInboxBrowserFixture();
|
||||
spec346AuthenticateGovernanceInboxBrowser($this, $user, $environmentA);
|
||||
|
||||
$expectedQueuePath = json_encode((string) parse_url(FindingExceptionsQueue::getUrl(panel: 'admin'), PHP_URL_PATH), JSON_THROW_ON_ERROR);
|
||||
|
||||
$page = visit(GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||
'environment_id' => (int) $environmentA->getKey(),
|
||||
]))
|
||||
->waitForText('Environment filter:')
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Review accepted risk')
|
||||
->assertScript('(() => {
|
||||
const lane = document.querySelector("[data-testid=\"governance-inbox-lane-risk_exception_review\"]");
|
||||
const link = [...(lane?.querySelectorAll("a[href]") || [])]
|
||||
.find((element) => element.textContent.includes("Review accepted risk"));
|
||||
|
||||
return Boolean(link);
|
||||
})()', true);
|
||||
|
||||
$page->script('(() => {
|
||||
const lane = document.querySelector("[data-testid=\"governance-inbox-lane-risk_exception_review\"]");
|
||||
const link = [...(lane?.querySelectorAll("a[href]") || [])]
|
||||
.find((element) => element.textContent.includes("Review accepted risk"));
|
||||
|
||||
link?.click();
|
||||
})()');
|
||||
|
||||
$page
|
||||
->waitForText('Finding Exceptions Queue')
|
||||
->assertScript("window.location.pathname === {$expectedQueuePath}", true)
|
||||
->assertScript('window.location.search.includes("environment_id='.(int) $environmentA->getKey().'")', true)
|
||||
->assertScript('window.location.search.includes("exception=")', true)
|
||||
->assertScript('! window.location.search.includes("tenant=")', true)
|
||||
->assertScript('! window.location.search.includes("tenant_id=")', true)
|
||||
->assertScript('! window.location.search.includes("managed_environment_id=")', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
$page->script('window.history.back();');
|
||||
|
||||
$page
|
||||
->waitForText('Governance Inbox')
|
||||
->waitForText('Environment filter: '.$environmentA->name)
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Environment filter: '.$environmentA->name)
|
||||
->assertScript('window.location.search.includes("environment_id='.(int) $environmentA->getKey().'")', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec346GovernanceInboxScreenshot('governance-inbox--return-scope'));
|
||||
|
||||
spec346CopyBrowserScreenshot('governance-inbox--return-scope');
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment}
|
||||
*/
|
||||
function spec346GovernanceInboxBrowserFixture(): array
|
||||
{
|
||||
$environmentA = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec346 Browser Environment A',
|
||||
'external_id' => 'spec346-browser-environment-a',
|
||||
]);
|
||||
[$user, $environmentA] = createUserWithTenant(
|
||||
tenant: $environmentA,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
|
||||
$environmentB = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'name' => 'Spec346 Browser Environment B',
|
||||
'external_id' => 'spec346-browser-environment-b',
|
||||
]);
|
||||
createUserWithTenant(
|
||||
tenant: $environmentB,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentA)
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentA)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->ownedBy((int) $user->getKey())
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentB)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->ownedBy((int) $user->getKey())
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentB->workspace_id,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'evidence_jsonb' => [
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$exceptionFinding = Finding::factory()
|
||||
->for($environmentA)
|
||||
->riskAccepted()
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
]);
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'managed_environment_id' => (int) $environmentA->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' => 'Spec346 browser accepted-risk support review',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDays(3),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
OperationRun::factory()
|
||||
->forTenant($environmentA)
|
||||
->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$backupHealthResolver = app(TenantBackupHealthResolver::class);
|
||||
$fingerprints = app(ManagedEnvironmentTriageReviewFingerprint::class);
|
||||
$fingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($environmentA));
|
||||
|
||||
ManagedEnvironmentTriageReview::factory()
|
||||
->for($environmentA)
|
||||
->followUpNeeded()
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'reviewed_by_user_id' => (int) $user->getKey(),
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'review_fingerprint' => $fingerprint['fingerprint'],
|
||||
'review_snapshot' => $fingerprint['snapshot'],
|
||||
]);
|
||||
|
||||
spec346SeedBrowserRecentlyClosedDecision($environmentA, $user);
|
||||
|
||||
return [$user, $environmentA, $environmentB];
|
||||
}
|
||||
|
||||
function spec346AuthenticateGovernanceInboxBrowser(
|
||||
mixed $test,
|
||||
User $user,
|
||||
ManagedEnvironment $rememberedEnvironment,
|
||||
): void {
|
||||
$workspaceId = (int) $rememberedEnvironment->workspace_id;
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $workspaceId => (int) $rememberedEnvironment->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$test->actingAs($user)->withSession($session);
|
||||
|
||||
foreach ($session as $key => $value) {
|
||||
session()->put($key, $value);
|
||||
}
|
||||
|
||||
setAdminPanelContext($rememberedEnvironment);
|
||||
}
|
||||
|
||||
function spec346GovernanceInboxScreenshot(string $name): string
|
||||
{
|
||||
return 'spec346-'.$name;
|
||||
}
|
||||
|
||||
function spec346CopyBrowserScreenshot(string $name, ?string $targetFilename = null): void
|
||||
{
|
||||
$filename = spec346GovernanceInboxScreenshot($name).'.png';
|
||||
$source = base_path('tests/Browser/Screenshots/'.$filename);
|
||||
$targetDirectory = repo_path('specs/346-governance-inbox-final-operator-workflow/artifacts/screenshots');
|
||||
$targetFilename ??= $filename;
|
||||
|
||||
if (! is_file($source)) {
|
||||
$source = \Pest\Browser\Support\Screenshot::path($filename);
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt < 10 && ! is_file($source); $attempt++) {
|
||||
usleep(100_000);
|
||||
clearstatcache(true, $source);
|
||||
}
|
||||
|
||||
if (! is_dir($targetDirectory)) {
|
||||
@mkdir($targetDirectory, 0755, true);
|
||||
}
|
||||
|
||||
if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_file($source)) {
|
||||
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$targetFilename);
|
||||
}
|
||||
}
|
||||
|
||||
function spec346SeedBrowserRecentlyClosedDecision(ManagedEnvironment $environment, User $user): void
|
||||
{
|
||||
$finding = Finding::factory()
|
||||
->for($environment)
|
||||
->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_REJECTED,
|
||||
'current_validity_state' => FindingException::VALIDITY_REJECTED,
|
||||
'request_reason' => 'Browser recently closed decision',
|
||||
'requested_at' => now()->subDays(5),
|
||||
'review_due_at' => now()->subDays(2),
|
||||
'rejected_at' => now()->subDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = FindingExceptionDecision::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'finding_exception_id' => (int) $exception->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
|
||||
'reason' => 'Browser recently rejected closure reason',
|
||||
'decided_at' => now()->subDay(),
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$exception->forceFill([
|
||||
'current_decision_id' => (int) $decision->getKey(),
|
||||
])->save();
|
||||
}
|
||||
@ -57,6 +57,80 @@
|
||||
->toContain('data-shared-normalized-diff-state="unavailable"');
|
||||
});
|
||||
|
||||
it('does not render an empty diff section when drift evidence has no supported diff surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'demo-finding-without-diff-surface',
|
||||
'evidence_fidelity' => 'meta',
|
||||
'evidence_jsonb' => [
|
||||
'demo_fixture' => 'spec342-findings',
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'admin', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Evidence (Sanitized)');
|
||||
|
||||
expect($response->getContent())
|
||||
->not->toContain('id="infolist.diff::section"')
|
||||
->not->toContain('data-shared-detail-family="normalized-diff"');
|
||||
});
|
||||
|
||||
it('shows an explicit scope tag diff unavailable message when policy version references are missing', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-scope-tag-drift',
|
||||
'evidence_fidelity' => 'meta',
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'different_version',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'policy scope tag drift',
|
||||
'summary' => [
|
||||
'kind' => 'policy_scope_tags',
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => null,
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => null,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'admin', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Diff unavailable')
|
||||
->assertSee('Scope tags diff');
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('id="infolist.diff::section"');
|
||||
});
|
||||
|
||||
it('renders a diff against an empty baseline for unexpected_policy findings with a current policy version reference', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -97,3 +99,35 @@
|
||||
$finding->refresh();
|
||||
expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey());
|
||||
});
|
||||
|
||||
it('executes the triage action from an environment-bound admin finding detail without filament tenancy', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
|
||||
$referer = FindingResource::getUrl('view', ['record' => $finding], panel: 'admin', tenant: $tenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant($tenant, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$component = Livewire::withHeaders([
|
||||
'referer' => $referer,
|
||||
'x-livewire' => 'true',
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(ViewFinding::class, ['record' => $finding->getKey()]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$component
|
||||
->call('mountAction', 'triage', [], ['recordKey' => (string) $finding->getKey()])
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified('Finding triaged');
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_TRIAGED)
|
||||
->and($finding->triaged_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -62,25 +62,21 @@
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Governance Inbox')
|
||||
->assertSee('Prioritized governance decisions, owners, evidence, and follow-up actions across entitled environments.')
|
||||
->assertSee('What decision clears the highest-priority item?')
|
||||
->assertSee('Decision workbench')
|
||||
->assertSee('Daily operator queue for governance follow-up, accepted risk, evidence gaps, and review handoff.')
|
||||
->assertSee('Open governance work')
|
||||
->assertSee('Primary inbox lanes')
|
||||
->assertSee('Reason')
|
||||
->assertSee('Impact')
|
||||
->assertSee('Owner')
|
||||
->assertSee('Due')
|
||||
->assertSee('Evidence')
|
||||
->assertSee('Accepted risk')
|
||||
->assertSee('Environment')
|
||||
->assertSee('Review finding')
|
||||
->assertSee('Evidence captured on finding')
|
||||
->assertSee('No accepted risk')
|
||||
->assertSee('Decision summary')
|
||||
->assertSee('More context')
|
||||
->assertSee('Owner / due')
|
||||
->assertSee('Evidence path')
|
||||
->assertSee('Primary next action')
|
||||
->assertSee('Queue context')
|
||||
->assertSee('Assigned findings')
|
||||
->assertDontSee('No governance decisions need attention')
|
||||
->assertSee('Next recommended action')
|
||||
->assertSee('Linked records')
|
||||
->assertSee('Source detail')
|
||||
->assertDontSee('No governance items need attention.')
|
||||
->assertDontSee('tenant filter')
|
||||
->assertDontSee('current tenant')
|
||||
->assertDontSee('entitled tenant')
|
||||
@ -102,18 +98,12 @@
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('What decision clears the highest-priority item?')
|
||||
->assertSee('No governance decisions need attention')
|
||||
->assertSee('The current workspace scope has no repo-backed governance decisions requiring action.')
|
||||
->assertSee('Decision summary')
|
||||
->assertSee('Owner / due')
|
||||
->assertSee('Evidence path')
|
||||
->assertSee('Accepted risk')
|
||||
->assertSee('Primary next action')
|
||||
->assertSee('Diagnostics')
|
||||
->assertSee('Open governance work')
|
||||
->assertSee('No governance items need attention.')
|
||||
->assertSee('Findings, decisions, accepted-risk reviews, evidence gaps, and review follow-ups will appear here when they need operator attention.')
|
||||
->assertSee('Diagnostics / source detail')
|
||||
->assertSee('Collapsed')
|
||||
->assertDontSee('Visible decisions')
|
||||
->assertDontSee('Priority family')
|
||||
->assertDontSee('Decision workbench')
|
||||
->assertDontSee('raw payload')
|
||||
->assertDontSee('stack trace')
|
||||
->assertDontSee('debug metadata')
|
||||
@ -182,12 +172,12 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin').'?family=finding_exceptions')
|
||||
->assertOk()
|
||||
->assertSee('Review accepted-risk decision')
|
||||
->assertSee('Risk / exception review')
|
||||
->assertSee('Exception needs governance evidence')
|
||||
->assertSee('Pending exception')
|
||||
->assertSee('Evidence missing')
|
||||
->assertSee('Review accepted risk')
|
||||
->assertSee('Diagnostics')
|
||||
->assertSee('Diagnostics / source detail')
|
||||
->assertSee('Collapsed')
|
||||
->assertDontSee('raw payload')
|
||||
->assertDontSee('stack trace')
|
||||
@ -196,6 +186,38 @@
|
||||
->assertDontSee('debug metadata');
|
||||
});
|
||||
|
||||
it('keeps source-family filter navigation anchored and expanded on filtered inbox URLs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Finding::factory()
|
||||
->for($tenant)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'subject_external_id' => 'anchored-source-family-finding',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin').'?family=assigned_findings')
|
||||
->assertOk()
|
||||
->assertSee('Assigned findings')
|
||||
->assertSee('Open my findings');
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
expect($content)
|
||||
->toContain('id="source-detail"')
|
||||
->toContain('family=assigned_findings#source-detail')
|
||||
->toContain('family=finding_exceptions#source-detail');
|
||||
|
||||
expect((bool) preg_match('/<details[^>]*id="source-detail"[^>]*open|<details[^>]*open[^>]*id="source-detail"/s', $content))
|
||||
->toBeTrue();
|
||||
|
||||
expect((bool) preg_match('/<a[^>]*family=assigned_findings#source-detail[^>]*aria-current="page"|<a[^>]*aria-current="page"[^>]*family=assigned_findings#source-detail/s', $content))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
it('renders visible governance attention sections on the governance inbox page', function (): void {
|
||||
$alphaTenant = ManagedEnvironment::factory()->create([
|
||||
'status' => 'active',
|
||||
|
||||
@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentTriageReview;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewFingerprint;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('documents the Spec 346 governance inbox repo truth and lane contract artifacts', function (): void {
|
||||
$repoTruthMap = repo_path('specs/346-governance-inbox-final-operator-workflow/repo-truth-map.md');
|
||||
$laneContract = repo_path('specs/346-governance-inbox-final-operator-workflow/contracts/lane-classification.md');
|
||||
|
||||
expect($repoTruthMap)->toBeFile()
|
||||
->and($laneContract)->toBeFile();
|
||||
|
||||
$repoTruth = (string) file_get_contents($repoTruthMap);
|
||||
$laneClassification = (string) file_get_contents($laneContract);
|
||||
|
||||
expect($repoTruth)
|
||||
->toContain('environment_id')
|
||||
->toContain('GovernanceDecisionRegisterBuilder')
|
||||
->toContain('Review-ready')
|
||||
->toContain('Not added.')
|
||||
->and($laneClassification)
|
||||
->toContain('Needs triage')
|
||||
->toContain('Requires decision')
|
||||
->toContain('Risk / exception review')
|
||||
->toContain('Evidence required')
|
||||
->toContain('Blocked');
|
||||
});
|
||||
|
||||
it('renders the Spec 346 operator summary, active lanes, and linked actions before source detail', function (): void {
|
||||
[$user, $environmentA, $environmentB] = spec346GovernanceInboxFixture();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Governance Inbox')
|
||||
->assertSee('Daily operator queue for governance follow-up, accepted risk, evidence gaps, and review handoff.')
|
||||
->assertSee('Open governance work')
|
||||
->assertSee('Primary inbox lanes')
|
||||
->assertSee('Needs triage')
|
||||
->assertSee('Requires decision')
|
||||
->assertSee('Risk / exception review')
|
||||
->assertSee('Evidence required')
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Next recommended action')
|
||||
->assertSee('Triage Finding #')
|
||||
->assertSee('Linked records')
|
||||
->assertSee('Secondary actions')
|
||||
->assertSee('More context')
|
||||
->assertSee('Open Customer Review Workspace')
|
||||
->assertSee('The finding is unassigned and still needs first triage.')
|
||||
->assertSee('The finding remains assigned and needs follow-up before it can be cleared.')
|
||||
->assertSee('Spec346 accepted-risk support review')
|
||||
->assertSee('The operation reached a terminal outcome that still needs monitoring follow-up.')
|
||||
->assertSee('The latest review state asks for follow-up before this governance concern is closed.')
|
||||
->assertSee('Recently resolved')
|
||||
->assertSee('Source detail')
|
||||
->assertSee('Diagnostics / source detail')
|
||||
->assertDontSee('Decision workbench')
|
||||
->assertDontSee('Queue context')
|
||||
->assertDontSee('raw payload should stay hidden');
|
||||
|
||||
$content = (string) $response->getContent();
|
||||
|
||||
expect(strpos($content, 'Open governance work'))->toBeLessThan(strpos($content, 'Primary inbox lanes'))
|
||||
->and(strpos($content, 'Primary inbox lanes'))->toBeLessThan(strpos($content, 'Source detail'))
|
||||
->and(strpos($content, 'Primary inbox lanes'))->toBeLessThan(strpos($content, 'Recently resolved'));
|
||||
|
||||
expect($content)->not->toContain('No governance items need attention.');
|
||||
|
||||
expect($environmentB->workspace_id)->toBe($environmentA->workspace_id);
|
||||
});
|
||||
|
||||
it('normalizes rendered lane, action, badge, and link contracts before Blade consumes them', function (): void {
|
||||
[$user, $environmentA] = spec346GovernanceInboxFixture();
|
||||
|
||||
$this->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
|
||||
|
||||
$instance = Livewire::test(GovernanceInbox::class)
|
||||
->instance();
|
||||
|
||||
$summary = $instance->operatorSummary();
|
||||
$lanes = $instance->laneGroups();
|
||||
|
||||
expect($summary['next_recommended_item'])
|
||||
->toBeArray()
|
||||
->and($summary['next_recommended_item'])
|
||||
->toHaveKeys([
|
||||
'headline',
|
||||
'lane_label',
|
||||
'status_label',
|
||||
'environment_label',
|
||||
'reason_label',
|
||||
'impact_label',
|
||||
'primary_action',
|
||||
])
|
||||
->and($summary['next_recommended_item']['primary_action'])
|
||||
->toHaveKeys(['label', 'url']);
|
||||
|
||||
foreach ($summary['counts'] as $count) {
|
||||
expect($count)->toHaveKeys(['key', 'label', 'count', 'description', 'state', 'chip_label']);
|
||||
}
|
||||
|
||||
foreach ($lanes as $lane) {
|
||||
expect($lane)->toHaveKeys(['key', 'label', 'anchor_id', 'count', 'items']);
|
||||
|
||||
foreach ($lane['items'] as $item) {
|
||||
expect($item)->toHaveKeys([
|
||||
'lane_label',
|
||||
'status_label',
|
||||
'title',
|
||||
'environment_label',
|
||||
'reason_heading',
|
||||
'reason_label',
|
||||
'impact_label',
|
||||
'source_label',
|
||||
'owner_label',
|
||||
'due_label',
|
||||
'evidence_label',
|
||||
'exception_label',
|
||||
'primary_action',
|
||||
'secondary_actions',
|
||||
'linked_records',
|
||||
]);
|
||||
|
||||
expect($item['primary_action'])->toHaveKeys(['label', 'url']);
|
||||
|
||||
foreach ([...$item['secondary_actions'], ...$item['linked_records']] as $link) {
|
||||
expect($link)->toHaveKeys(['label', 'url']);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the Spec 346 productized empty state without looking like missing data', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec346 Empty Environment',
|
||||
]);
|
||||
[$user, $environment] = createUserWithTenant($environment, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Open governance work')
|
||||
->assertSee('No governance items need attention.')
|
||||
->assertSee('Findings, decisions, accepted-risk reviews, evidence gaps, and review follow-ups will appear here when they need operator attention.')
|
||||
->assertSee('Diagnostics / source detail')
|
||||
->assertDontSee('Decision workbench')
|
||||
->assertDontSee('No records found')
|
||||
->assertDontSee('Missing data');
|
||||
});
|
||||
|
||||
it('renders blocked governance follow-up with explicit blocker reason and next action', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec346 Blocked Environment',
|
||||
]);
|
||||
[$user, $environment] = createUserWithTenant($environment, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
OperationRun::factory()
|
||||
->forTenant($environment)
|
||||
->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Blocker')
|
||||
->assertSee('The operation reached a terminal outcome that still needs monitoring follow-up.')
|
||||
->assertSee('Open operation proof')
|
||||
->assertDontSee('No governance items need attention.');
|
||||
});
|
||||
|
||||
it('keeps recently resolved items secondary to active operator work', function (): void {
|
||||
[$user, $environment] = spec346RecentlyResolvedOnlyFixture();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Recently resolved')
|
||||
->assertSee('Open recently closed decisions')
|
||||
->assertSee('Recently rejected closure reason');
|
||||
|
||||
$content = (string) $response->getContent();
|
||||
|
||||
expect(strpos($content, 'Primary inbox lanes'))->toBeLessThan(strpos($content, 'Recently resolved'));
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment}
|
||||
*/
|
||||
function spec346GovernanceInboxFixture(): array
|
||||
{
|
||||
$environmentA = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec346 Environment Alpha',
|
||||
'external_id' => 'spec346-environment-alpha',
|
||||
]);
|
||||
[$user, $environmentA] = createUserWithTenant($environmentA, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$environmentB = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'name' => 'Spec346 Environment Beta',
|
||||
'external_id' => 'spec346-environment-beta',
|
||||
]);
|
||||
createUserWithTenant($environmentB, user: $user, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentA)
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentA)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->ownedBy((int) $user->getKey())
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentB)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->ownedBy((int) $user->getKey())
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentB->workspace_id,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'evidence_jsonb' => [
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'raw_payload' => 'raw payload should stay hidden',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$exceptionFinding = Finding::factory()
|
||||
->for($environmentA)
|
||||
->riskAccepted()
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
]);
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'managed_environment_id' => (int) $environmentA->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' => 'Spec346 accepted-risk support review',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDays(3),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
OperationRun::factory()
|
||||
->forTenant($environmentA)
|
||||
->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
spec346SeedReviewFollowUp($environmentA, $user);
|
||||
spec346SeedRecentlyClosedDecision($environmentA, $user, 'Recently rejected closure reason');
|
||||
|
||||
return [$user, $environmentA, $environmentB];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: ManagedEnvironment}
|
||||
*/
|
||||
function spec346RecentlyResolvedOnlyFixture(): array
|
||||
{
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec346 Resolved Environment',
|
||||
'external_id' => 'spec346-resolved-environment',
|
||||
]);
|
||||
[$user, $environment] = createUserWithTenant($environment, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
Finding::factory()
|
||||
->for($environment)
|
||||
->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
spec346SeedRecentlyClosedDecision($environment, $user, 'Recently rejected closure reason');
|
||||
|
||||
return [$user, $environment];
|
||||
}
|
||||
|
||||
function spec346SeedReviewFollowUp(ManagedEnvironment $environment, User $user): void
|
||||
{
|
||||
$backupHealthResolver = app(TenantBackupHealthResolver::class);
|
||||
$fingerprints = app(ManagedEnvironmentTriageReviewFingerprint::class);
|
||||
$fingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($environment));
|
||||
|
||||
ManagedEnvironmentTriageReview::factory()
|
||||
->for($environment)
|
||||
->followUpNeeded()
|
||||
->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'reviewed_by_user_id' => (int) $user->getKey(),
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'review_fingerprint' => $fingerprint['fingerprint'],
|
||||
'review_snapshot' => $fingerprint['snapshot'],
|
||||
]);
|
||||
}
|
||||
|
||||
function spec346SeedRecentlyClosedDecision(ManagedEnvironment $environment, User $user, string $reason): void
|
||||
{
|
||||
$finding = Finding::factory()
|
||||
->for($environment)
|
||||
->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_REJECTED,
|
||||
'current_validity_state' => FindingException::VALIDITY_REJECTED,
|
||||
'request_reason' => 'Closed exception for Spec346 register follow-up',
|
||||
'requested_at' => now()->subDays(5),
|
||||
'review_due_at' => now()->subDays(2),
|
||||
'rejected_at' => now()->subDays(1),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = FindingExceptionDecision::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'finding_exception_id' => (int) $exception->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
|
||||
'reason' => $reason,
|
||||
'decided_at' => now()->subDay(),
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$exception->forceFill([
|
||||
'current_decision_id' => (int) $decision->getKey(),
|
||||
])->save();
|
||||
}
|
||||
@ -72,7 +72,7 @@
|
||||
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
|
||||
->assertSee('Focused review lane')
|
||||
->assertSee('Decision lane')
|
||||
->assertSee('Related drilldown')
|
||||
->assertSee('Related records')
|
||||
->assertDontSee('Quiet monitoring mode')
|
||||
->assertActionVisible('clear_selected_exception')
|
||||
->assertActionVisible('approve_selected_exception')
|
||||
|
||||
@ -103,5 +103,9 @@
|
||||
->assertSee('Queue visibility test')
|
||||
->assertSee('Expiring')
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee('Focused review lane');
|
||||
->assertSee('Focused review lane')
|
||||
->assertSee('Governance context')
|
||||
->assertSee('This accepted-risk record is already active')
|
||||
->assertSee('Open exception detail')
|
||||
->assertDontSee('This exception is no longer decision-ready');
|
||||
});
|
||||
|
||||
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewFingerprint;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('renders the canonical environment filter and scope-correct workspace-hub links on the governance inbox', function (): void {
|
||||
[$user, $environmentA, $environmentB, $records] = spec346GovernanceInboxScopeFixture();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||
'environment_id' => (int) $environmentA->getKey(),
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Environment filter:')
|
||||
->assertSee($environmentA->name)
|
||||
->assertSee('Clear filter')
|
||||
->assertSee(GovernanceInbox::getUrl(panel: 'admin'), false)
|
||||
->assertSee((string) parse_url(FindingExceptionsQueue::getUrl(panel: 'admin'), PHP_URL_PATH), false)
|
||||
->assertSee('environment_id='.(int) $environmentA->getKey(), false)
|
||||
->assertSee('exception='.(int) $records['exception']->getKey(), false)
|
||||
->assertSee(CustomerReviewWorkspace::environmentFilterUrl($environmentA), false)
|
||||
->assertSee(DecisionRegister::getUrl(panel: 'admin', parameters: [
|
||||
'environment_id' => (int) $environmentA->getKey(),
|
||||
]), false)
|
||||
->assertSee(route('admin.evidence.overview', [
|
||||
'environment_id' => (int) $environmentA->getKey(),
|
||||
]), false)
|
||||
->assertDontSee('environment_id='.(int) $environmentB->getKey(), false)
|
||||
->assertDontSee('tenant=', false)
|
||||
->assertDontSee('tenant_id=', false)
|
||||
->assertDontSee('managed_environment_id=', false)
|
||||
->assertDontSee('tenant_scope=', false)
|
||||
->assertDontSee('tableFilters', false);
|
||||
});
|
||||
|
||||
it('keeps clean governance inbox entry unfiltered and ignores retired environment query aliases', function (): void {
|
||||
[$user, $environmentA, $environmentB] = spec346GovernanceInboxScopeFixture();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $environmentA->workspace_id => (int) $environmentA->getKey(),
|
||||
],
|
||||
])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name);
|
||||
|
||||
$legacyQueries = [
|
||||
['tenant' => (string) $environmentA->getKey()],
|
||||
['tenant_id' => (int) $environmentA->getKey()],
|
||||
['managed_environment_id' => (int) $environmentA->getKey()],
|
||||
['environment' => (string) $environmentA->getKey()],
|
||||
['tenant_scope' => 'environment'],
|
||||
['tableFilters' => ['managed_environment_id' => ['value' => (string) $environmentA->getKey()]]],
|
||||
];
|
||||
|
||||
foreach ($legacyQueries as $query) {
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: $query))
|
||||
->assertOk()
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment, 3: array{exception: FindingException}}
|
||||
*/
|
||||
function spec346GovernanceInboxScopeFixture(): array
|
||||
{
|
||||
$environmentA = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec346 Scope Environment Alpha',
|
||||
'external_id' => 'spec346-scope-environment-alpha',
|
||||
]);
|
||||
[$user, $environmentA] = createUserWithTenant($environmentA, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$environmentB = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'name' => 'Spec346 Scope Environment Beta',
|
||||
'external_id' => 'spec346-scope-environment-beta',
|
||||
]);
|
||||
createUserWithTenant($environmentB, user: $user, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentB)
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentB->workspace_id,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentA)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->ownedBy((int) $user->getKey())
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
$exceptionFinding = Finding::factory()
|
||||
->for($environmentA)
|
||||
->riskAccepted()
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'managed_environment_id' => (int) $environmentA->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' => 'Spec346 scope contract exception',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDays(2),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$backupHealthResolver = app(TenantBackupHealthResolver::class);
|
||||
$fingerprints = app(ManagedEnvironmentTriageReviewFingerprint::class);
|
||||
$fingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($environmentA));
|
||||
|
||||
ManagedEnvironmentTriageReview::factory()
|
||||
->for($environmentA)
|
||||
->followUpNeeded()
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'reviewed_by_user_id' => (int) $user->getKey(),
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'review_fingerprint' => $fingerprint['fingerprint'],
|
||||
'review_snapshot' => $fingerprint['snapshot'],
|
||||
]);
|
||||
|
||||
return [$user, $environmentA, $environmentB, [
|
||||
'exception' => $exception,
|
||||
]];
|
||||
}
|
||||
@ -9,40 +9,109 @@ # UI-004 Governance Inbox
|
||||
| Design depth | Strategic Surface |
|
||||
| Repo truth | repo-verified |
|
||||
| Screenshot | `../screenshots/desktop/ui-004-governance-inbox.png` |
|
||||
| Browser status | Reached through local workspace route. |
|
||||
| Browser status | Re-validated through Spec 346 browser smoke and spec-local screenshots. |
|
||||
|
||||
## First Five Seconds
|
||||
## Before / After Hierarchy
|
||||
|
||||
The page is positioned as a decision queue. It needs to make the human-in-the-loop moment unmistakable: what is pending, why it matters, who owns it, and what should be done next.
|
||||
Before Spec 346:
|
||||
|
||||
## Productization Review
|
||||
1. Secondary scope chips
|
||||
2. Decision workbench
|
||||
3. Detail aside
|
||||
4. Queue context by source family
|
||||
|
||||
- Decision-first: strong concept, needs sharper first action.
|
||||
- Evidence-first: should link to finding, review, run, and proof artifacts.
|
||||
- Context: workspace hub.
|
||||
- Customer/auditor safety: operator-facing, but outputs may feed customer review.
|
||||
- Diagnostics: should remain lower than recommendation and evidence basis.
|
||||
After Spec 346:
|
||||
|
||||
## Information Inventory
|
||||
1. Operator summary with direct `Next recommended action`
|
||||
2. Primary inbox lanes
|
||||
3. Recently resolved secondary disclosure
|
||||
4. Source detail disclosure
|
||||
5. Diagnostics disclosure
|
||||
|
||||
Default content should include pending decision type, impact, environment scope, evidence basis, owner, age/SLA, and recommended next action. Any raw reason ownership or payload data should be hidden.
|
||||
## Current Productized Model
|
||||
|
||||
## Dangerous Actions
|
||||
Governance Inbox now behaves as the operator command surface for governance follow-up, not as a section-first technical list.
|
||||
|
||||
Potential approve, reject, accept risk, close, assign, or escalate actions. Target handling requires explicit confirmation and audit posture per action family.
|
||||
The first screen answers:
|
||||
|
||||
## Scores
|
||||
- What is open right now?
|
||||
- What is the next recommended item?
|
||||
- Which lane is it in?
|
||||
- Why does it matter?
|
||||
- What is the next action?
|
||||
- Which source, proof, or review surface is linked?
|
||||
|
||||
| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 3 | 3 | 3 | 4 | 3 | 3 | 4 | 3 | 3 | 4 | 3 | 4 |
|
||||
## Lane Model
|
||||
|
||||
## Top Issues
|
||||
Primary active lanes are derived from existing repo-backed entries only:
|
||||
|
||||
1. Needs one dominant queue-clearing action model.
|
||||
2. Decision evidence and status dimensions must be separated.
|
||||
3. Customer-safe downstream wording needs review.
|
||||
- `Needs triage`
|
||||
- `Requires decision`
|
||||
- `Risk / exception review`
|
||||
- `Evidence required`
|
||||
- `Blocked`
|
||||
|
||||
## Target Direction
|
||||
Secondary disclosure:
|
||||
|
||||
P0 individual target mockup. This should become the central operator decision surface rather than another technical list.
|
||||
- `Recently resolved` comes from the existing Decision Register builder and stays visually subordinate to active work.
|
||||
|
||||
Intentionally omitted:
|
||||
|
||||
- `Review-ready` is not shown because the current page has no bounded repo-backed review-ready truth.
|
||||
|
||||
## Scope Contract
|
||||
|
||||
- Governance Inbox remains workspace-owned.
|
||||
- Visible local scope uses canonical `environment_id`.
|
||||
- Clean entry stays workspace-wide and does not inherit remembered environment scope.
|
||||
- Clear-filter behavior returns to the clean workspace URL.
|
||||
- Governance Inbox first-party links do not emit retired public query aliases:
|
||||
- `tenant`
|
||||
- `tenant_id`
|
||||
- `managed_environment_id`
|
||||
- `environment`
|
||||
- `tenant_scope`
|
||||
- `tableFilters`
|
||||
|
||||
## Operator Workflow Notes
|
||||
|
||||
- Active work is grouped by operator path, not by source family.
|
||||
- The first viewport promotes a prioritized top item with a direct primary action instead of an indirect lane-review CTA.
|
||||
- Each active card keeps one dominant next action, with title, lane/status, environment, reason, and impact visible.
|
||||
- Linked records, source, owner/due, evidence, accepted-risk/decision detail, and secondary actions stay available under `More context`.
|
||||
- Zero-count lanes are demoted into compact `Clear` chips/status instead of equal-weight lane cards.
|
||||
- Existing source-family context is still available under `Source detail`.
|
||||
- Diagnostics remain collapsed and source-owned.
|
||||
- Emitted `#lane-*` links are covered by browser smoke so the target lane scrolls/anchors correctly.
|
||||
|
||||
## Customer / Legal Language Boundary
|
||||
|
||||
The page remains internal/operator-facing and uses safe operational wording such as:
|
||||
|
||||
- `accepted risk`
|
||||
- `decision`
|
||||
- `requires attention`
|
||||
- `blocked`
|
||||
- `open review context`
|
||||
|
||||
It avoids legalistic or customer-signoff claims.
|
||||
|
||||
## Deferred Follow-Ups
|
||||
|
||||
Not addressed inside Spec 346:
|
||||
|
||||
- Provider-readiness/onboarding productization beyond truthful existing links
|
||||
- Review-ready derivation on the inbox itself
|
||||
- New mutation workflows or new governance persistence
|
||||
- Customer portal / PSA handoff
|
||||
|
||||
## Current Outcome
|
||||
|
||||
Spec 346 now addresses the main productization gap identified in the earlier audit, but the spec is intentionally not closed in this pass:
|
||||
|
||||
- one dominant queue-clearing model now exists
|
||||
- evidence/status/next-action are separated at the card level
|
||||
- the first viewport now exposes a direct recommended item and primary action
|
||||
- zero-lane weight and mobile card density are bounded
|
||||
- source-family detail no longer dominates the first screen
|
||||
- workspace/environment scope stays explicit and stable
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
@ -0,0 +1,48 @@
|
||||
# Specification Quality Checklist: Spec 346 - Governance Inbox Final Operator Workflow
|
||||
|
||||
**Purpose**: Validate Spec 346 preparation completeness before implementation.
|
||||
**Created**: 2026-06-02
|
||||
**Feature**: `specs/346-governance-inbox-final-operator-workflow/spec.md`
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] CHK001 The selected candidate is directly provided by the user and also matches the Spec 345 next-spec recommendation.
|
||||
- [x] CHK002 The candidate aligns with current roadmap and candidate truth: a narrow follow-up over the existing governance inbox runtime, not a greenfield governance system.
|
||||
- [x] CHK003 No existing `specs/346-*` package or `346-*` branch existed before this prep.
|
||||
- [x] CHK004 Related specs were checked for completed-spec signals and are treated as context only: 250, 257, 265, 306, 307, 308, 327, 342, 343, 344, and 345.
|
||||
- [x] CHK005 Close alternatives are deferred rather than hidden scope: provider readiness, artifact lifecycle, localization/copy hardening, and PSA handoff.
|
||||
- [x] CHK006 Scope is narrowed to one existing strategic surface (`/admin/governance/inbox`) plus its truthful linked destinations.
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] CHK007 `spec.md` defines problem, user value, goals, functional requirements, non-functional requirements, non-goals, acceptance criteria, assumptions, risks, and open questions.
|
||||
- [x] CHK008 `plan.md` lists the likely affected repo surfaces and preserves repo-truth-first execution.
|
||||
- [x] CHK009 `tasks.md` is ordered into small phases with explicit repo-truth, test, browser, UI-audit, and validation tasks.
|
||||
- [x] CHK010 No unresolved template placeholders remain in `spec.md`, `plan.md`, or `tasks.md`.
|
||||
|
||||
## Constitution And Scope
|
||||
|
||||
- [x] CHK011 Proportionality review is present and explicitly rejects new persistence, new workflow engines, and new global abstractions.
|
||||
- [x] CHK012 Workspace/environment isolation boundaries and deny-as-not-found posture are preserved.
|
||||
- [x] CHK013 UI Surface Impact and UI/Productization Coverage are completed for the existing strategic Governance Inbox surface.
|
||||
- [x] CHK014 Filament v5 / Livewire v4 posture, panel provider location, global-search posture, destructive-action rules, asset strategy, and testing plan are explicit.
|
||||
|
||||
## Plan Quality
|
||||
|
||||
- [x] CHK015 Plan sequencing is repo truth gate -> tests first -> lane contract -> page productization -> links/scope/safety -> UI audit -> browser smoke -> validation.
|
||||
- [x] CHK016 Deployment/ops impact is explicit and currently expects no env, migration, queue, scheduler, storage, or asset changes.
|
||||
- [x] CHK017 No Graph/provider calls during UI render are enforced by plan constraints.
|
||||
|
||||
## Task Quality
|
||||
|
||||
- [x] CHK018 Tasks include concrete repo surfaces and avoid inventing new runtime paths beyond the existing Governance Inbox and truthful linked surfaces.
|
||||
- [x] CHK019 Tasks include focused Feature coverage and one bounded Browser smoke for the strategic operator surface.
|
||||
- [x] CHK020 Tasks require the repo-truth map, lane-classification artifact, and page-report update instead of speculative framework work.
|
||||
- [x] CHK021 Tasks explicitly forbid new persistence/framework scope and rewriting completed specs.
|
||||
|
||||
## Spec Readiness Gate
|
||||
|
||||
- [x] CHK022 `spec.md`, `plan.md`, `tasks.md`, and this checklist exist.
|
||||
- [x] CHK023 Open questions do not block safe implementation; they are repo-truth refinement questions for the implementation loop.
|
||||
- [x] CHK024 Scope is bounded enough for a later implementation loop.
|
||||
- [x] CHK025 Result: ready for implementation loop.
|
||||
@ -0,0 +1,43 @@
|
||||
# Spec 346 Lane Classification
|
||||
|
||||
Status: implemented
|
||||
Created: 2026-06-02
|
||||
Scope: page-local, derived lane mapping for `/admin/governance/inbox`
|
||||
|
||||
## Rule
|
||||
|
||||
Spec 346 does not add a new persisted inbox-item state machine. Lane assignment is derived from existing source-family entries returned by `GovernanceInboxSectionBuilder`.
|
||||
|
||||
Each visible item appears in exactly one primary lane on the inbox first screen.
|
||||
|
||||
## Active Lane Mapping
|
||||
|
||||
| Source family / state | Lane | Why |
|
||||
| --- | --- | --- |
|
||||
| `intake_findings` | `Needs triage` | These findings are still unassigned and need a first operator path. |
|
||||
| `assigned_findings` with `evidence_state = missing` | `Evidence required` | The operator already owns the item, but linked proof is still missing. |
|
||||
| `assigned_findings` with linked evidence | `Requires decision` | The item still needs operator follow-through and is no longer blocked on missing proof. |
|
||||
| `finding_exceptions` | `Risk / exception review` | Accepted-risk and exception records already have their own review semantics and should not be diluted into generic evidence or decision lanes. |
|
||||
| `review_follow_up` | `Requires decision` | The existing repo truth signals follow-up or changed-since-review state, not a clean review-ready state. |
|
||||
| `stale_operations` | `Blocked` | Existing operation truth already represents stale or terminal technical follow-up. |
|
||||
| `alert_delivery_failures` | `Blocked` | Delivery failures are technical blockers that need operational follow-through. |
|
||||
|
||||
## Secondary / Supporting States
|
||||
|
||||
| Topic | Treatment |
|
||||
| --- | --- |
|
||||
| Recently resolved | Derived from `GovernanceDecisionRegisterBuilder` and rendered as a secondary collapsed disclosure, not as a primary active lane. |
|
||||
| Diagnostics | Kept collapsed and source-owned. |
|
||||
| Source-family context | Kept available in the secondary `Source detail` disclosure. |
|
||||
|
||||
## Intentionally Omitted Lanes
|
||||
|
||||
| Suggested lane | Why omitted |
|
||||
| --- | --- |
|
||||
| `Review-ready` | No honest repo-backed inbox-ready state is currently derivable on this page without inventing new workflow truth. |
|
||||
|
||||
## Why Page-Local Mapping Was Enough
|
||||
|
||||
- The source builder already returns repo-backed entries with status, reason, impact, evidence, and action URLs.
|
||||
- The lane transform is presentational and does not need to spread into models, enums, migrations, or shared infrastructure.
|
||||
- Keeping the mapper local to `GovernanceInbox` avoids creating a new governance engine or taxonomy layer.
|
||||
340
specs/346-governance-inbox-final-operator-workflow/plan.md
Normal file
340
specs/346-governance-inbox-final-operator-workflow/plan.md
Normal file
@ -0,0 +1,340 @@
|
||||
# Implementation Plan: Spec 346 - Governance Inbox Final Operator Workflow
|
||||
|
||||
**Branch**: `346-governance-inbox-final-operator-workflow` | **Date**: 2026-06-02 | **Spec**: `specs/346-governance-inbox-final-operator-workflow/spec.md`
|
||||
**Input**: User-provided Spec 346 draft + Spec 345 next-spec recommendation + current Governance Inbox runtime truth.
|
||||
|
||||
## Summary
|
||||
|
||||
Finalize the existing Governance Inbox as the daily operator queue.
|
||||
|
||||
This is a narrow runtime productization slice only:
|
||||
|
||||
- no new governance engine
|
||||
- no new persisted inbox item state
|
||||
- no Decision Register rebuild
|
||||
- no customer portal
|
||||
- no shell/sidebar/topbar rewrite
|
||||
- no broad provider-readiness redesign
|
||||
- no new workflow mutation surface by default
|
||||
|
||||
The implementation should make the first screen answer:
|
||||
|
||||
`What needs my attention, why does it matter, and what is the next action?`
|
||||
|
||||
Status note: Spec 346 is not closed. The 2026-06-02 bounded density/productization polish is part of this same spec and must be validated before close-out is claimed.
|
||||
|
||||
## Technical Context
|
||||
|
||||
- **Language/Version**: PHP 8.4.15, Laravel 12.52.x.
|
||||
- **Primary Dependencies**: Filament v5.2.x, Livewire v4.1.x, Pest v4, Tailwind CSS v4.
|
||||
- **Storage**: PostgreSQL; no schema change expected.
|
||||
- **Testing**: Pest Feature tests plus one bounded Pest Browser smoke file.
|
||||
- **Validation Lanes**: confidence + browser.
|
||||
- **Target Platform**: `apps/platform` Laravel monolith; Sail-first locally; Dokploy/container posture unchanged.
|
||||
- **Project Type**: web application.
|
||||
- **Performance Goals**: DB-only page render, no Graph/provider calls during render, bounded lane counts/previews, eager loading only where relationship-backed labels or links require it.
|
||||
- **Constraints**: no hidden environment scope, no `/admin/t` resurrection, no retired query alias support, no new persisted state, no false customer-safe/legal/compliance claims, diagnostics-secondary ordering, and no cross-workspace leakage.
|
||||
- **Scale/Scope**: one existing strategic page, one existing section builder, one existing ledger surface, targeted Feature coverage, one Browser smoke, and spec-local prep artifacts.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: material change to an existing strategic operator surface (`UI-028`).
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
|
||||
- `/admin/governance/inbox`
|
||||
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
|
||||
- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
|
||||
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
|
||||
- linked existing routes for `DecisionRegister`, findings, finding exceptions, review workspace, evidence, review packs, operations, and provider-readiness destinations where repo-backed
|
||||
- **No-impact class, if applicable**: N/A
|
||||
- **Native vs custom classification summary**: native Filament page plus existing Blade composition; keep new UI on Filament/shared primitives first
|
||||
- **Shared-family relevance**: governance queue summaries, evidence/proof links, workspace hub filtering, review linkage, navigation continuity
|
||||
- **State layers in scope**: page payload, URL query filter, secondary diagnostics disclosure, current source-link actions
|
||||
- **Audience modes in scope**: operator-MSP, manager, support where authorized
|
||||
- **Decision/diagnostic/raw hierarchy plan**: summary and lanes first, linked proof second, diagnostics third
|
||||
- **Raw/support gating plan**: collapsed or clearly secondary; capability-gated where the current source surface requires it
|
||||
- **One-primary-action / duplicate-truth control**: each active item keeps one dominant next action; supporting links stay secondary
|
||||
- **Repository-signal treatment**: review-mandatory because this is a strategic operator surface with existing audit/report coverage
|
||||
- **Special surface test profiles**: `global-context-shell` plus decision-first disclosure
|
||||
- **Required tests or manual smoke**: functional-core + browser smoke
|
||||
- **Exception path and spread control**: no new runtime framework. Any page-local lane DTO/helper must stay bounded to `GovernanceInbox`
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **UI/Productization coverage decision**: update `docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md`; update route inventory or coverage matrix only if route/archetype classification changes materially
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**:
|
||||
- Governance Inbox page/view/builder
|
||||
- Decision Register linkage
|
||||
- current findings / exception queues and detail surfaces
|
||||
- current review workspace and related review artifacts
|
||||
- evidence/proof and operation-run link helpers
|
||||
- workspace hub environment filter and clear-filter contracts
|
||||
- **Shared abstractions reused**:
|
||||
- `GovernanceInboxSectionBuilder`
|
||||
- `CanonicalNavigationContext`
|
||||
- `WorkspaceHubEnvironmentFilter`
|
||||
- `WorkspaceHubNavigation`
|
||||
- `OperationRunLinks`
|
||||
- existing resource/page URL helpers and current policy/capability checks
|
||||
- **New abstraction introduced? why?**: none by default. A small page-local lane mapper or DTO is allowed only if it reduces scattered view/page logic and remains local to `GovernanceInbox`
|
||||
- **Why the existing abstraction was sufficient or insufficient**: current abstractions already provide truth and drill-through. They do not yet provide the final operator lane hierarchy, summary counts, or resolved/blocked segregation required by Spec 345.
|
||||
- **Bounded deviation / spread control**: do not create a generic governance queue framework, a new shared taxonomy layer, or a new persisted truth
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: existing proof links only
|
||||
- **Central contract reused**: `OperationRunLinks` and current operation URLs
|
||||
- **Delegated UX behaviors**: operation deep-link resolution stays shared; no new operation start/toast/notification lifecycle
|
||||
- **Surface-owned behavior kept local**: explicit blocker display and link selection for blocked items only
|
||||
- **Queued DB-notification policy**: N/A
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no new provider seam
|
||||
- **Provider-owned seams**: N/A
|
||||
- **Platform-core seams**: governance queue, decision linkage, evidence/review/proof presentation over current workspace/environment artifacts
|
||||
- **Neutral platform terms / contracts preserved**: workspace, environment, governance inbox, decision, review-ready, blocked, evidence, accepted risk, diagnostics
|
||||
- **Retained provider-specific semantics and why**: only where underlying source content is already repo-backed
|
||||
- **Bounded extraction or follow-up path**: provider readiness remains a separate follow-up spec
|
||||
|
||||
## Current Repo Truth Summary
|
||||
|
||||
- `GovernanceInbox` already exists, is registered in the admin panel, and currently renders:
|
||||
- a decision workbench
|
||||
- secondary family filters
|
||||
- a detail aside
|
||||
- diagnostics disclosure
|
||||
- a queue context below
|
||||
- `GovernanceInboxSectionBuilder` already derives the current source families:
|
||||
- assigned findings
|
||||
- intake findings
|
||||
- finding exceptions
|
||||
- stale operations
|
||||
- alert delivery failures
|
||||
- review follow-up
|
||||
- `DecisionRegister` already exists as `/admin/governance/decisions` and should remain the historical/proof ledger
|
||||
- current Governance Inbox tests already verify:
|
||||
- rendered workbench copy and missing-state honesty
|
||||
- accepted-risk/exception visibility
|
||||
- environment filter behavior
|
||||
- diagnostics hidden by default
|
||||
- current browser smoke and screenshot artifact behavior
|
||||
- current UI audit `docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md` already records the remaining gap: one dominant queue-clearing model, better separation of evidence/status dimensions, and safer downstream wording
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 0 - Repo Truth Gate
|
||||
|
||||
1. Re-read this spec, plan, tasks, and the related completed context only:
|
||||
- Specs 250, 257, 265, 306, 307, 308, 327, 342, 343, 344, 345
|
||||
2. Inspect current runtime files before editing:
|
||||
- `GovernanceInbox.php`
|
||||
- `governance-inbox.blade.php`
|
||||
- `GovernanceInboxSectionBuilder.php`
|
||||
- `DecisionRegister.php`
|
||||
3. Create `specs/346-governance-inbox-final-operator-workflow/repo-truth-map.md` documenting:
|
||||
- current families
|
||||
- current item fields
|
||||
- current proof/link destinations
|
||||
- current scope/filter contract
|
||||
- current gaps against Spec 346
|
||||
4. Do not invent unsupported states. If the repo cannot derive `Review-ready` or `Recently resolved` truthfully, document the limitation and keep the v1 lane set smaller.
|
||||
|
||||
### Phase 1 - Tests First
|
||||
|
||||
1. Add focused Feature coverage for:
|
||||
- summary-first hierarchy
|
||||
- operator lane headings
|
||||
- reason/impact/next-action rendering
|
||||
- visible `environment_id` state
|
||||
- empty state and blocked state
|
||||
- resolved items staying secondary
|
||||
2. Add focused Navigation coverage for:
|
||||
- canonical `environment_id` links only
|
||||
- no retired query alias usage
|
||||
- correct destination URLs for decision/review/evidence/operation links
|
||||
3. Reuse existing governance inbox fixtures/helpers where possible; do not widen suite defaults.
|
||||
|
||||
### Phase 2 - Lane Classification Contract
|
||||
|
||||
1. Create `specs/346-governance-inbox-final-operator-workflow/contracts/lane-classification.md`
|
||||
2. Define the smallest truthful mapping from current source families/item states into operator lanes
|
||||
3. Keep the mapping derived and local
|
||||
4. Prefer conservative omission or `unsupported` documentation over invented lane logic
|
||||
|
||||
Expected direction:
|
||||
|
||||
- `Needs triage`
|
||||
- `Requires decision`
|
||||
- `Risk / exception review`
|
||||
- `Blocked`
|
||||
- `Evidence required` only when current truth supports it
|
||||
- `Review-ready` only when current truth supports it
|
||||
- `Recently resolved` only when current truth supports it
|
||||
|
||||
### Phase 3 - Page Productization
|
||||
|
||||
1. Move operator summary to the top and make it answer the first operator question immediately
|
||||
2. Refactor the primary page hierarchy around derived operator lanes rather than only source-family sections
|
||||
3. Keep one dominant next action per active item
|
||||
4. Keep diagnostics collapsed or secondary
|
||||
5. Keep queue/source context available as supporting context, not the dominant first-screen story
|
||||
|
||||
### Phase 4 - Links, Scope, And Safety
|
||||
|
||||
1. Reuse current link helpers/resources/routes for findings, decisions, evidence, review packs, operation proof, and readiness surfaces
|
||||
2. Preserve current workspace/environment filter contract and deny-as-not-found behavior
|
||||
3. Keep the page read-first by default
|
||||
4. If a new high-impact action becomes necessary, stop and update spec/plan before implementation
|
||||
|
||||
### Phase 5 - Copy, Audit Artifact, And Screenshots
|
||||
|
||||
1. Update copy/localization strings only where the runtime diff requires it
|
||||
2. Keep operator-safe, non-legalistic language
|
||||
3. Update `docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md` with before/after hierarchy, lane model, scope contract, and deferred follow-ups
|
||||
4. Capture screenshots under this spec package during browser smoke
|
||||
|
||||
### Phase 6 - Browser Smoke And Validation
|
||||
|
||||
1. Add one bounded Browser smoke proving:
|
||||
- summary first
|
||||
- visible lanes
|
||||
- filtered `environment_id` behavior
|
||||
- one representative primary action
|
||||
- return to inbox scope
|
||||
- empty or blocked state if fixtures support it
|
||||
2. Run focused validation commands
|
||||
3. Record known unreachable states honestly instead of faking fixtures
|
||||
|
||||
### Phase 7 - Bounded Density / Productization Polish
|
||||
|
||||
1. Harden the Governance Inbox page ViewModel contract so rendered actions, lanes, badges/counts, source entries, and links have consistent keys before Blade renders them.
|
||||
2. Replace indirect summary-level lane CTAs with a prioritized `Next recommended action` item in the first viewport when active work exists.
|
||||
3. Demote zero-count lanes into compact clear indicators while keeping active lanes prominent.
|
||||
4. Compress active item cards for mobile by keeping title, lane/status, environment, reason, impact, and primary action visible, with source/owner/evidence/decision/linked records/secondary actions behind secondary disclosure.
|
||||
5. Keep blocked-lane repetition bounded by compacting repeated detail boxes and moving repeated secondary context behind the same disclosure pattern.
|
||||
6. Verify emitted `#lane-*` hashes scroll to or visibly mark their lane in Browser smoke coverage.
|
||||
7. Do not add migrations, a new domain model, new mutating actions, PSA/ITSM, customer portal, or broader navigation scope.
|
||||
|
||||
## Deployment / Ops Impact
|
||||
|
||||
- **Env vars**: none expected
|
||||
- **Migrations**: none expected
|
||||
- **Queues/scheduler**: none expected
|
||||
- **Storage/volumes**: none expected
|
||||
- **Filament assets**: none expected; if any registered assets unexpectedly appear, deployment must include `cd apps/platform && php artisan filament:assets`
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature for hierarchy/state/scope/link contracts; Browser for rendered first-screen scanability and screenshot proof
|
||||
- **Affected validation lanes**: confidence + browser
|
||||
- **Why this lane mix is the narrowest sufficient proof**: queue logic and scope are deterministic in Feature tests; first-screen scanability and operator hierarchy are user-facing product truth and need browser proof
|
||||
- **Narrowest proving command(s)**:
|
||||
|
||||
```bash
|
||||
cd apps/platform
|
||||
./vendor/bin/sail artisan test tests/Feature/Governance/Spec346GovernanceInboxOperatorWorkflowTest.php tests/Feature/Navigation/Spec346GovernanceInboxScopeContractTest.php --compact
|
||||
./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec346GovernanceInboxOperatorWorkflowSmokeTest.php --compact
|
||||
./vendor/bin/sail artisan test tests/Feature/Governance/GovernanceInboxPageTest.php --compact
|
||||
./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php --compact
|
||||
./vendor/bin/sail artisan test tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php --compact
|
||||
./vendor/bin/sail pint --dirty
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing Governance Inbox builders/fixtures and avoid creating a broad new browser fixture family
|
||||
- **Expensive defaults or shared helper growth introduced?**: none by default
|
||||
- **Heavy-family additions, promotions, or visibility changes**: one explicit browser smoke only
|
||||
- **Surface-class relief / special coverage rule**: no relief; this is a strategic operator surface
|
||||
- **Closing validation and reviewer handoff**: confirm summary-first hierarchy, truthful lane mapping, visible `environment_id` state, scope-correct links, no forbidden query aliases, diagnostics-secondary ordering, and no new mutation surface
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond one explicit browser smoke
|
||||
- **Review-stop questions**:
|
||||
- Did the implementation invent a new workflow state or persistence?
|
||||
- Did the page silently inherit environment scope?
|
||||
- Did the page start behaving like a second Decision Register or a task engine?
|
||||
- Did diagnostics or raw details become first-screen content?
|
||||
- Did a link emit a retired public query alias?
|
||||
- **Escalation path**: `document-in-feature` for unreachable states; `follow-up-spec` for missing backend truth; `reject-or-split` for governance-engine scope creep
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/346-governance-inbox-final-operator-workflow/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
├── repo-truth-map.md
|
||||
├── contracts/
|
||||
│ └── lane-classification.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── artifacts/
|
||||
└── screenshots/
|
||||
```
|
||||
|
||||
### Expected implementation touch points
|
||||
|
||||
```text
|
||||
apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php
|
||||
apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php
|
||||
apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php
|
||||
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
|
||||
apps/platform/tests/Feature/Governance/Spec346GovernanceInboxOperatorWorkflowTest.php
|
||||
apps/platform/tests/Feature/Navigation/Spec346GovernanceInboxScopeContractTest.php
|
||||
apps/platform/tests/Browser/Spec346GovernanceInboxOperatorWorkflowSmokeTest.php
|
||||
docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md
|
||||
```
|
||||
|
||||
### Supporting surfaces to inspect, not broadly redesign
|
||||
|
||||
```text
|
||||
apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php
|
||||
apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
|
||||
apps/platform/app/Filament/Resources/FindingResource.php
|
||||
apps/platform/app/Filament/Resources/FindingExceptionResource.php
|
||||
apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php
|
||||
apps/platform/app/Filament/Resources/ReviewPackResource.php
|
||||
apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php
|
||||
apps/platform/app/Support/Navigation/WorkspaceHubNavigation.php
|
||||
apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
|
||||
apps/platform/app/Support/OperationRunLinks.php
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|---|---|---|
|
||||
| Possible page-local lane mapper / DTO | May be needed to keep lane grouping and item rendering testable without scattering logic across page and Blade | Pure Blade-only grouping risks duplicated conditions, fragile tests, and false claims |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **New persisted truth**: none
|
||||
- **New abstraction**: none by default; only a bounded page-local lane mapper if needed
|
||||
- **New state/status family**: no persisted family; lane keys remain derived
|
||||
- **Ownership cost**: repo-truth map, lane-classification artifact, focused tests, one browser smoke, screenshots, and page-report update
|
||||
- **Rejected alternatives**: new queue engine, persisted queue state, Decision Register expansion, provider-readiness merger, or cross-product workflow framework
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- Inventory-first: pass; all lane and summary states derive from current records
|
||||
- Read/write separation: pass; default surface remains read-first and navigation-oriented
|
||||
- Graph contract path: pass; no Graph/provider calls during page render
|
||||
- Deterministic capabilities: pass; current policies/capabilities remain authoritative
|
||||
- Workspace/environment isolation: pass; visible `environment_id` contract remains central
|
||||
- Run observability: pass; operation links remain proof only
|
||||
- Data minimization: pass; raw diagnostics remain secondary
|
||||
- Test governance: pass; confidence + one browser smoke are explicit
|
||||
- Proportionality / no premature abstraction: pass with bounded local mapper guard
|
||||
- Persisted truth / behavioral state: pass; no new persistence or lifecycle family
|
||||
- Shared pattern first: pass; reuse current inbox/decision/review/evidence/link helpers
|
||||
- Provider boundary: pass; no provider seam change
|
||||
- UI/Productization coverage: pass; existing strategic surface is updated with explicit page-report maintenance
|
||||
- Filament v5 / Livewire v4 compliance: required and explicit
|
||||
- Provider registration: unchanged in `apps/platform/bootstrap/providers.php`
|
||||
- Global search posture: no resource global-search change is expected; existing sensitive resources stay disabled or unchanged
|
||||
@ -0,0 +1,90 @@
|
||||
# Spec 346 Repo Truth Map
|
||||
|
||||
Status: implemented
|
||||
Created: 2026-06-02
|
||||
Purpose: record the repo-backed inputs, scope contracts, and current gaps that shaped the final Governance Inbox operator workflow.
|
||||
|
||||
## Runtime Surfaces
|
||||
|
||||
| Area | Repo source | Current truth |
|
||||
| --- | --- | --- |
|
||||
| Governance Inbox page | `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` | Workspace-owned Filament page at `/admin/governance/inbox` with visible optional `environment_id` filter. |
|
||||
| Governance Inbox view | `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` | Operator summary first, lane groups second, source-family context and diagnostics secondary. |
|
||||
| Derived source families | `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` | Existing read-only source families remain: assigned findings, intake findings, finding exceptions, stale operations, alert delivery failures, review follow-up. |
|
||||
| Decision history / proof | `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php`, `apps/platform/app/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilder.php` | Existing read-only decision ledger with `open` and `recently_closed` states. Reused, not rebuilt. |
|
||||
|
||||
## Repo-Backed Inputs
|
||||
|
||||
| Signal | Repo-backed source | Inbox use |
|
||||
| --- | --- | --- |
|
||||
| Assigned findings | `Finding`, `FindingResource`, section builder assigned query | `Requires decision` or `Evidence required` depending on linked evidence state. |
|
||||
| Intake findings | `Finding`, intake query | `Needs triage`. |
|
||||
| Accepted-risk / exception records | `FindingException`, `FindingExceptionDecision`, `FindingExceptionsQueue` | `Risk / exception review`. |
|
||||
| Failed / stale operations | `OperationRun`, `OperationRunLinks` | `Blocked`. |
|
||||
| Failed alert deliveries | `AlertDelivery`, `AlertDeliveryResource` | `Blocked`. |
|
||||
| Review follow-up | `ManagedEnvironmentTriageReview`, `EnvironmentReviewRegisterService`, `CustomerReviewWorkspace` | `Requires decision` with customer-review linkage. |
|
||||
| Recently closed governance decisions | `GovernanceDecisionRegisterBuilder` | Secondary `Recently resolved` disclosure only. |
|
||||
|
||||
## Current Item Fields
|
||||
|
||||
Current source entries already expose the fields needed for a derived operator card without new persistence:
|
||||
|
||||
- `headline`
|
||||
- `status_label`
|
||||
- `reason_label`
|
||||
- `impact_label`
|
||||
- `tenant_label`
|
||||
- `owner_label`
|
||||
- `due_label`
|
||||
- `evidence_label`
|
||||
- `exception_label`
|
||||
- `primary_action_label`
|
||||
- `primary_action_url`
|
||||
- `destination_url`
|
||||
- `evidence_path_url`
|
||||
- `urgency_rank`
|
||||
|
||||
Spec 346 keeps these fields derived and page-local.
|
||||
|
||||
## Link Targets
|
||||
|
||||
| Link type | Current truth |
|
||||
| --- | --- |
|
||||
| Finding detail | Existing tenant-owned `FindingResource` view route. |
|
||||
| Finding exceptions queue | Existing workspace hub route with canonical `environment_id` and `exception`. |
|
||||
| Decision Register | Existing workspace hub route with optional `environment_id` and `register_state`. |
|
||||
| Evidence overview | Existing workspace hub route `route('admin.evidence.overview', ['environment_id' => ...])`. |
|
||||
| Customer Review Workspace | Existing workspace hub route `CustomerReviewWorkspace::environmentFilterUrl($environment)`. |
|
||||
| Operation proof | Existing `OperationRunLinks::tenantlessView(...)`. |
|
||||
| Environment detail | Existing `ManagedEnvironmentLinks::viewUrl(...)`. |
|
||||
|
||||
## Scope Contract
|
||||
|
||||
- Governance Inbox remains workspace-owned.
|
||||
- The only visible local environment scope contract is `?environment_id={id}`.
|
||||
- Clean entry must stay tenantless/workspace-wide even when the shell remembers an environment.
|
||||
- No Governance Inbox first-party link may emit retired public query aliases:
|
||||
- `tenant`
|
||||
- `tenant_id`
|
||||
- `managed_environment_id`
|
||||
- `environment`
|
||||
- `tenant_scope`
|
||||
- `tableFilters`
|
||||
|
||||
## Gaps And Conservative Decisions
|
||||
|
||||
| Topic | Repo truth | Spec 346 decision |
|
||||
| --- | --- | --- |
|
||||
| Review-ready lane | No bounded repo-backed governance-inbox-ready state exists on this page today. | Omitted instead of invented. |
|
||||
| Persisted inbox items | No new truth table or workflow engine exists. | Not added. |
|
||||
| New mutating actions | Existing page is read-first and current source surfaces own mutations. | Not added. |
|
||||
| Recently resolved lane | Repo truth exists only in the Decision Register, not in current inbox source-family entries. | Shown as a secondary disclosure backed by the existing register builder. |
|
||||
| Provider-readiness blocker classification | Repo truth exists on adjacent readiness/required-permissions surfaces, but not as a generalized inbox state. | Existing operation / alert / environment links reused; no new readiness engine introduced. |
|
||||
|
||||
## Filament / Runtime Guardrails
|
||||
|
||||
- Livewire v4-only patterns retained.
|
||||
- No panel registration changes; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- No global-search posture changed.
|
||||
- No new assets registered.
|
||||
- No migration, queue, scheduler, env-var, or deployment contract change was required.
|
||||
467
specs/346-governance-inbox-final-operator-workflow/spec.md
Normal file
467
specs/346-governance-inbox-final-operator-workflow/spec.md
Normal file
@ -0,0 +1,467 @@
|
||||
# Feature Specification: Spec 346 - Governance Inbox Final Operator Workflow
|
||||
|
||||
**Feature Branch**: `346-governance-inbox-final-operator-workflow`
|
||||
**Created**: 2026-06-02
|
||||
**Status**: Draft
|
||||
**Type**: Platform productization / operator workflow / governance inbox closeout
|
||||
**Runtime posture**: Narrow runtime productization over an existing strategic surface. No new governance engine or new persisted workflow truth.
|
||||
**Close-out posture**: Not closed. The bounded density/productization polish requested on 2026-06-02 remains part of Spec 346 before close-out is claimed.
|
||||
**Input**: User-provided full Spec 346 draft + repo truth from Specs 250, 257, 265, 306, 307, 308, 327, 338-345.
|
||||
|
||||
## Dependencies And Historical Context
|
||||
|
||||
Depends on the existing governance and scope line:
|
||||
|
||||
- Spec 250 - Decision-Based Governance Inbox v1
|
||||
- Spec 257 - Governance Decision Surface Convergence
|
||||
- Spec 265 - Decision Register & Approval Workflow v1
|
||||
- Spec 306 - Decision Register Reconciliation
|
||||
- Spec 307 - Decision Register Evidence / OperationRun Link Polish
|
||||
- Spec 308 - Decision Register Summary / Review Pack Inclusion
|
||||
- Spec 327 - Governance Inbox Decision-First Workbench Productization
|
||||
- Specs 338-341 - workspace / environment scope and canonical-link contracts
|
||||
- Specs 342-344 - customer review completion, accepted-risk productization, and audience polish
|
||||
- Spec 345 - Platform Productization Readiness / Roadmap Reconciliation Gate
|
||||
|
||||
Repo truth adjustment:
|
||||
|
||||
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` already exists and is repo-real.
|
||||
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` already composes the current families:
|
||||
- `assigned_findings`
|
||||
- `intake_findings`
|
||||
- `finding_exceptions`
|
||||
- `stale_operations`
|
||||
- `alert_delivery_failures`
|
||||
- `review_follow_up`
|
||||
- `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php` is already the historical/proof-oriented decision ledger.
|
||||
- The current UI audit route inventory tracks Governance Inbox as `UI-028`, with page report `docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md`, not a new `ui-008-*` artifact.
|
||||
- Existing test truth lives primarily under:
|
||||
- `apps/platform/tests/Feature/Governance/*`
|
||||
- `apps/platform/tests/Feature/Navigation/*`
|
||||
- `apps/platform/tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php`
|
||||
|
||||
Spec 346 must therefore productize the existing page, route, builder, and shared link/filter contracts instead of inventing a new queue family, a new persistence layer, or a second decision home.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Governance Inbox is repo-real and already shows governance attention, but it still reads more like a dense admin queue than the calm daily operator worklist that answers what needs attention, why it matters, and what the next action is.
|
||||
- **Today's failure**: Operators can see findings, exceptions, operations, alerts, and review follow-up, but still need to reconstruct whether an item needs triage, a decision, evidence, risk review, or review handoff. The page does not yet consistently separate active work from blocked, review-ready, or recently resolved context.
|
||||
- **User-visible improvement**: `/admin/governance/inbox` becomes the central operator queue for governance work: summary first, actionable lanes, explicit reason and impact, one primary next action per item, clear downstream links, visible environment filter state, and calmer disclosure of diagnostics.
|
||||
- **Smallest enterprise-capable version**: Rework only the existing Governance Inbox page, its view, and its derived payload composition over current repo-backed records. Add targeted Feature and Browser proof for lane classification, scope-correct links, empty/blocked/resolved states, and first-screen scanability.
|
||||
- **Explicit non-goals**: No new governance engine, no new persisted inbox item, no Kanban board, no PSA/helpdesk handoff, no customer portal, no Decision Register rebuild, no broad Findings or Customer Review Workspace rewrite, no provider readiness redesign, no new workflow mutation surface by default, no new status family unless current repo truth proves one is unavoidable and proportional.
|
||||
- **Permanent complexity imported**: One page-local operator lane contract, one repo-truth map, one lane-classification artifact, focused Feature tests, one bounded Browser smoke file, and screenshot artifacts. No new table, no global abstraction, no public framework, and no new persisted source of truth.
|
||||
- **Why now**: Spec 345 explicitly identifies Governance Inbox Final Operator Workflow as the next central productization gap after shell/scope closure and customer-review maturity. The backlog wording is stale relative to current runtime truth; the operator queue itself is now the clearest remaining daily-use blocker.
|
||||
- **Why not local**: Copy-only tweaks or one more summary card would leave the operator reconstruction problem intact. A new workboard would overbuild. The narrow correct slice is a derived, decision-oriented final workflow pass over the existing inbox runtime.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: One strategic-surface and one cross-surface composition flag. Defense: the slice stays on one existing page, reuses existing truth and routes, forbids new persistence/frameworks, and explicitly preserves completed specs as context only.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 12/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Candidate Source And Completed-Spec Guardrail
|
||||
|
||||
- **Candidate source**:
|
||||
- direct user-provided Spec 346 draft
|
||||
- `docs/product/spec-candidates.md` manual-promotion backlog item `decision-based-governance-inbox-v1`
|
||||
- `docs/product/roadmap.md` decision-based governance inbox priority lane
|
||||
- `specs/345-platform-productization-readiness-roadmap-reconciliation-gate/next-spec-recommendation.md`
|
||||
- **Completed-spec guardrail result**:
|
||||
- no `specs/346-*` package existed before this prep
|
||||
- related Specs 250, 257, 265, 306, 307, 308, 327, 342, 343, 344, and 345 contain prepared, validated, completed-task, close-out, review-outcome, or implementation-history signals and are treated as historical context only
|
||||
- these related packages must not be rewritten, normalized, or converted back into preparation-only wording
|
||||
- **Close alternatives deferred**:
|
||||
- provider readiness / onboarding productization
|
||||
- evidence and retained artifact lifecycle follow-through
|
||||
- localization and customer-safe copy hardening
|
||||
- PSA / ITSM handoff
|
||||
- **Smallest viable implementation slice**: Existing Governance Inbox only: operator summary first, lane grouping over repo-real items, visible `environment_id` filter, scope-correct deep links into existing surfaces, calmer empty/blocked/resolved treatment, and focused tests/browser smoke.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace canonical-view governance operator queue, optionally filtered by visible `environment_id`.
|
||||
- **Primary Routes**:
|
||||
- existing route `/admin/governance/inbox`
|
||||
- related existing route `/admin/governance/decisions`
|
||||
- linked existing workspace or environment surfaces for findings, finding exceptions, evidence, reviews, operations, and provider/readiness destinations when repo-backed
|
||||
- **Data Ownership**:
|
||||
- `Finding` remains the source of truth for findings, owner/due, severity, evidence summary, and current primary remediation/triage path
|
||||
- `FindingException` and `FindingExceptionDecision` remain the accepted-risk / exception truth
|
||||
- `ManagedEnvironmentTriageReview`, `EnvironmentReview`, `EnvironmentReviewAcknowledgement`, and `ReviewPack` remain the review/customer-safe handoff truth
|
||||
- `EvidenceSnapshot` and related artifact resources remain evidence truth
|
||||
- `OperationRun` plus existing run-link helpers remain operation proof truth
|
||||
- `AlertDelivery` remains alert-delivery failure truth
|
||||
- `WorkspaceHubEnvironmentFilter`, `WorkspaceHubNavigation`, and canonical navigation helpers remain scope/filter truth
|
||||
- the inbox itself remains a derived read surface only
|
||||
- **RBAC**:
|
||||
- workspace membership is required
|
||||
- environment filtering must resolve only through current-workspace entitled environments
|
||||
- non-members and out-of-scope `environment_id` requests remain deny-as-not-found
|
||||
- existing family/resource policies and capabilities remain authoritative for which lanes, items, and actions are visible
|
||||
- the page must not hint at hidden work from invisible families or hidden environments
|
||||
|
||||
For canonical-view specs:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: clean `/admin/governance/inbox` must remain workspace-wide and must not inherit remembered environment shell state, hidden topbar environment context, legacy query aliases, or stale table filters.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: `?environment_id=` must be visible, local to the page, resolved through current workspace membership, and denied as not found when out of scope.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [x] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
|
||||
|
||||
- **Route/page/surface**: `/admin/governance/inbox`, `GovernanceInbox`, `governance-inbox.blade.php`, `GovernanceInboxSectionBuilder`
|
||||
- **Current or new page archetype**: Strategic operator queue / governance inbox workbench, continuing `UI-028`
|
||||
- **Design depth**: Strategic Surface
|
||||
- **Repo-truth level**: repo-verified existing runtime surface
|
||||
- **Existing pattern reused**: existing Governance Inbox page, workspace hub filter chip, Decision Register linkage, current shared navigation/link helpers, current page report `ui-004-governance-inbox.md`
|
||||
- **New pattern required**: bounded page-local lane grouping and operator summary over existing truth; no new global runtime pattern
|
||||
- **Screenshot required**: yes, under `specs/346-governance-inbox-final-operator-workflow/artifacts/screenshots/`
|
||||
- **Page audit required**: update the existing Governance Inbox page report `docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md` if the runtime diff changes the documented hierarchy materially; do not invent `ui-008-*` by default
|
||||
- **Customer-safe review required**: operator-facing surface only, but downstream wording must remain customer-safe where review-ready or accepted-risk states appear
|
||||
- **Dangerous-action review required**: no new dangerous action is expected. If a new governance mutation appears necessary, the spec and plan must be updated first and the action must follow confirmation, authorization, audit, and test rules
|
||||
- **Coverage files updated or explicitly not needed**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||
- [x] `docs/ui-ux-enterprise-audit/page-reports/...`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||
- [ ] `N/A - no reachable UI surface impact`
|
||||
- **No-impact rationale when applicable**: N/A
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, action links, workspace hub filtering, evidence/proof links, review/review-pack links, navigation continuity, governance queue hierarchy
|
||||
- **Systems touched**:
|
||||
- `GovernanceInbox`
|
||||
- `GovernanceInboxSectionBuilder`
|
||||
- `DecisionRegister`
|
||||
- findings / finding-exception queues and detail resources
|
||||
- `CustomerReviewWorkspace`
|
||||
- evidence and review-pack resources
|
||||
- `OperationRunLinks`
|
||||
- workspace hub filter and navigation helpers
|
||||
- **Existing pattern(s) to extend**: current inbox family payloads, workspace hub filter chip, canonical navigation/back-link behavior, current source routes and policy checks
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `WorkspaceHubEnvironmentFilter`, `WorkspaceHubNavigation`, `OperationRunLinks`, current resource/page URL helpers and policies
|
||||
- **Why the existing shared path is sufficient or insufficient**: existing paths are sufficient for truth, authorization, and drill-through. They are insufficient only for the final operator queue hierarchy because they still present family-level attention more prominently than the queue-clearing decision model.
|
||||
- **Allowed deviation and why**: one bounded page-local derived lane contract or DTO is allowed if it keeps summary and item rendering testable without becoming a reusable workflow engine
|
||||
- **Consistency impact**: lane copy, next-action labels, environment scope, evidence/proof labels, and customer-safe wording must stay aligned with current findings, exception, evidence, review, and Decision Register language
|
||||
- **Review focus**: block any second decision home, new persisted queue state, unauthorized hidden-work hinting, diagnostics-first hierarchy, or local mutation semantics that duplicate existing surfaces
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: deep-link semantics only
|
||||
- **Shared OperationRun UX contract/layer reused**: existing `OperationRunLinks` and existing operations page/resource URLs
|
||||
- **Delegated start/completion UX behaviors**: N/A - no new operation start or lifecycle
|
||||
- **Local surface-owned behavior that remains**: show blocked/proof context and operation-link next action only where current repo truth already supports it
|
||||
- **Queued DB-notification policy**: N/A
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no new provider seam
|
||||
- **Boundary classification**: platform-core operator workflow over existing environment-bound governance records
|
||||
- **Seams affected**: presentation and routing only
|
||||
- **Neutral platform terms preserved or introduced**: workspace, environment, governance inbox, decision, evidence, review-ready, accepted risk, blocked, proof, diagnostics
|
||||
- **Provider-specific semantics retained and why**: any Microsoft/Intune wording remains only where the underlying repo-backed source item already uses it
|
||||
- **Why this does not deepen provider coupling accidentally**: no Graph calls, no provider models, no provider connection changes, and no new provider-shaped persistence are introduced
|
||||
- **Follow-up path**: provider readiness/onboarding productization remains a separate follow-up spec
|
||||
|
||||
## UI / Surface Guardrail Impact
|
||||
|
||||
| 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 Blade composition | governance queues, evidence/proof links, review linkage, navigation continuity | page, URL-query, derived payload | no | Existing route only |
|
||||
| Operator summary zone | yes | page-local composition | queue-clearing summary, lane counts, primary next action | page payload | no | Repo-backed counts only |
|
||||
| Lane groups | yes | page-local composition | decision-first work grouping over current families | page payload | no | Derived only; no new persistence |
|
||||
| Work item cards / rows | yes | page-local composition over existing sections/items | reason, impact, next action, linked context | page payload | no | Must stay source-backed |
|
||||
| Recently resolved / diagnostics areas | yes | page-local composition | disclosure hierarchy and calmness | page payload | no | Secondary or collapsed only |
|
||||
|
||||
## Decision-First Surface Role
|
||||
|
||||
| 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 | Primary Decision Surface | Operator decides what governance work to clear next | summary counts, lane, reason, impact, environment, age/state, primary next action | proof links, evidence detail, review context, decision history, technical diagnostics | Primary because it is the central operator queue | follows `what needs my attention now?` before `what does this record contain?` | removes cross-page reconstruction |
|
||||
| Decision Register | Secondary Context | Operator verifies historical decision/proof truth for a selected governance item | decision ledger and proof context | deeper decision history and related proof | Secondary because it is the ledger, not the queue | complements the inbox without replacing it | keeps history visible without flattening queue work |
|
||||
| Diagnostics disclosure | Tertiary Evidence / Diagnostics | Support or operator confirms technical detail after choosing a path | collapsed availability only | raw/source diagnostics where authorized | Not primary | preserves support depth without dominating queue hierarchy | prevents debug-first experience |
|
||||
|
||||
## Audience-Aware Disclosure
|
||||
|
||||
| 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 | operator-MSP, manager, support where authorized | lane, reason, impact, source, environment, linked context, primary next action | blocked reason, proof path, review/evidence/decision linkage | raw payloads, debug metadata, secrets, stack traces, low-level provider diagnostics | context-aware navigation action | diagnostics and raw detail | summary tells the operator once what matters; later sections add proof only |
|
||||
| Decision / proof context inside inbox | operator-MSP, support where authorized | customer-safe and operator-safe proof summary | linked review/evidence/operation detail | raw payloads and internal-only support detail | one dominant open/review action | raw detail and unauthorized links | no repeated queue-level summary in every linked section |
|
||||
|
||||
## UI/UX Surface Classification
|
||||
|
||||
| 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 | Utility / Workspace Decision | Read-only workflow hub | review or clear the next governance item | explicit primary action per item or section | forbidden unless implementation proves a native pattern fits cleanly | secondary links beside or below the primary action | none by default | `/admin/governance/inbox` | existing source routes only | workspace shell, visible environment filter, lane, source, state | Governance inbox | why item appears, why it matters, what to do next | none |
|
||||
| Decision Register | Utility / Evidence Ledger | Read-only register | open the related decision/proof context | explicit existing register affordance | current repo-real behavior only | existing secondary proof links | none | `/admin/governance/decisions` | existing decision detail path | workspace shell, visible environment filter, register state | Decision register | historical/proof truth | existing runtime contract remains authoritative |
|
||||
|
||||
## Operator Surface Contract
|
||||
|
||||
| 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 | MSP/workspace operator | choose the next governance item and route into the owning surface | workspace operator queue | What needs my attention, why does it matter, and what should I do next? | operator summary, lanes, reason, impact, source, environment, linked context, next action | raw diagnostics, debug metadata, low-level support detail | queue lane, urgency, evidence state, accepted-risk/exception state, blocked/review-ready/resolved posture | none by default | review finding, open decision register, review exception, open evidence, open review workspace, open operation, open provider readiness | none by default |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: possibly one page-local derived lane/view-model helper only
|
||||
- **New enum/state/reason family?**: no persisted family; lane keys stay derived and local unless current repo truth proves a reused constant set is already available
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: the repo-real Governance Inbox does not yet answer in one calm place whether an item needs triage, decision, risk review, evidence, customer review handoff, or support follow-up
|
||||
- **Existing structure is insufficient because**: the current workbench and family sections still expose families more directly than the operator workflow, and active vs blocked vs recently resolved context is not clearly separated
|
||||
- **Narrowest correct implementation**: derive operator lanes and a top summary over existing inbox sections/items, keep all routing on existing surfaces, and add tests/browser smoke
|
||||
- **Ownership cost**: one lane contract artifact, page-local mapping logic, feature tests, browser smoke, and UI-report update
|
||||
- **Alternative intentionally rejected**: a new governance work engine, persisted inbox-item table, new decision entity, or cross-domain queue framework
|
||||
- **Release truth**: current-release operator workflow closure over existing governance truth
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility shims, legacy query aliases, or new compatibility paths are out of scope unless explicitly required by repo truth. Canonical `environment_id` behavior remains the only supported public filter contract.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature, Browser
|
||||
- **Validation lane(s)**: confidence, browser
|
||||
- **Why this classification and these lanes are sufficient**: Feature tests can prove lane classification, environment scope, empty/blocked/resolved states, and scope-correct link contracts cheaply. One bounded Browser smoke is required because this is a strategic operator surface and first-screen scanability is part of the product promise.
|
||||
- **New or expanded test families**:
|
||||
- `apps/platform/tests/Feature/Governance/Spec346GovernanceInboxOperatorWorkflowTest.php`
|
||||
- `apps/platform/tests/Feature/Navigation/Spec346GovernanceInboxScopeContractTest.php`
|
||||
- `apps/platform/tests/Browser/Spec346GovernanceInboxOperatorWorkflowSmokeTest.php`
|
||||
- focused updates to existing Governance Inbox and workspace-hub filter tests only when reuse is more proportional than new files
|
||||
- **Fixture / helper cost impact**: moderate. Reuse existing governance inbox fixtures and current factories for findings, finding exceptions, review follow-up, and operation proof. Avoid broad suite-wide fixture widening.
|
||||
- **Heavy-family visibility / justification**: one explicit Browser smoke only
|
||||
- **Special surface test profile**: `global-context-shell` plus decision-first disclosure
|
||||
- **Standard-native relief or required special coverage**: special coverage required for summary-first hierarchy, lane grouping, blocked/empty states, environment filter visibility, and downstream link continuity
|
||||
- **Reviewer handoff**: reviewers must confirm no hidden environment context, no forbidden query aliases, no new mutation lane, no false calmness, diagnostics-secondary ordering, and unchanged panel-provider/global-search/destructive-action posture
|
||||
- **Budget / baseline / trend impact**: one explicit browser smoke addition; no broader lane expansion expected
|
||||
- **Escalation needed**: `document-in-feature` if an unreachable state cannot be proven without scope creep; `follow-up-spec` if the page reveals missing backend truth rather than a UI/productization gap
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Governance/Spec346GovernanceInboxOperatorWorkflowTest.php tests/Feature/Navigation/Spec346GovernanceInboxScopeContractTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec346GovernanceInboxOperatorWorkflowSmokeTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
|
||||
- `git diff --check`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
TenantPilot already has substantial governance building blocks:
|
||||
|
||||
- findings
|
||||
- finding exceptions / accepted-risk truth
|
||||
- evidence snapshots
|
||||
- review packs and review workspace surfaces
|
||||
- Decision Register
|
||||
- operation proof
|
||||
- workspace/environment filters
|
||||
- audit records
|
||||
|
||||
The remaining product gap is not another backend foundation. It is the daily operator question:
|
||||
|
||||
`What needs my attention, why does it matter, and what should I do next?`
|
||||
|
||||
Governance Inbox must become the calm operator queue that connects current truth into actionable items without inventing a second governance system.
|
||||
|
||||
## Product Intent
|
||||
|
||||
Governance Inbox is the operator command surface for governance work.
|
||||
|
||||
It is:
|
||||
|
||||
- not a raw table
|
||||
- not a customer portal
|
||||
- not a legal/compliance approval system
|
||||
- not a new GRC engine
|
||||
|
||||
It is the workspace-owned queue that groups existing governance signals into clear next actions.
|
||||
|
||||
## Current Repo Truth Summary
|
||||
|
||||
- The current page already has:
|
||||
- a decision workbench
|
||||
- a secondary queue context
|
||||
- visible `environment_id` filtering
|
||||
- existing navigation and proof links
|
||||
- current tests and browser smoke from Spec 327
|
||||
- The current builder currently groups by source family, not final operator workflow lane.
|
||||
- `DecisionRegister` already exists and should stay the ledger/proof context, not become the queue.
|
||||
- Existing page audit `ui-004-governance-inbox.md` already states the unresolved issues this spec should close:
|
||||
1. one dominant queue-clearing action model
|
||||
2. clearer separation of decision evidence and status dimensions
|
||||
3. customer-safe downstream wording review
|
||||
|
||||
## Goals
|
||||
|
||||
1. Make Governance Inbox action-oriented.
|
||||
2. Group active work into clear operator lanes.
|
||||
3. Keep workspace/environment scope explicit and local.
|
||||
4. Reuse existing findings, exception, evidence, review, decision, and operation surfaces.
|
||||
5. Make decision/risk/evidence/review states visible without opening three other pages first.
|
||||
6. Preserve calm, operator-safe, non-legalistic wording.
|
||||
7. Add focused Feature and Browser proof for operator workflow behavior.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- existing Governance Inbox page, view, and derived payload composition
|
||||
- lane grouping over current repo-real governance items
|
||||
- operator summary first
|
||||
- visible `environment_id` filter state
|
||||
- scope-correct links into existing findings, decision, evidence, review, operation, and readiness surfaces where repo-backed
|
||||
- productized empty, blocked, and recently resolved treatment
|
||||
- targeted tests, browser smoke, screenshots, and updated page-report coverage
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- new persisted queue state or workflow engine
|
||||
- a new decision table or Decision Register rewrite
|
||||
- customer portal or external customer routes
|
||||
- PSA/ITSM handoff
|
||||
- provider execution logic or provider readiness redesign
|
||||
- broad shell/navigation rewrite
|
||||
- review/evidence generation rewrite
|
||||
- new legal approval/signature workflow
|
||||
- broad notification platform work
|
||||
- AI prioritization or autonomous governance behavior
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- **FR-346-001 Summary first**: Governance Inbox must start with an operator summary that shows total visible governance work plus counts for the active lane model in the current scope.
|
||||
- **FR-346-002 Lane grouping**: Governance Inbox must group active work into derived operator lanes instead of exposing source families alone as the primary hierarchy.
|
||||
- **FR-346-003 Minimal truthful lane set**: The implementation must ship the smallest truthful lane set over existing runtime data. `Needs triage`, `Requires decision`, `Risk / exception review`, and `Blocked` are the minimum target lanes. `Evidence required`, `Review-ready`, and `Recently resolved` must appear only when current repo truth can derive them without inventing new persistence or state families.
|
||||
- **FR-346-004 Reason / impact / next action**: Every visible active item must show why it appears, why it matters, and one primary next action.
|
||||
- **FR-346-005 Visible local environment filter**: `/admin/governance/inbox?environment_id={environment}` must remain the visible public scope contract. No hidden topbar/session-only environment filtering is allowed.
|
||||
- **FR-346-006 Scope-correct links**: Environment-owned detail links must preserve current scope rules and must not emit retired public query contracts such as `tenant`, `tenant_id`, `managed_environment_id`, or `tableFilters`.
|
||||
- **FR-346-007 Empty state**: The empty state must explain that there are no governance items needing attention in the current scope and must not look like missing data.
|
||||
- **FR-346-008 Blocked state**: Blocked items must show blocker reason and a truthful next action into the owning proof/readiness surface when available.
|
||||
- **FR-346-009 Resolved items stay secondary**: recently resolved or acknowledged work may appear, but must not dominate active operator workload.
|
||||
- **FR-346-010 Decision Register linkage**: items requiring decision must link to the existing Decision Register or existing decision-owning detail surfaces instead of inventing a new decision workflow.
|
||||
- **FR-346-011 Review linkage**: review-ready or customer-safe follow-up items must link to current review surfaces using the visible `environment_id` contract when applicable.
|
||||
- **FR-346-012 Diagnostics secondary**: technical diagnostics must remain secondary, collapsed, or otherwise de-emphasized relative to the queue-clearing decision hierarchy.
|
||||
- **FR-346-013 Render contract hardening**: every rendered lane, badge/count, action, source entry, and link payload must expose consistent keys before Blade consumes it. Missing action URLs must become absent actions, not partial arrays.
|
||||
- **FR-346-014 First viewport recommended action**: when active work exists, the first viewport must expose a prioritized recommended item with a direct primary action, not only a summary-level lane CTA.
|
||||
- **FR-346-015 Zero-lane weight reduction**: lanes with zero items must be visually secondary as compact clear indicators or equivalent compact status, while active lanes remain prominent.
|
||||
- **FR-346-016 Mobile card compression**: active item cards must keep title, lane/status, environment, reason, impact, and primary action visible; source, owner/due, evidence, accepted-risk/decision, linked records, and secondary actions must be secondary or collapsible.
|
||||
- **FR-346-017 Hash lane behavior**: if the UI emits `#lane-*` links, the target lane must scroll/anchor correctly or be visibly marked. Unsupported hash behavior must be removed or deferred.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **NFR-346-001 Enterprise calmness**: first screen must be scannable and must not present equal-weight cards, repeated green success states, or raw diagnostic blocks as the primary story
|
||||
- **NFR-346-002 Operator-first language**: use operator-safe language such as `review`, `decision recorded`, `accepted risk`, `blocked`, `requires attention`, and `ready for review`; avoid legal/compliance overclaims
|
||||
- **NFR-346-003 Performance**: no unbounded workspace scan. Counts and lane previews must remain bounded and eager-load only what the current view needs
|
||||
- **NFR-346-004 Accessibility**: semantic lane headings, text-backed badges, keyboard-reachable actions, readable empty states, and non-color-only status cues
|
||||
- **NFR-346-005 No hidden scope**: no remembered environment or hidden shell state may silently alter page data
|
||||
|
||||
## Assumptions
|
||||
|
||||
- current Governance Inbox section items contain enough repo-backed data to derive a calm operator-lane contract without new persistence
|
||||
- Decision Register remains the correct historical/proof surface and does not need new lifecycle semantics for this slice
|
||||
- current review/evidence/run links are sufficient for truthful handoff when they exist, and omission/unavailable states are acceptable when they do not
|
||||
- current page-report and route-inventory entries are stable enough that updating `ui-004-governance-inbox.md` is more truthful than inventing a new page ID
|
||||
|
||||
## Risks
|
||||
|
||||
- **Scope creep into a task engine**: mitigated by explicit prohibition on new persistence/frameworks
|
||||
- **Overconfident lane semantics**: mitigated by repo-truth-first lane-classification artifact and conservative omission of unsupported lanes
|
||||
- **Noisy first screen**: mitigated by summary-first hierarchy, bounded previews, and collapsed diagnostics
|
||||
- **Link drift**: mitigated by reusing current navigation/filter contracts and rerunning existing workspace-hub tests
|
||||
- **Completed-spec churn**: mitigated by explicit guardrail that related completed specs remain untouched
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **AC-001**: `/admin/governance/inbox` reads as the central operator queue, not only as a family queue/table
|
||||
- **AC-002**: the first major section is the operator summary
|
||||
- **AC-003**: items are grouped into clear operator lanes backed by repo truth
|
||||
- **AC-004**: active items show reason, impact, source/environment context, and one primary next action
|
||||
- **AC-005**: workspace-owned scope is preserved and `environment_id` remains visible and local
|
||||
- **AC-006**: downstream links are scope-correct and do not emit forbidden public query aliases
|
||||
- **AC-007**: empty and blocked states are productized and calm
|
||||
- **AC-008**: Decision Register and Customer Review Workspace are linked, not rebuilt
|
||||
- **AC-009**: focused Feature tests, Browser smoke, `pint --dirty`, and `git diff --check` are part of the implementation plan
|
||||
- **AC-010**: no customer portal, no PSA handoff, no new governance engine, and no shell rewrite are introduced
|
||||
- **AC-011**: the `Undefined array key "label"` failure is fixed by normalized ViewModel payload contracts, not only by defensive Blade checks
|
||||
- **AC-012**: active work pages show `Next recommended action` with a direct item-level CTA in the first viewport
|
||||
- **AC-013**: zero-count lanes are secondary clear chips/status, and item cards keep secondary detail collapsed on mobile
|
||||
- **AC-014**: emitted lane hashes scroll to or visibly mark the lane in browser smoke coverage
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
- Evidence and retained artifact lifecycle follow-through
|
||||
- Provider readiness / onboarding productization
|
||||
- Localization and customer-safe copy hardening
|
||||
- Sellable smoke matrix
|
||||
- PSA / ITSM handoff
|
||||
|
||||
## Open Questions
|
||||
|
||||
These do not block safe preparation, but implementation must resolve them from repo truth:
|
||||
|
||||
1. Should `alert_delivery_failures` and `stale_operations` remain first-class queue lanes, or should they be folded into a broader `Blocked` operator lane with source-family tags?
|
||||
2. Can `Recently resolved` be derived honestly from current runtime timestamps/state, or should that lane be deferred until the repo truth proves a stable bounded rule?
|
||||
3. Is `Review-ready` already derivable from current review/evidence/review-pack truth on the inbox page, or should it remain a linked supporting state rather than a first-class lane in v1?
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See the governance work that matters first (Priority: P1)
|
||||
|
||||
As an MSP/operator user, I want Governance Inbox to show a summary first and group active work into operator lanes so I can decide what to clear next without reconstructing context across multiple pages.
|
||||
|
||||
**Why this priority**: This is the central operator workflow gap identified by Spec 345.
|
||||
|
||||
**Independent Test**: Seed visible findings, exceptions, blocked proof, and review follow-up states; open Governance Inbox; verify summary-first hierarchy, lane headings, and one dominant next action per visible item.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** visible governance attention exists, **When** the operator opens `/admin/governance/inbox`, **Then** the summary appears before secondary queue context and the page groups work by operator lanes.
|
||||
2. **Given** a visible active item exists, **When** the operator reads the lane item, **Then** the item shows reason, impact, source/environment context, and one primary next action.
|
||||
3. **Given** no visible governance work exists, **When** the operator opens the page, **Then** the page shows a calm empty state and does not imply hidden work exists elsewhere.
|
||||
|
||||
### User Story 2 - Keep environment scope explicit and safe (Priority: P1)
|
||||
|
||||
As a workspace operator, I want Governance Inbox to remain workspace-owned while using a visible `environment_id` filter so filtered and clean states stay predictable and leak-free.
|
||||
|
||||
**Why this priority**: Specs 338-341 make this a hard workspace hub contract.
|
||||
|
||||
**Independent Test**: Open clean and filtered URLs, clear the filter, reload, and try retired query aliases plus cross-workspace `environment_id` values.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the clean inbox URL, **When** the page loads, **Then** no hidden environment filter appears from remembered shell/session state.
|
||||
2. **Given** an entitled `?environment_id=` filter, **When** the page loads, **Then** the visible environment chip appears and only scoped data is shown.
|
||||
3. **Given** a cross-workspace or retired query alias input, **When** the page loads, **Then** the request remains safe and no unsupported filter contract is applied.
|
||||
|
||||
### User Story 3 - Route into the right downstream truth (Priority: P2)
|
||||
|
||||
As an operator, I want each governance item to lead me to the correct existing detail or proof surface so the inbox helps me finish work instead of duplicating it.
|
||||
|
||||
**Why this priority**: the inbox must stay a queue, not another ownership surface.
|
||||
|
||||
**Independent Test**: Click primary actions for representative decision, risk, review, evidence, and blocked items and verify canonical scope-correct targets.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an item requires decision, **When** the operator chooses its primary action, **Then** the target is the existing Decision Register or existing decision-owning surface.
|
||||
2. **Given** an item is blocked on proof or provider state, **When** the operator chooses its primary action, **Then** the target is the current operation/proof/readiness surface and the blocker is explicit.
|
||||
3. **Given** review-ready or customer-safe follow-up exists, **When** the operator chooses its primary action, **Then** the target is the existing review workspace or related review surface using `environment_id` where appropriate.
|
||||
135
specs/346-governance-inbox-final-operator-workflow/tasks.md
Normal file
135
specs/346-governance-inbox-final-operator-workflow/tasks.md
Normal file
@ -0,0 +1,135 @@
|
||||
# Tasks: Spec 346 - Governance Inbox Final Operator Workflow
|
||||
|
||||
**Input**: Design documents from `/specs/346-governance-inbox-final-operator-workflow/`
|
||||
**Prerequisites**: `spec.md`, `plan.md`, `repo-truth-map.md`
|
||||
|
||||
**Tests**: Required. This is a runtime UI/operator workflow productization change on an existing Filament page with browser smoke coverage.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane classification is named and bounded to the changed behavior.
|
||||
- [x] New or changed tests stay in the smallest honest family, and the browser addition is explicit.
|
||||
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
|
||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [x] The declared surface test profile (`global-context-shell` plus operator workflow disclosure) is explicit.
|
||||
- [x] Any material escalation, deferred truth, or residual gap is recorded in the spec package or final report.
|
||||
|
||||
## Phase 1: Preparation And Repo Truth
|
||||
|
||||
**Purpose**: Confirm current runtime truth and keep the implementation bounded to the existing Governance Inbox surface.
|
||||
|
||||
- [x] T001 Re-read `specs/346-governance-inbox-final-operator-workflow/spec.md` and `plan.md`.
|
||||
- [x] T002 Re-read related completed context only: Specs 250, 257, 265, 306, 307, 308, 327, 342, 343, 344, and 345. Do not modify their artifacts.
|
||||
- [x] T003 Verify current `GovernanceInbox` route/class/view/builder and existing tests before editing.
|
||||
- [x] T004 Create `specs/346-governance-inbox-final-operator-workflow/repo-truth-map.md` documenting current families, payload shape, scope contract, and repo-real gaps.
|
||||
- [x] T005 Confirm no migration, package, env, queue, scheduler, storage, or deployment asset change is required.
|
||||
- [x] T006 Confirm Filament v5 / Livewire v4.0+ compliance and avoid legacy Filament or Livewire APIs.
|
||||
- [x] T007 Confirm panel provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||
- [x] T008 Confirm no Governance Inbox resource/global-search posture changes are required.
|
||||
|
||||
## Phase 2: Tests First
|
||||
|
||||
**Purpose**: Lock the operator summary, lane grouping, scope contract, and blocked/empty/resolved behavior before refactoring the page.
|
||||
|
||||
- [x] T009 Add `apps/platform/tests/Feature/Governance/Spec346GovernanceInboxOperatorWorkflowTest.php`.
|
||||
- [x] T010 Add `apps/platform/tests/Feature/Navigation/Spec346GovernanceInboxScopeContractTest.php`.
|
||||
- [x] T011 Add `apps/platform/tests/Browser/Spec346GovernanceInboxOperatorWorkflowSmokeTest.php`.
|
||||
- [x] T012 Add feature assertions for summary-first hierarchy and operator lane headings.
|
||||
- [x] T013 Add feature assertions that items render reason, impact, source, environment, and next action.
|
||||
- [x] T014 Add feature assertions for the visible `environment_id` contract and filtered state.
|
||||
- [x] T015 Add feature assertions for productized empty and blocked states.
|
||||
- [x] T016 Add feature assertions that resolved items stay secondary.
|
||||
- [x] T017 Add navigation assertions for canonical `environment_id` links and rejection of retired query keys.
|
||||
- [x] T018 Update existing Governance Inbox tests only where the old page hierarchy was intentionally replaced.
|
||||
|
||||
## Phase 3: Lane Classification Contract
|
||||
|
||||
**Purpose**: Define the smallest truthful operator-lane mapping over existing inbox families.
|
||||
|
||||
- [x] T019 Create `specs/346-governance-inbox-final-operator-workflow/contracts/lane-classification.md`.
|
||||
- [x] T020 Map `intake_findings` into `Needs triage`.
|
||||
- [x] T021 Map decision-oriented active work into `Requires decision`.
|
||||
- [x] T022 Map exception-driven items into `Risk / exception review`.
|
||||
- [x] T023 Map evidence-gap states into `Evidence required` only where repo truth supports it.
|
||||
- [x] T024 Map stale operations and alert-delivery failures into `Blocked`.
|
||||
- [x] T025 Omit `Review-ready` as a primary lane because current repo truth does not support a clean, honest derived lane on this page.
|
||||
- [x] T026 Keep `Recently resolved` secondary and sourced from existing decision history rather than new persisted inbox state.
|
||||
|
||||
## Phase 4: Page Productization
|
||||
|
||||
**Purpose**: Turn the current page into a calm, summary-first operator workflow without creating a new governance engine.
|
||||
|
||||
- [x] T027 Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` to expose operator summary, lane groups, recently resolved context, and secondary diagnostics.
|
||||
- [x] T028 Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` so the summary is the first major section.
|
||||
- [x] T029 Render clear lane sections for active work instead of leading with source-family context.
|
||||
- [x] T030 Ensure each visible item shows reason, impact, source, environment, and one dominant next action.
|
||||
- [x] T031 Keep source detail, filters, and diagnostics present but secondary or collapsed by default.
|
||||
- [x] T032 Keep the page read-first; do not add a new mutating workflow surface.
|
||||
|
||||
## Phase 5: Links, Scope, And Safety
|
||||
|
||||
**Purpose**: Preserve the workspace hub contract and route operators into existing surfaces without legacy query leakage.
|
||||
|
||||
- [x] T033 Keep `/admin/governance/inbox` workspace-owned with visible local `environment_id` state.
|
||||
- [x] T034 Ensure environment-scoped destinations preserve canonical workspace/environment routing.
|
||||
- [x] T035 Update finding-exception links to stop emitting the retired `tenant` query key.
|
||||
- [x] T036 Update alert-delivery links to stop emitting `tableFilters` in the public scope contract.
|
||||
- [x] T037 Preserve scope-correct links to existing findings, decisions, evidence, reviews, operations, and provider/readiness destinations where repo-backed.
|
||||
- [x] T038 Do not add approve/certify/sign-off style semantics or new destructive actions.
|
||||
|
||||
## Phase 6: Audit And Spec Artifacts
|
||||
|
||||
**Purpose**: Keep the productization package and UI audit aligned to the runtime diff.
|
||||
|
||||
- [x] T039 Update `docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md` with the summary-first hierarchy, lane model, scope contract, and deferred truths.
|
||||
- [x] T040 Keep the existing Governance Inbox page report identity (`ui-004`) instead of inventing a new page-report family.
|
||||
- [x] T041 Record repo-truth limitations and deliberate omissions in the Spec 346 artifacts.
|
||||
|
||||
## Phase 7: Browser Smoke And Validation
|
||||
|
||||
**Purpose**: Prove the first-screen operator workflow and preserve existing scope contracts.
|
||||
|
||||
- [x] T042 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Governance/Spec346GovernanceInboxOperatorWorkflowTest.php tests/Feature/Navigation/Spec346GovernanceInboxScopeContractTest.php --compact`.
|
||||
- [x] T043 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec346GovernanceInboxOperatorWorkflowSmokeTest.php --compact`.
|
||||
- [x] T044 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php --compact`.
|
||||
- [x] T045 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Navigation/WorkspaceHubRegistryTest.php --compact`.
|
||||
- [x] T046 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Governance/GovernanceInboxPageTest.php --compact`.
|
||||
- [x] T047 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php --compact`.
|
||||
- [x] T048 Run `cd apps/platform && ./vendor/bin/sail pint --dirty`.
|
||||
- [x] T049 Run `git diff --check`.
|
||||
- [x] T050 Save browser screenshots under `specs/346-governance-inbox-final-operator-workflow/artifacts/screenshots/` when generated by the smoke flow.
|
||||
- [x] T051 Report full-suite status honestly if not run.
|
||||
|
||||
## Phase 8: Bounded Density / Productization Polish
|
||||
|
||||
**Purpose**: Finish the bounded first-viewport and scanability polish without introducing new domain state or closing the spec prematurely.
|
||||
|
||||
- [x] T052 Harden the Governance Inbox ViewModel/array contract so lanes, actions, badges/counts, source entries, and links expose consistent keys before Blade renders them.
|
||||
- [x] T053 Replace the indirect summary CTA with a prioritized `Next recommended action` item and direct primary action in the first viewport.
|
||||
- [x] T054 Demote zero-count lanes into compact `Clear` chips/status while keeping active lanes prominent.
|
||||
- [x] T055 Compress active item cards for mobile and move source, owner/due, evidence, accepted-risk/decision, linked records, and secondary actions behind `More context`.
|
||||
- [x] T056 Reduce blocked-lane repetition by using the compact card/detail-disclosure pattern instead of repeated always-visible detail boxes.
|
||||
- [x] T057 Verify emitted `#lane-*` anchors scroll to or visibly mark their lane in browser smoke coverage.
|
||||
- [x] T058 Update focused Feature and Browser tests for contract hardening, first-viewport top action, zero-lane chips, mobile density, and hash navigation.
|
||||
- [x] T059 Re-run focused Spec 346 Feature tests and Browser smoke after the `Undefined array key "label"` fix.
|
||||
|
||||
## Non-Goals Checklist
|
||||
|
||||
- [x] NT001 Do not build a new governance engine or persisted inbox-item state.
|
||||
- [x] NT002 Do not rebuild Decision Register.
|
||||
- [x] NT003 Do not rebuild Findings, Accepted Risk lifecycle, Customer Review Workspace, or provider execution logic.
|
||||
- [x] NT004 Do not add customer portal, PSA/ITSM handoff, or broad notifications.
|
||||
- [x] NT005 Do not add legal/compliance approval semantics.
|
||||
- [x] NT006 Do not add migrations, packages, env vars, queues, scheduler changes, or deployment asset work.
|
||||
- [x] NT007 Do not reintroduce legacy public scope keys such as `tenant`, `tenant_id`, `managed_environment_id`, or `tableFilters`.
|
||||
- [x] NT008 Do not close Spec 346 as part of the bounded polish.
|
||||
|
||||
## Implementation Status Notes - Not Closed
|
||||
|
||||
- Spec 346 stayed bounded to the existing Governance Inbox page, its section builder, linked surfaces, focused tests, and the spec/audit artifacts.
|
||||
- The final page is summary-first and lane-based, while source detail and diagnostics remain secondary disclosure.
|
||||
- The density/productization polish keeps the same repo-backed data, adds no domain model or migration, and hardens the rendered payload contract before Blade consumes it.
|
||||
- `Review-ready` was deliberately not invented as a first-class lane because current page truth does not support it honestly without adding new workflow logic or persistence.
|
||||
- Existing page/browser tests that encoded the older workbench framing were updated to the new operator-workflow hierarchy because the changed behavior is intentional and in scope.
|
||||
- No new mutating governance action, asset registration, migration, env var, queue, scheduler, or storage change was introduced.
|
||||
- Spec 346 is intentionally not closed in this pass; close-out remains a separate decision after review.
|
||||
Loading…
Reference in New Issue
Block a user