feat: productize governance inbox decision-first workbench
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m42s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m42s
This commit is contained in:
parent
c8224843b3
commit
b1962ece80
@ -42,12 +42,17 @@ class GovernanceInbox extends Page
|
||||
|
||||
protected static ?int $navigationSort = 5;
|
||||
|
||||
protected static ?string $title = 'Governance inbox';
|
||||
protected static ?string $title = 'Governance Inbox';
|
||||
|
||||
protected static ?string $slug = 'governance/inbox';
|
||||
|
||||
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
|
||||
*/
|
||||
@ -73,6 +78,11 @@ class GovernanceInbox extends Page
|
||||
*/
|
||||
private ?array $unfilteredInboxPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $decisionWorkbench = null;
|
||||
|
||||
private ?Workspace $workspace = null;
|
||||
|
||||
private ?bool $visibleAlertsFamily = null;
|
||||
@ -140,6 +150,40 @@ public function sections(): array
|
||||
return $this->inboxPayload()['sections'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}
|
||||
* }
|
||||
*/
|
||||
public function decisionWorkbench(): array
|
||||
{
|
||||
if (is_array($this->decisionWorkbench)) {
|
||||
return $this->decisionWorkbench;
|
||||
}
|
||||
|
||||
$entries = $this->workbenchEntries();
|
||||
$selectedItem = $entries
|
||||
->sortBy([
|
||||
fn (array $entry): int => (int) ($entry['urgency_rank'] ?? 999),
|
||||
fn (array $entry): string => (string) ($entry['headline'] ?? ''),
|
||||
])
|
||||
->first();
|
||||
|
||||
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 array<string, mixed>
|
||||
*/
|
||||
@ -155,8 +199,8 @@ public function calmEmptyState(): array
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'No visible governance attention right now',
|
||||
'body' => 'The current workspace scope is calm across the visible governance families.',
|
||||
'title' => 'No governance decisions need attention',
|
||||
'body' => 'The current workspace scope has no repo-backed governance decisions requiring action.',
|
||||
'action_label' => null,
|
||||
'action_url' => null,
|
||||
];
|
||||
@ -201,6 +245,101 @@ public function navigationContext(): CanonicalNavigationContext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function workbenchEntries(): \Illuminate\Support\Collection
|
||||
{
|
||||
return collect($this->sections())
|
||||
->flatMap(function (array $section): array {
|
||||
$entries = is_array($section['entries'] ?? null) ? $section['entries'] : [];
|
||||
|
||||
return array_map(function (array $entry) use ($section): array {
|
||||
$entry['section_key'] = (string) ($section['key'] ?? $entry['family_key'] ?? 'governance');
|
||||
$entry['section_label'] = (string) ($section['label'] ?? 'Governance item');
|
||||
|
||||
return $entry;
|
||||
}, $entries);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeWorkbenchItem(array $item): 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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, array<string, mixed>> $entries
|
||||
* @return list<array{label: string, value: string, description: string}>
|
||||
*/
|
||||
private function summaryCards(\Illuminate\Support\Collection $entries): 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();
|
||||
|
||||
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.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceMembership(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -110,6 +110,10 @@ private static function resolveFromManifest(string $entry): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
if (app()->runningUnitTests() || app()->environment('testing')) {
|
||||
return url('/build/'.$file);
|
||||
}
|
||||
|
||||
return asset('build/'.$file);
|
||||
}
|
||||
}
|
||||
|
||||
@ -568,7 +568,7 @@ private function assignedFindingsQuery(User $user, array $visibleFindingTenants,
|
||||
: array_keys($visibleFindingTenants);
|
||||
|
||||
return Finding::query()
|
||||
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
|
||||
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name', 'findingException'])
|
||||
->withSubjectDisplayName()
|
||||
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->where('assignee_user_id', (int) $user->getKey())
|
||||
@ -597,7 +597,7 @@ private function intakeFindingsQuery(array $visibleFindingTenants, ?ManagedEnvir
|
||||
: array_keys($visibleFindingTenants);
|
||||
|
||||
return Finding::query()
|
||||
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
|
||||
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name', 'findingException'])
|
||||
->withSubjectDisplayName()
|
||||
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->whereNull('assignee_user_id')
|
||||
@ -700,6 +700,8 @@ private function findingExceptionsQuery(Workspace $workspace, array $authorizedT
|
||||
'tenant',
|
||||
'requester:id,name',
|
||||
'owner:id,name',
|
||||
'currentDecision',
|
||||
'evidenceReferences',
|
||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||||
])
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
@ -769,6 +771,27 @@ private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNav
|
||||
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'decision_label' => $familyKey === 'assigned_findings' ? 'Review assigned finding' : 'Triage intake finding',
|
||||
'reason_label' => $this->findingReasonLabel($finding, $familyKey),
|
||||
'impact_label' => $this->findingImpactLabel($finding),
|
||||
'owner_label' => $this->findingOwnerLabel($finding),
|
||||
'owner_state' => $finding->owner_user_id !== null ? 'available' : 'missing',
|
||||
'due_label' => $this->findingDueLabel($finding),
|
||||
'due_state' => $finding->due_at === null ? 'unavailable' : ($finding->due_at->isPast() ? 'overdue' : 'available'),
|
||||
'evidence_label' => $this->findingEvidenceLabel($finding),
|
||||
'evidence_state' => $this->findingEvidenceState($finding),
|
||||
'evidence_path_label' => $this->findingEvidenceState($finding) === 'linked' ? 'Open finding evidence' : 'Source record only',
|
||||
'evidence_path_url' => $this->appendQuery(
|
||||
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'exception_label' => $this->findingExceptionLabel($finding),
|
||||
'exception_state' => $finding->findingException instanceof FindingException ? 'available' : 'none',
|
||||
'primary_action_label' => $familyKey === 'assigned_findings' ? 'Review finding' : 'Triage finding',
|
||||
'primary_action_url' => $this->appendQuery(
|
||||
FindingResource::getUrl('view', ['record' => $finding], tenant: $finding->tenant),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
||||
];
|
||||
}
|
||||
@ -795,6 +818,29 @@ private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $
|
||||
? 'Terminal follow-up'
|
||||
: 'Stale',
|
||||
'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
|
||||
'decision_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||
? 'Review terminal operation follow-up'
|
||||
: 'Review stale operation',
|
||||
'reason_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||
? 'The operation reached a terminal outcome that still needs monitoring follow-up.'
|
||||
: 'The active operation appears stale and needs operator attention.',
|
||||
'impact_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
||||
? 'Terminal operation follow-up'
|
||||
: 'Stale active operation',
|
||||
'owner_label' => 'Owner unavailable',
|
||||
'owner_state' => 'unavailable',
|
||||
'due_label' => $run->completed_at instanceof \DateTimeInterface
|
||||
? 'Completed '.FindingExceptionResource::relativeTimeDescription($run->completed_at)
|
||||
: 'Due date unavailable',
|
||||
'due_state' => 'unavailable',
|
||||
'evidence_label' => 'Operation proof available',
|
||||
'evidence_state' => 'linked',
|
||||
'evidence_path_label' => OperationRunLinks::identifier($run),
|
||||
'evidence_path_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
|
||||
'exception_label' => 'No accepted-risk state',
|
||||
'exception_state' => 'not_required',
|
||||
'primary_action_label' => 'Open operation proof',
|
||||
'primary_action_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
|
||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
||||
];
|
||||
}
|
||||
@ -809,9 +855,6 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext
|
||||
? (string) $payload['title']
|
||||
: 'Failed alert delivery';
|
||||
$sublineParts = array_values(array_filter([
|
||||
is_string($delivery->last_error_message) && $delivery->last_error_message !== ''
|
||||
? $delivery->last_error_message
|
||||
: null,
|
||||
is_string($delivery->event_type) && $delivery->event_type !== ''
|
||||
? $delivery->event_type
|
||||
: null,
|
||||
@ -831,6 +874,29 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext
|
||||
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'decision_label' => 'Review failed alert delivery',
|
||||
'reason_label' => 'An alert delivery attempt failed; source diagnostics remain on the alert delivery record.',
|
||||
'impact_label' => is_string($delivery->event_type) && $delivery->event_type !== ''
|
||||
? 'Alert event: '.$delivery->event_type
|
||||
: 'Alert delivery follow-up',
|
||||
'owner_label' => 'Owner unavailable',
|
||||
'owner_state' => 'unavailable',
|
||||
'due_label' => 'Due date unavailable',
|
||||
'due_state' => 'unavailable',
|
||||
'evidence_label' => 'Evidence not required',
|
||||
'evidence_state' => 'not_required',
|
||||
'evidence_path_label' => 'Alert delivery record',
|
||||
'evidence_path_url' => $this->appendQuery(
|
||||
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'exception_label' => 'No accepted-risk state',
|
||||
'exception_state' => 'not_required',
|
||||
'primary_action_label' => 'Open alert delivery',
|
||||
'primary_action_url' => $this->appendQuery(
|
||||
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
||||
];
|
||||
}
|
||||
@ -877,6 +943,45 @@ private function findingExceptionEntry(FindingException $exception, ?CanonicalNa
|
||||
),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'decision_label' => 'Review accepted-risk decision',
|
||||
'reason_label' => is_string($exception->request_reason) && trim($exception->request_reason) !== ''
|
||||
? trim($exception->request_reason)
|
||||
: 'This accepted-risk or exception record needs review.',
|
||||
'impact_label' => $this->findingExceptionImpactLabel($exception),
|
||||
'owner_label' => $exception->owner?->name ?? 'Owner missing',
|
||||
'owner_state' => $exception->owner?->name !== null ? 'available' : 'missing',
|
||||
'due_label' => $this->findingExceptionDueLabel($exception),
|
||||
'due_state' => $exception->review_due_at === null && $exception->expires_at === null
|
||||
? 'unavailable'
|
||||
: (($exception->review_due_at?->isPast() === true || $exception->expires_at?->isPast() === true) ? 'overdue' : 'available'),
|
||||
'evidence_label' => $this->findingExceptionEvidenceLabel($exception),
|
||||
'evidence_state' => $this->findingExceptionEvidenceState($exception),
|
||||
'evidence_path_label' => $this->findingExceptionEvidenceState($exception) === 'linked'
|
||||
? 'Open exception proof'
|
||||
: 'Source record only',
|
||||
'evidence_path_url' => $this->appendQuery(
|
||||
FindingExceptionsQueue::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant' => $exception->tenant?->external_id,
|
||||
'exception' => (int) $exception->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'exception_label' => $this->findingExceptionDecisionStateLabel($exception),
|
||||
'exception_state' => 'available',
|
||||
'primary_action_label' => 'Review accepted risk',
|
||||
'primary_action_url' => $this->appendQuery(
|
||||
FindingExceptionsQueue::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant' => $exception->tenant?->external_id,
|
||||
'exception' => (int) $exception->getKey(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
),
|
||||
$navigationContext?->toQuery() ?? [],
|
||||
),
|
||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
||||
];
|
||||
}
|
||||
@ -924,6 +1029,27 @@ private function reviewEntry(
|
||||
? 'Follow-up needed'
|
||||
: 'Changed since review',
|
||||
'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
|
||||
'decision_label' => 'Review customer workspace follow-up',
|
||||
'reason_label' => $state === ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED
|
||||
? 'The latest review state asks for follow-up before this governance concern is closed.'
|
||||
: 'This review concern changed since the latest reviewed state.',
|
||||
'impact_label' => $familyLabel.' review follow-up',
|
||||
'owner_label' => is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
|
||||
? 'Last reviewer: '.$row['reviewed_by_user_name']
|
||||
: 'Owner unavailable',
|
||||
'owner_state' => is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
|
||||
? 'available'
|
||||
: 'unavailable',
|
||||
'due_label' => 'Due date unavailable',
|
||||
'due_state' => 'unavailable',
|
||||
'evidence_label' => 'Review evidence path available',
|
||||
'evidence_state' => 'linked',
|
||||
'evidence_path_label' => 'Open review context',
|
||||
'evidence_path_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
|
||||
'exception_label' => 'Accepted-risk state unavailable',
|
||||
'exception_state' => 'unavailable',
|
||||
'primary_action_label' => 'Open review context',
|
||||
'primary_action_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
|
||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
||||
];
|
||||
}
|
||||
@ -1042,6 +1168,148 @@ private function findingExceptionStatusLabel(FindingException $exception): strin
|
||||
return Str::of((string) $exception->status)->replace('_', ' ')->title()->value();
|
||||
}
|
||||
|
||||
private function findingReasonLabel(Finding $finding, string $familyKey): string
|
||||
{
|
||||
if ($finding->due_at?->isPast() === true) {
|
||||
return 'The finding is overdue and still open in the current governance scope.';
|
||||
}
|
||||
|
||||
if ($finding->reopened_at !== null) {
|
||||
return 'The finding reopened after a previous resolution path.';
|
||||
}
|
||||
|
||||
if ($familyKey === 'intake_findings') {
|
||||
return 'The finding is unassigned and still needs first triage.';
|
||||
}
|
||||
|
||||
return 'The finding remains assigned and needs follow-up before it can be cleared.';
|
||||
}
|
||||
|
||||
private function findingImpactLabel(Finding $finding): string
|
||||
{
|
||||
$severity = is_string($finding->severity) && $finding->severity !== ''
|
||||
? Str::of($finding->severity)->replace('_', ' ')->title()->value()
|
||||
: 'Severity unavailable';
|
||||
$type = is_string($finding->finding_type) && $finding->finding_type !== ''
|
||||
? Str::of($finding->finding_type)->replace('_', ' ')->lower()->value()
|
||||
: 'finding';
|
||||
|
||||
return $severity.' '.$type;
|
||||
}
|
||||
|
||||
private function findingOwnerLabel(Finding $finding): string
|
||||
{
|
||||
if ($finding->ownerUser?->name !== null) {
|
||||
return $finding->ownerUser->name;
|
||||
}
|
||||
|
||||
if ($finding->assigneeUser?->name !== null) {
|
||||
return 'Active assignee: '.$finding->assigneeUser->name;
|
||||
}
|
||||
|
||||
return 'Owner missing';
|
||||
}
|
||||
|
||||
private function findingDueLabel(Finding $finding): string
|
||||
{
|
||||
if (! $finding->due_at instanceof \DateTimeInterface) {
|
||||
return 'Due date unavailable';
|
||||
}
|
||||
|
||||
$relative = FindingExceptionResource::relativeTimeDescription($finding->due_at);
|
||||
|
||||
if ($finding->due_at->isPast()) {
|
||||
return 'Overdue'.($relative !== null ? ': '.$relative : '');
|
||||
}
|
||||
|
||||
return $relative ?? $finding->due_at->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function findingEvidenceState(Finding $finding): string
|
||||
{
|
||||
return is_array($finding->evidence_jsonb) && $finding->evidence_jsonb !== []
|
||||
? 'linked'
|
||||
: 'missing';
|
||||
}
|
||||
|
||||
private function findingEvidenceLabel(Finding $finding): string
|
||||
{
|
||||
return $this->findingEvidenceState($finding) === 'linked'
|
||||
? 'Evidence captured on finding'
|
||||
: 'Evidence missing';
|
||||
}
|
||||
|
||||
private function findingExceptionLabel(Finding $finding): string
|
||||
{
|
||||
$exception = $finding->relationLoaded('findingException') ? $finding->findingException : null;
|
||||
|
||||
if ($exception instanceof FindingException) {
|
||||
return $this->findingExceptionDecisionStateLabel($exception);
|
||||
}
|
||||
|
||||
return 'No accepted risk';
|
||||
}
|
||||
|
||||
private function findingExceptionImpactLabel(FindingException $exception): string
|
||||
{
|
||||
return match ((string) $exception->current_validity_state) {
|
||||
FindingException::VALIDITY_EXPIRING => 'Accepted risk expiring',
|
||||
FindingException::VALIDITY_EXPIRED => 'Accepted risk expired',
|
||||
FindingException::VALIDITY_MISSING_SUPPORT => 'Accepted risk missing support',
|
||||
FindingException::VALIDITY_VALID => 'Accepted risk active',
|
||||
default => 'Exception review required',
|
||||
};
|
||||
}
|
||||
|
||||
private function findingExceptionDueLabel(FindingException $exception): string
|
||||
{
|
||||
$date = $exception->review_due_at ?? $exception->expires_at;
|
||||
|
||||
if (! $date instanceof \DateTimeInterface) {
|
||||
return 'Due date unavailable';
|
||||
}
|
||||
|
||||
$relative = FindingExceptionResource::relativeTimeDescription($date);
|
||||
|
||||
if ($date->isPast()) {
|
||||
return 'Overdue'.($relative !== null ? ': '.$relative : '');
|
||||
}
|
||||
|
||||
return $relative ?? $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function findingExceptionEvidenceState(FindingException $exception): string
|
||||
{
|
||||
$summaryCount = data_get($exception->evidence_summary, 'reference_count');
|
||||
$referenceCount = is_numeric($summaryCount)
|
||||
? (int) $summaryCount
|
||||
: ($exception->relationLoaded('evidenceReferences') ? $exception->evidenceReferences->count() : 0);
|
||||
|
||||
return $referenceCount > 0 ? 'linked' : 'missing';
|
||||
}
|
||||
|
||||
private function findingExceptionEvidenceLabel(FindingException $exception): string
|
||||
{
|
||||
return $this->findingExceptionEvidenceState($exception) === 'linked'
|
||||
? 'Evidence references linked'
|
||||
: 'Evidence missing';
|
||||
}
|
||||
|
||||
private function findingExceptionDecisionStateLabel(FindingException $exception): string
|
||||
{
|
||||
if ((string) $exception->status === FindingException::STATUS_PENDING) {
|
||||
return 'Pending exception';
|
||||
}
|
||||
|
||||
return match ((string) $exception->current_validity_state) {
|
||||
FindingException::VALIDITY_VALID => 'Accepted risk active',
|
||||
FindingException::VALIDITY_EXPIRING => 'Accepted risk expiring',
|
||||
FindingException::VALIDITY_EXPIRED => 'Accepted risk expired',
|
||||
FindingException::VALIDITY_MISSING_SUPPORT => 'Accepted risk missing support',
|
||||
default => Str::of((string) $exception->status)->replace('_', ' ')->title()->value(),
|
||||
};
|
||||
}
|
||||
|
||||
private function reviewSummary(int $followUpCount, int $changedCount): string
|
||||
{
|
||||
$total = $followUpCount + $changedCount;
|
||||
|
||||
@ -3,42 +3,30 @@
|
||||
$scope = $this->appliedScope();
|
||||
$sections = $this->sections();
|
||||
$emptyState = $this->calmEmptyState();
|
||||
$workbench = $this->decisionWorkbench();
|
||||
$selectedItem = $workbench['selected_item'] ?? null;
|
||||
$diagnostics = $workbench['diagnostics'] ?? [];
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
|
||||
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
|
||||
Governance inbox
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
||||
Governance inbox
|
||||
</h1>
|
||||
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
<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-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
<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-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
<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-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
|
||||
<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
|
||||
@ -51,112 +39,291 @@
|
||||
])
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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-full border px-3 py-1.5 text-sm 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' }}"
|
||||
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-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $scope['total_count'] ?? 0 }}</span>
|
||||
<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-full border px-3 py-1.5 text-sm 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' }}"
|
||||
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-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $family['count'] }}</span>
|
||||
<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>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($sections === [])
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4 rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-6 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] }}</h2>
|
||||
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $emptyState['body'] }}</p>
|
||||
<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 (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null))
|
||||
<div>
|
||||
<x-filament::button tag="a" color="gray" href="{{ $emptyState['action_url'] }}">
|
||||
{{ $emptyState['action_label'] }}
|
||||
</x-filament::button>
|
||||
@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>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
@foreach ($sections as $section)
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
@else
|
||||
<div class="mt-5 space-y-5">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $section['label'] }}</h2>
|
||||
<span class="inline-flex items-center rounded-full 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 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>
|
||||
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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' }}
|
||||
</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>
|
||||
</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-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>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
|
||||
</div>
|
||||
|
||||
<x-filament::button tag="a" color="gray" href="{{ $section['dominant_action_url'] }}">
|
||||
{{ $section['dominant_action_label'] }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($section['count'] === 0)
|
||||
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
|
||||
{{ $section['empty_state'] }}
|
||||
</div>
|
||||
@else
|
||||
<ul class="grid gap-3">
|
||||
@foreach ($section['entries'] as $entry)
|
||||
<li class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||
<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.16em] text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['tenant_label'] }}
|
||||
@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-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-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="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>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<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-full 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>
|
||||
@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['subline'] ?? null))
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $entry['subline'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-filament::button tag="a" color="gray" size="sm" href="{{ $entry['destination_url'] }}">
|
||||
Open source
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Models\Finding;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
it('Spec327 smokes non-empty governance inbox decision workbench entry', function (): void {
|
||||
[$user, $environmentA, $environmentB] = spec327GovernanceInboxFixture();
|
||||
spec327AuthenticateGovernanceInboxBrowser($this, $user, $environmentA);
|
||||
|
||||
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(__('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('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($environmentA->name)
|
||||
->assertSee($environmentB->name)
|
||||
->assertDontSee('No governance decisions need attention')
|
||||
->assertDontSee('tenant filter')
|
||||
->assertDontSee('current tenant')
|
||||
->assertDontSee('entitled tenant')
|
||||
->assertDontSee('all tenants')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('stack trace should stay hidden')
|
||||
->assertDontSee('provider secret should stay hidden')
|
||||
->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\"]");
|
||||
|
||||
if (! grid || ! workbench || ! detail) {
|
||||
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;
|
||||
})()', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec327GovernanceInboxScreenshot('governance-inbox--clean'));
|
||||
|
||||
spec327CopyBrowserScreenshot('governance-inbox--clean');
|
||||
spec327CopyBrowserScreenshot('governance-inbox--clean', 'governance-inbox-decision-workbench.png');
|
||||
});
|
||||
|
||||
it('Spec327 smokes filtered governance inbox clear and reload behavior', function (): void {
|
||||
[$user, $environmentA, $environmentB] = spec327GovernanceInboxFixture();
|
||||
$cleanPath = json_encode((string) parse_url(GovernanceInbox::getUrl(panel: 'admin'), PHP_URL_PATH), JSON_THROW_ON_ERROR);
|
||||
spec327AuthenticateGovernanceInboxBrowser($this, $user, $environmentA);
|
||||
|
||||
$page = visit(GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||
'environment_id' => (int) $environmentA->getKey(),
|
||||
]))
|
||||
->waitForText('Environment filter:')
|
||||
->assertSee('Environment filter: '.$environmentA->name)
|
||||
->assertSee('What decision clears the highest-priority item?')
|
||||
->assertSee($environmentA->name)
|
||||
->assertDontSee($environmentB->name)
|
||||
->assertScript('document.querySelector("[data-testid=\"governance-inbox-diagnostics\"]")?.open === false', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec327GovernanceInboxScreenshot('governance-inbox--filtered'));
|
||||
|
||||
spec327CopyBrowserScreenshot('governance-inbox--filtered');
|
||||
|
||||
$page
|
||||
->click('[data-testid="workspace-hub-environment-filter-clear"]')
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
->assertScript('! window.location.search.includes("environment_id=")', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec327GovernanceInboxScreenshot('governance-inbox--after-clear'));
|
||||
|
||||
spec327CopyBrowserScreenshot('governance-inbox--after-clear');
|
||||
|
||||
$page->script('window.location.reload();');
|
||||
|
||||
$page
|
||||
->waitForText(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee($environmentB->name)
|
||||
->assertScript("window.location.pathname === {$cleanPath}", true)
|
||||
->assertScript('! window.location.search.includes("environment_id=")', true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec327GovernanceInboxScreenshot('governance-inbox--after-reload'));
|
||||
|
||||
spec327CopyBrowserScreenshot('governance-inbox--after-reload');
|
||||
});
|
||||
|
||||
it('Spec327 smokes governance inbox diagnostics disclosure and secondary queue', function (): void {
|
||||
[$user, $environmentA] = spec327GovernanceInboxFixture();
|
||||
spec327AuthenticateGovernanceInboxBrowser($this, $user, $environmentA);
|
||||
|
||||
visit(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->waitForText('Queue context')
|
||||
->assertSee('Assigned findings')
|
||||
->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')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('internal exception should stay hidden')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->screenshot(true, spec327GovernanceInboxScreenshot('governance-inbox--diagnostics'));
|
||||
|
||||
spec327CopyBrowserScreenshot('governance-inbox--diagnostics');
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment}
|
||||
*/
|
||||
function spec327GovernanceInboxFixture(): array
|
||||
{
|
||||
$environmentA = ManagedEnvironment::factory()->active()->create([
|
||||
'name' => 'Spec327 Browser Environment A',
|
||||
'external_id' => 'spec327-browser-environment-a',
|
||||
]);
|
||||
|
||||
[$user, $environmentA] = createUserWithTenant(
|
||||
tenant: $environmentA,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
|
||||
$environmentB = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'name' => 'Spec327 Browser Environment B',
|
||||
'external_id' => 'spec327-browser-environment-b',
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $environmentB,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentA)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->ownedBy((int) $user->getKey())
|
||||
->overdueByHours()
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentA->workspace_id,
|
||||
'subject_external_id' => 'spec327-browser-priority-a',
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'evidence_jsonb' => [
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'raw_payload' => 'raw payload should stay hidden',
|
||||
'stack_trace' => 'stack trace should stay hidden',
|
||||
'provider_secret' => 'provider secret should stay hidden',
|
||||
'debug_metadata' => 'debug metadata should stay hidden',
|
||||
'internal_exception' => 'internal exception should stay hidden',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()
|
||||
->for($environmentB)
|
||||
->reopened()
|
||||
->create([
|
||||
'workspace_id' => (int) $environmentB->workspace_id,
|
||||
'subject_external_id' => 'spec327-browser-secondary-b',
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
'due_at' => now()->addDays(14),
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
return [$user, $environmentA, $environmentB];
|
||||
}
|
||||
|
||||
function spec327AuthenticateGovernanceInboxBrowser(
|
||||
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 spec327GovernanceInboxScreenshot(string $name): string
|
||||
{
|
||||
return 'spec327-'.$name;
|
||||
}
|
||||
|
||||
function spec327CopyBrowserScreenshot(string $name, ?string $targetFilename = null): void
|
||||
{
|
||||
$filename = spec327GovernanceInboxScreenshot($name).'.png';
|
||||
$source = \Pest\Browser\Support\Screenshot::path($filename);
|
||||
$targetDirectory = repo_path('specs/327-governance-inbox-decision-first-workbench-productization/artifacts/screenshots');
|
||||
$targetFilename ??= $filename;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,186 @@
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('documents the Spec 327 governance inbox repo truth map', function (): void {
|
||||
$path = repo_path('specs/327-governance-inbox-decision-first-workbench-productization/repo-truth-map.md');
|
||||
|
||||
expect($path)->toBeFile();
|
||||
|
||||
$contents = (string) file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->toContain('Findings')
|
||||
->toContain('Finding Exceptions / Accepted Risks')
|
||||
->toContain('OperationRun links')
|
||||
->toContain('Workspace / Environment filter state')
|
||||
->toContain('Diagnostics');
|
||||
});
|
||||
|
||||
it('renders the Spec 327 decision-first workbench for the highest-priority finding', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Spec327 Environment Alpha',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
Finding::factory()
|
||||
->for($tenant)
|
||||
->assignedTo((int) $user->getKey())
|
||||
->ownedBy((int) $user->getKey())
|
||||
->overdueByHours()
|
||||
->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'subject_external_id' => 'spec327-priority-finding',
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_IN_PROGRESS,
|
||||
'evidence_jsonb' => [
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'raw_payload' => 'raw payload should stay hidden',
|
||||
'debug_metadata' => 'debug metadata should stay hidden',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->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('Reason')
|
||||
->assertSee('Impact')
|
||||
->assertSee('Owner')
|
||||
->assertSee('Due')
|
||||
->assertSee('Evidence')
|
||||
->assertSee('Accepted risk')
|
||||
->assertSee('Review finding')
|
||||
->assertSee('Evidence captured on finding')
|
||||
->assertSee('No accepted risk')
|
||||
->assertSee('Decision summary')
|
||||
->assertSee('Owner / due')
|
||||
->assertSee('Evidence path')
|
||||
->assertSee('Primary next action')
|
||||
->assertSee('Queue context')
|
||||
->assertSee('Assigned findings')
|
||||
->assertDontSee('No governance decisions need attention')
|
||||
->assertDontSee('tenant filter')
|
||||
->assertDontSee('current tenant')
|
||||
->assertDontSee('entitled tenant')
|
||||
->assertDontSee('all tenants')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
->assertDontSee('debug metadata should stay hidden');
|
||||
});
|
||||
|
||||
it('renders a compact empty decision state without primary zero metric cards', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Spec327 Empty Environment',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
||||
|
||||
$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('Collapsed')
|
||||
->assertDontSee('Visible decisions')
|
||||
->assertDontSee('Priority family')
|
||||
->assertDontSee('raw payload')
|
||||
->assertDontSee('stack trace')
|
||||
->assertDontSee('debug metadata')
|
||||
->assertDontSee('provider secret');
|
||||
});
|
||||
|
||||
it('renders honest missing owner due evidence and accepted-risk states', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Spec327 Environment Missing State',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
Finding::factory()
|
||||
->for($tenant)
|
||||
->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'subject_external_id' => 'spec327-missing-state-finding',
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
'due_at' => null,
|
||||
'evidence_jsonb' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Owner missing')
|
||||
->assertSee('Due date unavailable')
|
||||
->assertSee('Evidence missing')
|
||||
->assertSee('No accepted risk')
|
||||
->assertSee('Triage finding');
|
||||
});
|
||||
|
||||
it('renders accepted-risk and exception state without exposing raw diagnostics by default', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Spec327 Environment Exception',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$finding = Finding::factory()
|
||||
->for($tenant)
|
||||
->riskAccepted()
|
||||
->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'subject_external_id' => 'spec327-exception-finding',
|
||||
]);
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => null,
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Exception needs governance evidence',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(GovernanceInbox::getUrl(panel: 'admin').'?family=finding_exceptions')
|
||||
->assertOk()
|
||||
->assertSee('Review accepted-risk decision')
|
||||
->assertSee('Exception needs governance evidence')
|
||||
->assertSee('Pending exception')
|
||||
->assertSee('Evidence missing')
|
||||
->assertSee('Review accepted risk')
|
||||
->assertSee('Diagnostics')
|
||||
->assertSee('Collapsed')
|
||||
->assertDontSee('raw payload')
|
||||
->assertDontSee('stack trace')
|
||||
->assertDontSee('provider secret')
|
||||
->assertDontSee('internal exception')
|
||||
->assertDontSee('debug metadata');
|
||||
});
|
||||
|
||||
it('renders visible governance attention sections on the governance inbox page', function (): void {
|
||||
$alphaTenant = ManagedEnvironment::factory()->create([
|
||||
'status' => 'active',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@ -0,0 +1,50 @@
|
||||
# Requirements Checklist: Spec 327 - Governance Inbox Decision-First Workbench Productization
|
||||
|
||||
**Purpose**: Validate preparation artifact quality before implementation.
|
||||
**Created**: 2026-05-18
|
||||
**Feature**: `specs/327-governance-inbox-decision-first-workbench-productization/spec.md`
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details leak into product requirements beyond required repo constraints.
|
||||
- [x] User value and operator workflow are clear.
|
||||
- [x] Scope is bounded to one existing runtime surface.
|
||||
- [x] Non-goals explicitly prevent backend/workflow overbuild.
|
||||
- [x] Dependencies and historical specs are listed.
|
||||
|
||||
## Repo Truth And Safety
|
||||
|
||||
- [x] Existing route/class/view/builder are named.
|
||||
- [x] Repo truth map exists and uses required classifications.
|
||||
- [x] No new persisted truth is proposed.
|
||||
- [x] No migrations/packages/env/queues/scheduler/storage changes are expected.
|
||||
- [x] No legacy tenant query alias support is allowed.
|
||||
|
||||
## Workspace / Environment Contract
|
||||
|
||||
- [x] Clean workspace-wide entry is specified.
|
||||
- [x] Canonical `environment_id` filter is specified.
|
||||
- [x] Visible chip and clear filter are specified.
|
||||
- [x] Legacy aliases are rejected.
|
||||
- [x] Cross-workspace environment guard is specified.
|
||||
|
||||
## RBAC / Audit / Diagnostics
|
||||
|
||||
- [x] Existing capabilities and policies remain authoritative.
|
||||
- [x] Unauthorized action behavior is specified.
|
||||
- [x] Diagnostics are collapsed/hidden by default.
|
||||
- [x] Dangerous actions are out of scope unless spec/plan are updated.
|
||||
- [x] No raw payloads/provider secrets/debug traces are default-visible.
|
||||
|
||||
## Testability
|
||||
|
||||
- [x] Feature tests are listed.
|
||||
- [x] Browser smoke flows are listed.
|
||||
- [x] Navigation/scope guard tests are listed.
|
||||
- [x] `pint --dirty` and `git diff --check` are listed.
|
||||
- [x] Full-suite status must be reported honestly.
|
||||
|
||||
## Readiness Decision
|
||||
|
||||
- [x] Spec is ready for implementation planning.
|
||||
- [x] No open question blocks a bounded implementation loop.
|
||||
@ -0,0 +1,305 @@
|
||||
# Implementation Plan: Spec 327 - Governance Inbox Decision-First Workbench Productization
|
||||
|
||||
**Branch**: `327-governance-inbox-decision-first-workbench-productization` | **Date**: 2026-05-18 | **Spec**: `specs/327-governance-inbox-decision-first-workbench-productization/spec.md`
|
||||
**Input**: User-provided Spec 327 and repo inspection.
|
||||
|
||||
## Summary
|
||||
|
||||
Productize the existing workspace-scoped Governance Inbox into a decision-first operator workbench. The implementation must keep the current route and source-family truth, introduce no backend foundation, and make the first viewport answer:
|
||||
|
||||
```text
|
||||
What decision clears the highest-priority item?
|
||||
```
|
||||
|
||||
The workbench will elevate selected/highest-priority item detail, reason, impact, owner/due, evidence state, accepted-risk/exception state, and a single next action, while keeping the existing queue/source sections as secondary context and diagnostics collapsed.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12.52.0.
|
||||
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, Tailwind CSS 4.2.2.
|
||||
**Storage**: PostgreSQL; no schema change expected.
|
||||
**Testing**: Pest 4 Feature/Livewire/Browser tests.
|
||||
**Validation Lanes**: confidence and browser; targeted navigation guard tests.
|
||||
**Target Platform**: Laravel Sail locally; Dokploy/container deployment posture unchanged.
|
||||
**Project Type**: Laravel monolith under `apps/platform`.
|
||||
**Performance Goals**: DB-only page render; no Graph calls during render; no extra heavy query family beyond existing inbox source queries unless bounded and eager-loaded.
|
||||
**Constraints**: No new persisted truth, no migration, no packages, no queue/scheduler/storage/env changes, no legacy alias support.
|
||||
**Scale/Scope**: One existing Filament page and its feature-local builder/view/tests.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed existing operator-facing strategic surface.
|
||||
- **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`
|
||||
- **No-impact class, if applicable**: N/A.
|
||||
- **Native vs custom classification summary**: Native Filament page plus existing Blade composition; no new UI framework.
|
||||
- **Shared-family relevance**: status messaging, action links, evidence/proof links, OperationRun links, workspace/environment filter chip, navigation context, diagnostics disclosure.
|
||||
- **State layers in scope**: page payload, URL query (`environment_id`, `family`), selected/highest-priority item state, diagnostics disclosure.
|
||||
- **Audience modes in scope**: operator-MSP, manager, auditor/support reviewer where authorized.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first, proof/evidence second, diagnostics/support raw third.
|
||||
- **Raw/support gating plan**: collapsed by default and capability-gated where existing capabilities support access.
|
||||
- **One-primary-action / duplicate-truth control**: selected/highest-priority item owns one primary next action; queue rows/source links remain secondary.
|
||||
- **Handling modes by drift class or surface**: review-mandatory for UI-028 strategic surface; document-in-feature for any UI coverage registry no-change decision.
|
||||
- **Repository-signal treatment**: Spec 325 target image is visual direction only; runtime claims must be repo-verified or unavailable.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- **Inventory-first, snapshots-second**: N/A, no Graph or snapshot changes.
|
||||
- **Read/write separation by default**: Page remains read-first. Any unexpected mutation requires spec/plan update, confirmation, authorization, audit, notification, and tests.
|
||||
- **Single Contract Path to Graph**: No Graph calls may be added.
|
||||
- **Deterministic Capabilities**: Reuse existing `Capabilities`, `CapabilityResolver`, `WorkspaceCapabilityResolver`, policies, and resource authorization.
|
||||
- **Proportionality / anti-bloat**: No new source of truth, persisted entity, enum/status family, public abstraction, or cross-domain UI framework.
|
||||
- **Workspace isolation**: Clean workspace URL stays workspace-wide; `environment_id` is resolved through current workspace and actor entitlement.
|
||||
- **Tenant/environment language**: Runtime copy must avoid tenant as platform context; provider-specific tenant wording only where explicitly provider-bound.
|
||||
- **OperationRun UX**: Deep links only through `OperationRunLinks`; no operation start or lifecycle changes.
|
||||
- **UI-COV-001**: Existing strategic surface UI-028 changes; active spec package must carry repo-truth map, tests, and browser screenshots, and implementation close-out must decide whether route inventory/coverage matrix updates are needed.
|
||||
- **TEST-GOV-001**: Targeted Feature and Browser tests are explicit; no broad heavy-governance lane unless implementation reveals structural risk.
|
||||
|
||||
## Current Repo Truth Summary
|
||||
|
||||
Existing verified surfaces:
|
||||
|
||||
- `GovernanceInbox` is a Filament `Page` with slug `governance/inbox`.
|
||||
- `governance-inbox.blade.php` currently renders header/scope chips, family filter pills, and section cards/entries.
|
||||
- `GovernanceInboxSectionBuilder` already assembles:
|
||||
- `assigned_findings`
|
||||
- `intake_findings`
|
||||
- `finding_exceptions`
|
||||
- `stale_operations`
|
||||
- `alert_delivery_failures`
|
||||
- `review_follow_up`
|
||||
- `WorkspaceHubEnvironmentFilter::fromRequest()` accepts canonical `environment_id`, scopes to current workspace, and rejects inaccessible/cross-workspace IDs.
|
||||
- Navigation tests already cover canonical environment filter, clear filter, legacy alias rejection, and workspace hub no-drift behavior.
|
||||
- Governance Inbox already uses `CanonicalNavigationContext` and `OperationRunLinks` for source handoff.
|
||||
|
||||
Known current productization gap:
|
||||
|
||||
- The page is still section/queue-first. It does not yet consistently promote a selected/highest-priority item with decision, reason, impact, owner/due, evidence path, exception state, and a single next action ahead of the queue.
|
||||
|
||||
## Existing Repository Surfaces Likely Affected
|
||||
|
||||
Runtime files, only during later implementation:
|
||||
|
||||
- `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/resources/lang/en/*` and `apps/platform/resources/lang/de/*` only if current project pattern requires localized strings for new stable copy.
|
||||
|
||||
Tests, only during later implementation:
|
||||
|
||||
- `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php`
|
||||
- `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php`
|
||||
- `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextTest.php`
|
||||
- `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
|
||||
- `apps/platform/tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php`
|
||||
- `apps/platform/tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php`
|
||||
- `apps/platform/tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php`
|
||||
|
||||
Spec/UI artifacts:
|
||||
|
||||
- `specs/327-governance-inbox-decision-first-workbench-productization/repo-truth-map.md`
|
||||
- screenshot artifacts under `specs/327-governance-inbox-decision-first-workbench-productization/artifacts/screenshots/`
|
||||
- optional UI coverage registry updates only if implementation materially changes route/archetype/coverage state.
|
||||
|
||||
## Domain / Model Implications
|
||||
|
||||
- No new model, table, migration, enum, status family, source of truth, or persisted display state.
|
||||
- Workbench item state must derive from existing source payloads:
|
||||
- `Finding`: title/subject, status, severity, owner/assignee, due date, operation/evidence JSON where available.
|
||||
- `FindingException`: status, current validity, owner, review due, request reason, current decision, evidence references.
|
||||
- `OperationRun`: problem class, status/outcome, run identifier, managed environment, canonical link.
|
||||
- `AlertDelivery`: failed status, event type/title/error text, managed environment/workspace.
|
||||
- `ManagedEnvironmentTriageReview` / `EnvironmentReview`: review follow-up state and customer review destination.
|
||||
- `EvidenceSnapshot` / `FindingExceptionEvidenceReference`: proof path only where linked and authorized.
|
||||
- If exact evidence or owner/due is missing for a family, render explicit unavailable/missing states.
|
||||
|
||||
## UI / Filament Implications
|
||||
|
||||
- Filament v5 and Livewire v4.0+ compliance must be preserved.
|
||||
- Panel providers remain in `apps/platform/bootstrap/providers.php`; no panel provider changes expected.
|
||||
- No globally searchable resource is added or changed. Related resources must remain either disabled for global search or backed by safe View/Edit pages.
|
||||
- The layout should use Filament sections/cards/badges/buttons and Tailwind utility classes consistent with existing pages; no heavy one-off CSS.
|
||||
- Header must stay short:
|
||||
- `Governance Inbox`
|
||||
- workspace label
|
||||
- scope label: workspace-wide or filtered environment
|
||||
- visible filter chip and clear action when filtered
|
||||
- Main workbench must render before queue sections.
|
||||
- Summary cards should be compact and action-relevant.
|
||||
- Right-side detail panel should be desktop aside and mobile stack.
|
||||
- Diagnostics must be collapsed by default.
|
||||
|
||||
## Livewire / Page State Implications
|
||||
|
||||
- Existing `tenantId` internal property may remain as implementation detail only; runtime URL/filter language must be `environment_id`.
|
||||
- Existing `family` query filter can remain if it does not conflict with `environment_id` contract.
|
||||
- Selected/highest-priority state should be deterministic on page load. If interactive selection is implemented, it must not introduce persisted state or break reload/back/forward behavior.
|
||||
- Clear filter must remove `environment_id` and any environment-like table/session filter state through existing helpers.
|
||||
|
||||
## OperationRun / Monitoring Implications
|
||||
|
||||
- No new `OperationRun` creation or lifecycle transition.
|
||||
- No queued/running/terminal DB notification changes.
|
||||
- Any operation proof link must use existing `OperationRunLinks` and authorization.
|
||||
- Raw `OperationRun.context`, `failure_summary`, or payload-like data must not be default-visible.
|
||||
|
||||
## RBAC / Policy Implications
|
||||
|
||||
Reuse existing authorization:
|
||||
|
||||
- Workspace page access through `WorkspaceCapabilityResolver::isMember()`.
|
||||
- Environment access through `User::canAccessTenant()` and current accessible environments.
|
||||
- Findings through `Capabilities::TENANT_FINDINGS_VIEW` and `FindingPolicy`.
|
||||
- Assignment actions, if shown, through `Capabilities::TENANT_FINDINGS_ASSIGN` / policy.
|
||||
- Finding exceptions/accepted risks through `Capabilities::FINDING_EXCEPTION_VIEW`, `FINDING_EXCEPTION_MANAGE`, and `FINDING_EXCEPTION_APPROVE` as appropriate.
|
||||
- Evidence through `Capabilities::EVIDENCE_VIEW` and `EvidenceSnapshotPolicy`.
|
||||
- Alerts through `Capabilities::ALERTS_VIEW`.
|
||||
- Diagnostics through `Capabilities::SUPPORT_DIAGNOSTICS_VIEW` where any diagnostics UI is exposed.
|
||||
|
||||
No new permission semantics should be added unless implementation proves existing capabilities cannot express the action and spec/plan are updated first.
|
||||
|
||||
## Audit / Evidence / Disclosure Implications
|
||||
|
||||
- No new audit event is required for read-only page rendering unless current page-open audit conventions are extended repo-wide.
|
||||
- Evidence should appear as proof path/state:
|
||||
- linked
|
||||
- missing
|
||||
- stale
|
||||
- unavailable
|
||||
- not required
|
||||
- Do not show raw provider payloads, debug metadata, internal exception traces, provider secrets, raw OperationRun payloads, or stack traces by default.
|
||||
- If diagnostics disclosure is present, it must be collapsed and capability-aware.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- No migrations.
|
||||
- No seeders.
|
||||
- No data backfills.
|
||||
- No packages.
|
||||
- No env vars.
|
||||
- No queues/scheduler/storage changes.
|
||||
- No deployment asset changes.
|
||||
- No backwards compatibility layer.
|
||||
- No legacy tenant query alias support.
|
||||
|
||||
If implementation discovers an actual schema need, stop and update spec/plan/tasks/repo-truth-map first. Default decision remains no migration.
|
||||
|
||||
## Localization / Copy Implications
|
||||
|
||||
- Runtime copy must be concise and operator-safe.
|
||||
- Avoid platform-context `tenant` wording. Use `Workspace` and `Environment` for shell/filter/product context.
|
||||
- Provider-bound tenant wording may remain only when describing an external Microsoft/Entra tenant identifier or provider payload outside the default decision view.
|
||||
- Add EN/DE localization only if the surrounding files already route stable page copy through language files; otherwise keep localized scope as implementation-local and document the decision.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1 - Repo Truth And Current UI Audit
|
||||
|
||||
- Re-read this spec, plan, tasks, and `repo-truth-map.md`.
|
||||
- Inspect current `GovernanceInbox`, view, builder, related models, policies, and tests.
|
||||
- Update `repo-truth-map.md` before runtime changes if implementation discovers new source truth or gaps.
|
||||
- Confirm no migration/package/env/queue/storage need.
|
||||
|
||||
### Phase 2 - Tests First
|
||||
|
||||
- Add tests for repo truth map existence.
|
||||
- Add Feature/Livewire tests for decision-first workbench, right detail panel, queue context, diagnostics hidden, evidence state, accepted-risk/exception state, RBAC action visibility, environment filter, legacy aliases, cross-workspace guard, and tenant-copy guard.
|
||||
|
||||
### Phase 3 - Layout Productization
|
||||
|
||||
- Refactor the existing page into:
|
||||
- header/scope
|
||||
- main decision workbench
|
||||
- summary cards
|
||||
- queue/table context
|
||||
- right detail/decision panel
|
||||
- collapsed diagnostics disclosure
|
||||
- Keep existing family/queue context and source links.
|
||||
|
||||
### Phase 4 - Data Binding
|
||||
|
||||
- Bind workbench and panel to repo-verified sources.
|
||||
- Render unavailable states for missing owner/due/evidence/decision data.
|
||||
- Do not create synthetic success, risk, evidence, or decision claims.
|
||||
|
||||
### Phase 5 - Action Hierarchy And RBAC
|
||||
|
||||
- Show one primary next action per selected/highest-priority item.
|
||||
- Link only to existing, authorized source routes/actions.
|
||||
- Keep bulk/source actions secondary.
|
||||
- Do not introduce destructive actions.
|
||||
|
||||
### Phase 6 - Scope / Filter Integration
|
||||
|
||||
- Preserve clean workspace-wide entry.
|
||||
- Preserve `?environment_id=` filter, visible chip, clear filter, reload/back/forward behavior.
|
||||
- Preserve legacy alias rejection and cross-workspace guard.
|
||||
|
||||
### Phase 7 - Browser Smoke And Screenshots
|
||||
|
||||
- Add targeted Browser smoke for clean, filtered, clear/reload, selected item/detail panel, diagnostics hidden, table secondary, and no platform-context tenant wording.
|
||||
- Save screenshots under the spec artifacts path when generated.
|
||||
|
||||
### Phase 8 - Validation And Close-Out
|
||||
|
||||
- Run targeted Feature/navigation tests, Browser smoke, filtered guard tests, `pint --dirty`, and `git diff --check`.
|
||||
- Report full suite status honestly if not run.
|
||||
- Record no migrations/seeders/packages/env/queues/scheduler/storage/deployment asset/backcompat/legacy alias support.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Required tests:
|
||||
|
||||
- `it('documents_governance_inbox_repo_truth_map')`
|
||||
- `it('renders_governance_inbox_decision_first_workbench')`
|
||||
- `it('renders_governance_inbox_decision_detail_panel')`
|
||||
- `it('keeps_governance_inbox_queue_table_available_as_secondary_context')`
|
||||
- `it('governance_inbox_hides_raw_diagnostics_by_default')`
|
||||
- `it('governance_inbox_shows_accepted_risk_or_exception_state')`
|
||||
- `it('governance_inbox_shows_evidence_state_without_raw_payload')`
|
||||
- `it('governance_inbox_respects_capabilities_for_primary_actions')`
|
||||
- `it('governance_inbox_supports_canonical_environment_filter')`
|
||||
- `it('governance_inbox_rejects_legacy_environment_aliases')`
|
||||
- `it('governance_inbox_rejects_cross_workspace_environment_filter')`
|
||||
- `it('governance_inbox_does_not_use_tenant_as_platform_context_copy')`
|
||||
|
||||
Required Browser smoke:
|
||||
|
||||
- `tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php`
|
||||
|
||||
Browser flows:
|
||||
|
||||
1. Clean workspace Governance Inbox.
|
||||
2. Environment-filtered Governance Inbox.
|
||||
3. Clear filter and reload.
|
||||
4. Decision workbench and right detail panel visible.
|
||||
5. Diagnostics hidden by default.
|
||||
6. Queue/table context remains visible lower/secondary.
|
||||
7. No platform-context tenant wording.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Runtime page-only productization; no deployment asset changes expected.
|
||||
- No `filament:assets` deployment change expected because no registered Filament assets are planned.
|
||||
- If implementation adds registered Filament assets unexpectedly, stop and update spec/plan first, then include `cd apps/platform && php artisan filament:assets` in deployment notes.
|
||||
- Staging validation should include targeted Browser smoke for light mode and workspace/environment filter behavior before production promotion.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- **False green risk**: Use only repo-backed success/ready labels; otherwise render unavailable or attention states.
|
||||
- **RBAC leakage risk**: Derive counts after capability/environment scoping; hidden families must not leak counts.
|
||||
- **Scope drift risk**: Reuse existing `WorkspaceHubEnvironmentFilter` and clear-filter helpers; preserve Spec 314-322 tests.
|
||||
- **Diagnostics leakage risk**: Assert raw payload/debug/provider-secret/internal exception text absent by default.
|
||||
- **UI overbuild risk**: Keep composition page-local and Filament-native; no new framework.
|
||||
- **Test cost risk**: Use targeted Feature and Browser tests; avoid heavy fixture defaults.
|
||||
|
||||
## Gate Review
|
||||
|
||||
Candidate Selection Gate: expected pass. Direct user-supplied Spec 327, roadmap-aligned, not previously specced as `327`, related completed specs preserved, scope is one existing page.
|
||||
|
||||
Spec Readiness Gate: expected pass when `spec.md`, `plan.md`, `tasks.md`, `repo-truth-map.md`, and `checklists/requirements.md` exist and preparation analysis has no blocking issues.
|
||||
@ -0,0 +1,99 @@
|
||||
# Spec 327 Repo Truth Map
|
||||
|
||||
Status: implemented
|
||||
Created: 2026-05-18
|
||||
Purpose: classify each Governance Inbox decision-first workbench element before runtime implementation. This map is based on repository inspection only; implementation must update it before runtime changes if new source gaps appear.
|
||||
|
||||
## Classification Legend
|
||||
|
||||
- `repo-verified`: exact runtime source exists and was inspected.
|
||||
- `foundation-real`: backend model/service/policy exists, but exact page binding still needs implementation verification.
|
||||
- `derived from existing model`: display value can be derived from existing persisted/domain truth.
|
||||
- `empty state / unavailable`: no safe source/action exists for v1; show explicit unavailable state or omit.
|
||||
- `deferred future capability`: outside Spec 327 and must not be shown as live runtime truth.
|
||||
|
||||
## Required Data Areas
|
||||
|
||||
| Data area | Repo source | Preparation finding |
|
||||
|---|---|---|
|
||||
| Findings | `Finding`, `FindingResource`, `GovernanceInboxSectionBuilder::assignedFindingsSection()` and `intakeFindingsSection()` | repo-real for subject/title, status, owner/assignee, due, environment, source links, severity/family fields |
|
||||
| Finding Exceptions / Accepted Risks | `FindingException`, `FindingExceptionDecision`, `FindingExceptionEvidenceReference`, `FindingExceptionsQueue`, `FindingExceptionResource` | repo-real for status, validity, owner, request reason, due/expiry, current decision, evidence refs |
|
||||
| Governance Inbox current state | `GovernanceInbox`, `governance-inbox.blade.php`, `GovernanceInboxSectionBuilder` | route/page/view/builder exist; current layout is section/queue-first |
|
||||
| Decision Register / decisions | `DecisionRegister`, `FindingExceptionDecision`, Specs 265/306/307/308 | foundation-real for decision-related links/states; only show if existing source route/action is repo-real and authorized |
|
||||
| Evidence links | `EvidenceSnapshot`, `EvidenceSnapshotItem`, `FindingExceptionEvidenceReference`, `EvidenceSnapshotPolicy`, `Capabilities::EVIDENCE_VIEW` | foundation-real; exact inbox binding must be verified during implementation |
|
||||
| OperationRun links | `OperationRun`, `OperationRunLinks`, `RelatedNavigationResolver` | repo-real for stale/terminal follow-up and proof deep links |
|
||||
| Workspace / Environment filter state | `WorkspaceContext`, `WorkspaceHubEnvironmentFilter`, `WorkspaceHubFilterStateResetter`, `WorkspaceHubRegistry`, filter chip partial | repo-real canonical `environment_id`, clear filter, alias rejection, cross-workspace guard |
|
||||
| Owners / assignments | `Finding.owner_user_id`, `Finding.assignee_user_id`, `FindingException.owner_user_id`, resource display helpers | repo-real where fields are present; unavailable otherwise |
|
||||
| Due dates | `Finding.due_at`, `FindingException.review_due_at`, `FindingException.expires_at` | repo-real where fields are present; unavailable otherwise |
|
||||
| Statuses / priorities / severities | source model status/severity fields and builder ordering | derived; no new persisted status family |
|
||||
| Alerts | `AlertDelivery`, `AlertDeliveryResource`, `Capabilities::ALERTS_VIEW` | repo-real for failed delivery attention family |
|
||||
| Reviews / review follow-up | `ManagedEnvironmentTriageReview`, `EnvironmentReview`, `EnvironmentReviewRegisterService`, `CustomerReviewWorkspace` | repo-real for follow-up and review workspace handoff |
|
||||
|
||||
## UI Element Map
|
||||
|
||||
| UI element | Source model/service/page | Status source | Authorization/capability | Workspace/Environment scope | Evidence/OperationRun/Audit link | Fallback/empty state | Classification |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Governance Inbox route | `GovernanceInbox` page, slug `governance/inbox` | Filament page registration | workspace membership via `WorkspaceCapabilityResolver::isMember()` | `WorkspaceContext` session; optional `environment_id` | no direct audit found during prep | redirect workspace chooser, 404, or 403 per existing behavior | repo-verified |
|
||||
| Header title | `governance-inbox.blade.php`, page title | static page copy | page access | workspace-wide or filtered | none | static title | repo-verified |
|
||||
| Workspace chip | `appliedScope()['workspace_label']` | current `Workspace` name | page access | workspace session | none | omit if unavailable | repo-verified |
|
||||
| Environment filter chip | `workspace-hub-environment-filter-chip` partial | `WorkspaceHubEnvironmentFilter::fromRequest()` | actor must access environment | `?environment_id={id}` only | none | no chip on clean URL | repo-verified |
|
||||
| Clear filter action | `pageUrl(['environment_id' => null])`, shared chip | clean URL generated by page/helper | page access | removes canonical filter | none | omit when unfiltered | repo-verified |
|
||||
| Legacy alias rejection | `WorkspaceHubRegistry`, `WorkspaceHubFilterStateResetter`, navigation tests | forbidden query keys | page/source access | aliases do not set filter | none | workspace-wide view or safe 404 | repo-verified |
|
||||
| Cross-workspace environment guard | `WorkspaceHubEnvironmentFilter::fromRequest()` | environment scoped by workspace and `canAccessTenant()` | workspace and environment entitlement | current workspace only | none | `NotFoundHttpException` | repo-verified |
|
||||
| Main decision question | new page/view copy | static stable copy | page access | current scope | none | visible even empty with honest state | empty state / unavailable until implemented |
|
||||
| Highest-priority/selected item | existing section entries plus builder ordering | entry `urgency_rank`, source model status/due | source-family capabilities | current workspace/filter | source links | no selected item / no visible decisions | derived from existing model |
|
||||
| Decision title | `Finding` subject, `FindingException` finding label, `OperationRun` problem class, alert title, review family label | existing entry `headline` | source-family capability | current workspace/filter | source route | fallback source identifier | repo-verified |
|
||||
| Reason | `FindingException.request_reason`, operation problem class, alert error/event, review row state, finding status/reopened/due | source fields and builder summaries | source-family capability | current workspace/filter | source route/proof links | `Reason unavailable` if absent | derived from existing model |
|
||||
| Impact | source severity/status/validity/problem class/review follow-up | `Finding.severity`, `FindingException.current_validity_state`, `OperationRun::problemClass()`, review state | source-family capability | current workspace/filter | source route/proof links | `Impact unavailable` if no safe mapping | derived from existing model |
|
||||
| Owner | `Finding.ownerUser`, `Finding.assigneeUser`, `FindingException.owner` | owner/assignee user relationships | source-family capability | environment/workspace scoped | source route | `Owner missing` or `Owner unavailable` | repo-verified |
|
||||
| Due date | `Finding.due_at`, `FindingException.review_due_at`, `FindingException.expires_at` | model casts and resource relative-time helpers | source-family capability | environment/workspace scoped | source route | `Due date unavailable` | repo-verified |
|
||||
| Evidence state | `Finding.evidence_jsonb`, `FindingException.evidence_summary`, `FindingExceptionEvidenceReference`, `EvidenceSnapshot` | existing JSON/relations where linked | `Capabilities::EVIDENCE_VIEW` for evidence links | environment/workspace scoped | evidence snapshot/reference links if authorized | `Evidence missing`, `Unavailable`, or omit | foundation-real |
|
||||
| Evidence path | evidence refs, snapshots, review context, source routes | relation presence and policy checks | `EVIDENCE_VIEW` plus source policies | current workspace/filter | evidence/operation/review links only | unavailable rows | foundation-real |
|
||||
| Operation proof | `OperationRun`, `OperationRunLinks` | run relation or operation attention entry | existing operation entitlement checks | workspace and environment entitlement | canonical operation links | `Operation proof unavailable` | repo-verified |
|
||||
| Accepted-risk / exception state | `FindingException.status`, `current_validity_state`, `Finding.status = risk_accepted` | existing constants and validity fields | `FINDING_EXCEPTION_VIEW/MANAGE/APPROVE`, findings view | environment/workspace scoped | current decision/evidence references if authorized | `No exception` or unavailable | repo-verified |
|
||||
| Decision Register link/action | `DecisionRegister`, `FindingExceptionDecision`, existing decision routes/tests | current decision/link presence | existing decision/finding exception capabilities | workspace hub with optional filter | decision proof/run links where existing | omit / `No active decision record` | foundation-real |
|
||||
| Primary next action | existing source routes/actions from builder entries | destination URL/action label per family | source route/action authorization | current workspace/filter | source route/proof links | `Next action unavailable` | repo-verified for open-source links; derived for selected action label |
|
||||
| Assign owner action | `FindingPolicy::assign`, `Capabilities::TENANT_FINDINGS_ASSIGN` | existing capability/policy | tenant/environment capability | source environment | audit on source action if existing | hide/unavailable unless source action exists | foundation-real |
|
||||
| Create/update exception action | `FindingExceptionPolicy`, `Capabilities::FINDING_EXCEPTION_MANAGE/APPROVE`, existing source surfaces | existing policy and source action | workspace/environment capability | source environment | source audit if action exists | hide/unavailable unless source action exists | foundation-real |
|
||||
| Open evidence action | `EvidenceSnapshotResource`, `EvidenceSnapshotPolicy` | linked evidence and capability | `EVIDENCE_VIEW` | source environment/workspace | evidence detail route | hide/unavailable | foundation-real |
|
||||
| Open diagnostics action | `Capabilities::SUPPORT_DIAGNOSTICS_VIEW` if exposed | no current default inbox raw diagnostics | support diagnostics capability | current scope | source diagnostics only if repo-real | collapsed/hidden | empty state / unavailable |
|
||||
| Summary card: open decisions | current selected/available entries count | builder `total_count` or filtered item count | source-family capabilities | current scope | source links | `0 visible decisions` | repo-verified as visible attention count; label must avoid unsupported claim |
|
||||
| Summary card: critical/high priority | `Finding.severity`, exception validity/status, operation problem class | model fields | source-family capabilities | current scope | source links | unavailable if no safe cross-family mapping | derived from existing model |
|
||||
| Summary card: overdue | `Finding.due_at`, `FindingException.review_due_at/expires_at` | datetime fields | source-family capabilities | current scope | source links | `No due dates available` | derived from existing model |
|
||||
| Summary card: owner missing | finding/exception owner fields | nullable owner/assignee IDs | source-family capabilities | current scope | source links | unavailable for families without owner source | derived from existing model |
|
||||
| Summary card: evidence missing | evidence refs/summary/snapshot presence | evidence fields/relations | source-family and evidence capabilities | current scope | evidence links | unavailable where not supported | foundation-real |
|
||||
| Queue/table context | existing sections/entries rendered in Blade | builder sections and entries | source-family capabilities | current scope and family filter | source routes | existing calm empty states | repo-verified |
|
||||
| Right decision/detail panel | new layout over selected/highest entry | selected entry and source-specific derived fields | source-family capabilities | current scope | source/evidence/operation links | panel shows unavailable states | empty state / unavailable until implemented |
|
||||
| Diagnostics disclosure | new collapsed section if needed | safe copy only unless authorized diagnostics source exists | `SUPPORT_DIAGNOSTICS_VIEW` if raw detail exists | current scope | source routes only | collapsed/hidden | empty state / unavailable |
|
||||
| Raw provider payloads | raw Graph/provider payloads | not safe default | support-only future | N/A | N/A | never default-visible | deferred future capability |
|
||||
| Platform-context tenant copy guard | runtime copy/tests | string assertions | N/A | page copy | N/A | use Workspace/Environment | repo-verified need; implementation test required |
|
||||
|
||||
## Required Runtime Element Decisions
|
||||
|
||||
| Element | v1 decision |
|
||||
|---|---|
|
||||
| New persisted inbox item | deferred future capability; do not build |
|
||||
| New Decision Register workflow | deferred future capability; do not rebuild |
|
||||
| AI prioritization | deferred future capability; do not show |
|
||||
| Owner/due/evidence where absent | explicit unavailable/missing state; do not invent |
|
||||
| Green success state | allowed only for exact repo-backed proof; otherwise avoid |
|
||||
| Diagnostics | collapsed/hidden by default and capability-aware if exposed |
|
||||
| Dangerous/mutating actions | do not add unless spec/plan updated first |
|
||||
| Legacy query aliases | rejected/neutralized; do not support |
|
||||
|
||||
## Implementation Update Rule
|
||||
|
||||
If implementation discovers that a planned UI element has no safe source, no authorization path, or would require new persisted truth, the element must become `empty state / unavailable` or `deferred future capability`. Do not create backend foundation inside Spec 327 without updating `spec.md`, `plan.md`, and this map first.
|
||||
|
||||
## Implementation Close-Out
|
||||
|
||||
Implemented on 2026-05-18 against the existing `/admin/governance/inbox` Filament page.
|
||||
|
||||
- The workbench now derives selected/highest-priority state from existing `GovernanceInboxSectionBuilder` entries; no new persisted inbox item, status family, queue, migration, or backend source of truth was added.
|
||||
- Findings and finding exceptions now expose repo-backed decision labels, reasons, impact, owner/due, evidence, accepted-risk/exception, and primary source-action labels. Unsupported fields render explicit missing or unavailable states.
|
||||
- Alert delivery failure copy no longer exposes raw provider/error diagnostics in the default inbox entry. Diagnostics remain collapsed and point operators back to authorized source surfaces.
|
||||
- The page remains workspace-owned with optional canonical `environment_id` filtering. Legacy tenant/environment aliases were not added or supported.
|
||||
- Primary actions are read-first source navigation links. No destructive, provider-changing, approve/reject, restore, delete, or remediation action was added.
|
||||
- Browser proof and screenshots live under `specs/327-governance-inbox-decision-first-workbench-productization/artifacts/screenshots/`. The route inventory/design coverage registry were not changed because the route and UI-028 strategic-surface classification are unchanged.
|
||||
- Follow-up refinement removed the duplicate in-view page heading and moved the first dominant experience to the decision/evidence workbench. Empty workbench state now renders one compact `No governance decisions need attention` decision state instead of primary zero-metric cards.
|
||||
- Final browser proof keeps the empty state separate and adds a non-empty repo-backed finding fixture that visibly renders the decision title, reason, impact, missing owner, due date, missing evidence, accepted-risk state, and primary next action in the decision workbench plus right-side evidence panel. The required screenshot artifact is `artifacts/screenshots/governance-inbox-decision-workbench.png`.
|
||||
@ -0,0 +1,458 @@
|
||||
# Feature Specification: Spec 327 - Governance Inbox Decision-First Workbench Productization
|
||||
|
||||
**Feature Branch**: `327-governance-inbox-decision-first-workbench-productization`
|
||||
**Created**: 2026-05-18
|
||||
**Status**: Draft
|
||||
**Type**: Runtime UI productization / decision workbench / governance triage surface
|
||||
**Runtime posture**: Narrow runtime UI implementation. Repo-based. No invented backend foundation.
|
||||
**Input**: User-provided full Spec 327 draft.
|
||||
|
||||
## Dependencies And Historical Context
|
||||
|
||||
Depends on:
|
||||
|
||||
- Spec 250 - Decision-Based Governance Inbox v1, treated as historical inbox foundation.
|
||||
- Spec 257 - Governance Decision Surface Convergence, treated as historical decision-home convergence foundation.
|
||||
- Spec 314 - Workspace Hub Navigation Context Contract.
|
||||
- Spec 315 - Environment CTA Explicit Filter Contract.
|
||||
- Spec 316 - Workspace Hub Clear Filter Contract.
|
||||
- Spec 317 - Legacy Tenant / Environment Context Cleanup.
|
||||
- Spec 318 - Admin Surface Scope & Shell Context Audit.
|
||||
- Spec 319 - Environment-Owned Surface Routing & Shell Context Contract.
|
||||
- Spec 320 - Workspace-Owned Analysis Surface Registration & Shell Cutover.
|
||||
- Spec 321 - Alerts / Audit Log Environment Filter Contract Decision.
|
||||
- Spec 322 - Browser No-Drift Regression Guard.
|
||||
- Spec 325 - Screenshot-Anchored Strategic Target Images.
|
||||
- Spec 326 - Customer Review Workspace v1 Productization.
|
||||
|
||||
Repo truth adjustment: the user draft intentionally starts from Spec 325 target direction and the Spec 326 premium-layout lesson, but the repository already has a runtime `GovernanceInbox` page and a `GovernanceInboxSectionBuilder`. Spec 327 must productize that existing route and builder instead of creating a new workflow engine, ticket queue, Decision Register rebuild, persisted inbox item, or broad status taxonomy. Spec 325 premium references are visual calibration only; they are not runtime truth for counts, states, actions, RBAC, audit, evidence, or OperationRun behavior.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Governance Inbox is repo-real and already aggregates findings, finding exceptions, stale or failed operations, alert delivery failures, and review follow-up, but the first-read experience still behaves like a sectioned queue rather than a decision-first workbench that answers which governance decision clears the highest-priority item.
|
||||
- **Today's failure**: Operators can see attention families and source links, but they still need to infer reason, impact, owner, due date, evidence state, accepted-risk state, and one safest next action from section summaries and row sublines. Diagnostics/source routing can compete with the decision hierarchy.
|
||||
- **User-visible improvement**: Governance operators get a polished workbench where the highest-priority or selected item leads with decision, reason, impact, owner/due, evidence, exception/accepted-risk state, and a single primary next action while raw diagnostics remain collapsed.
|
||||
- **Smallest enterprise-capable version**: Productize only the existing `/admin/governance/inbox` page and its current builder/view payloads. Add a repo-truth map first, derive any display states from existing models/services, keep the current section/table context available, and add targeted Feature/Browser coverage for layout, scope, RBAC, diagnostics, and no tenant-context copy.
|
||||
- **Explicit non-goals**: No backend workflow rebuild, no Decision Register rebuild, no helpdesk/PSA system, no AI prioritization, no new remediation automation, no new persisted inbox entity, no new decision taxonomy, no migrations, no packages, no env vars, no queues/scheduler/storage changes, no broad redesign of Customer Review Workspace, Operations Hub, Evidence Overview, Environment Dashboard, Baseline Compare, or Restore Safety Workflow.
|
||||
- **Permanent complexity imported**: Feature-local page composition, feature-local display payloads if needed, targeted Feature tests, a Browser smoke test, screenshot artifacts, and `repo-truth-map.md`. No new persisted truth, no public abstraction, no enum/status family, no cross-domain UI framework.
|
||||
- **Why now**: Spec 326 completed the customer review productization lane and explicitly deferred Governance Inbox as Spec 327. `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and Spec 325 all identify Governance Inbox decision experience as a P1/P0 strategic surface and the largest remaining operator workflow productization gap.
|
||||
- **Why not local**: A copy-only patch or another section link would leave the primary operator question unresolved. A new task engine would overbuild. The narrow correct slice is a decision-first productization pass on the existing workspace-scoped Governance Inbox.
|
||||
- **Approval class**: Workflow Compression.
|
||||
- **Red flags triggered**: Cross-surface decision composition, evidence/action display, and strategic UI productization. Defense: scope is bounded to one existing page, existing truth sources, existing authorization, existing routes, existing workspace/environment filter contract, and explicit no-new-backend constraints.
|
||||
- **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 manual promotion for Spec 327, aligned with `decision-based-governance-inbox-v1` in `docs/product/spec-candidates.md`, the roadmap's Decision-Based Governance Inbox lane, and the Governance Inbox target brief from Spec 325.
|
||||
- **Completed-spec check**: No `specs/327-*` package existed before generation. Related Specs 250, 257, and 314-326 contain historical/completed foundation or productization signals and must remain unchanged by this preparation.
|
||||
- **Close alternatives deferred**: Operations Hub Decision-First Workbench, Evidence/Audit disclosure, Environment Dashboard/Baseline Compare, and Restore Safety Workflow productization are follow-up specs 328-331, not hidden scope.
|
||||
- **Smallest viable implementation slice**: Existing Governance Inbox only, including header/scope clarity, main decision workbench, summary cards, queue/table context, right-side decision panel, diagnostics disclosure, RBAC-aware actions, canonical `environment_id` filter behavior, and tests/browser smoke.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace canonical-view governance decision workbench, optionally filtered by canonical `environment_id`.
|
||||
- **Primary Routes**:
|
||||
- Existing route: `/admin/governance/inbox`.
|
||||
- Existing page class: `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`.
|
||||
- Existing view: `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`.
|
||||
- Existing builder: `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`.
|
||||
- **Data Ownership**:
|
||||
- Findings truth: `Finding`, including owner, assignee, due date, severity, status, subject display, and linked operation runs.
|
||||
- Finding exception / accepted-risk truth: `FindingException`, `FindingExceptionDecision`, `FindingExceptionEvidenceReference`.
|
||||
- Operation proof truth: `OperationRun` and existing `OperationRunLinks`.
|
||||
- Evidence proof truth: `EvidenceSnapshot`, `EvidenceSnapshotItem`, and existing evidence policies/resources where linked.
|
||||
- Review follow-up truth: `ManagedEnvironmentTriageReview`, `EnvironmentReview`, `EnvironmentReviewRegisterService`, Customer Review Workspace links.
|
||||
- Alert delivery truth: `AlertDelivery`, alert resource routes, and workspace alert capabilities.
|
||||
- Workspace / environment filter truth: `WorkspaceContext`, `WorkspaceHubEnvironmentFilter`, `WorkspaceHubFilterStateResetter`, `WorkspaceHubRegistry`, and existing filter chip partial.
|
||||
- Audit truth: existing source surfaces and policies only; this spec introduces no new mutation/audit action unless spec/plan are updated first.
|
||||
- **RBAC**:
|
||||
- Workspace membership required.
|
||||
- Managed-environment entitlement required when an `environment_id` filter is applied or environment-bound data is rendered.
|
||||
- Existing capabilities and policies remain authoritative for findings, finding exceptions/accepted risks, evidence, operation runs, reviews, alerts, diagnostics, assignment, and source actions.
|
||||
- Non-member or cross-workspace environment access remains deny-as-not-found.
|
||||
- Member with missing source capability must not see unauthorized actions or sensitive existence hints.
|
||||
|
||||
For canonical-view specs:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Clean `/admin/governance/inbox` remains workspace-wide and must not inherit remembered environment context, Filament tenant context, session table filters, or legacy query aliases.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: `?environment_id=` must resolve through the current workspace and actor entitlement; cross-workspace or inaccessible IDs return 404/no-access.
|
||||
|
||||
## 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 Surface / Findings-Inbox Workbench, matching `docs/ui-ux-enterprise-audit/page-reports/ui-004-governance-inbox.md`.
|
||||
- **Design depth**: Strategic Surface.
|
||||
- **Repo-truth level**: repo-verified page and foundation; individual runtime elements must be classified in `repo-truth-map.md`.
|
||||
- **Existing pattern reused**: Filament Page, Filament sections, existing builder, existing source-surface links, existing workspace hub environment chip, existing capability resolvers, existing OperationRun links, existing resources/policies.
|
||||
- **New pattern required**: no new runtime framework; page-local workbench composition only.
|
||||
- **Screenshot required**: yes, Browser smoke screenshots under `specs/327-governance-inbox-decision-first-workbench-productization/artifacts/screenshots/`.
|
||||
- **Page audit required**: no new full audit unless implementation materially changes route inventory; active spec artifacts and screenshots are sufficient if route/archetype remains UI-028.
|
||||
- **Customer-safe review required**: operator-facing, but copy may feed customer review summaries. Keep customer/operator-safe labels and hide raw diagnostics.
|
||||
- **Dangerous-action review required**: no new dangerous action expected. If implementation unexpectedly adds approve/reject/accept-risk/assign/close style mutations, spec/plan must be updated first and actions must use confirmation, server authorization, audit, notifications, and tests.
|
||||
- **Coverage files updated or explicitly not needed**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||
- [ ] `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`
|
||||
- **Coverage decision for implementation**: implementation must either update UI coverage registry artifacts or document in this spec's close-out why UI-004 and Spec 325 target artifacts remain sufficient for the unchanged route/archetype.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes.
|
||||
- **Interaction class(es)**: status messaging, action links, governance queue summaries, evidence/proof links, OperationRun links, workspace/environment filter chip, diagnostics disclosure, navigation/back context.
|
||||
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `FindingResource`, `FindingExceptionsQueue`, `FindingExceptionResource`, `AlertDeliveryResource`, `CustomerReviewWorkspace`, `EnvironmentReviewResource`, `OperationRunLinks`, `CanonicalNavigationContext`, `WorkspaceHubEnvironmentFilter`, capability resolvers, and policies.
|
||||
- **Existing pattern(s) to extend**: existing Governance Inbox section payloads, existing workspace hub filter chip, existing navigation context, source-surface routes, existing policy/capability checks.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperationRunLinks`, `WorkspaceHubEnvironmentFilter`, `WorkspaceHubFilterStateResetter`, `CapabilityResolver`, `WorkspaceCapabilityResolver`, existing Filament resources/routes.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing paths are sufficient for truth, authorization, and source routing. They are insufficient only in first-read page hierarchy; Spec 327 may add page-local payload shaping but must not create a new generic workflow or status framework.
|
||||
- **Allowed deviation and why**: bounded page-local layout/view helper changes are allowed. New public cross-domain presenters, status taxonomies, or persisted item stores are not allowed.
|
||||
- **Consistency impact**: Labels, badges, actions, and links must stay aligned with existing findings, exception, evidence, review, alert, and operation vocabulary.
|
||||
- **Review focus**: Verify no fake metrics, no false green state, no raw diagnostics by default, no unauthorized action, no shell-scope regression, and no local duplicate of existing source-surface mutation semantics.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: secondary/deep-link semantics only; no new OperationRun creation, queueing, dedupe, completion, or lifecycle behavior.
|
||||
- **Shared OperationRun UX contract/layer reused**: existing `OperationRunLinks` and existing operation resource routes only.
|
||||
- **Delegated start/completion UX behaviors**: N/A - no operation start.
|
||||
- **Local surface-owned behavior that remains**: show operation proof availability/unavailability and source links only when existing run records and authorization support 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 governance workbench over existing environment-bound artifacts.
|
||||
- **Seams affected**: display and routing over findings, finding exceptions, evidence, review, alert, and operation proof.
|
||||
- **Neutral platform terms preserved or introduced**: workspace, environment filter, governance decision, evidence path, accepted risk, operation proof, diagnostics.
|
||||
- **Provider-specific semantics retained and why**: existing Microsoft/Intune terms may appear only where the underlying finding/review/resource already uses them. Do not surface raw provider IDs, Graph payloads, tenant aliases, or provider diagnostics by default.
|
||||
- **Why this does not deepen provider coupling accidentally**: no Graph calls, no provider contracts, no provider connection changes, and no new provider-shaped persistence.
|
||||
- **Follow-up path**: provider readiness, environment dashboard, baseline compare, restore safety, and evidence/audit productization remain separate specs.
|
||||
|
||||
## 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 | decision queue, evidence/proof links, source routing | page, URL-query, derived payload | no | Existing route only |
|
||||
| Header / scope area | yes | Filament section / Blade | workspace/environment context presentation | page, URL-query | no | Must keep Workspace shell ownership |
|
||||
| Main decision workbench | yes | Filament section/cards/badges | status/readiness/action hierarchy | page payload | no | Derived labels only, no new persisted state |
|
||||
| Summary cards | yes | Filament cards/badges | decision posture and count summaries | builder/page payload | no | Only repo-backed counts or unavailable states |
|
||||
| Queue/table context | yes | existing sections/list; table-like queue may be refactored | specialist source surfaces | builder/page payload | no | Current table/queue remains secondary context |
|
||||
| Right detail / decision panel | yes | native layout / Blade | evidence, accepted risk, decision summary, action links | page payload | no | Desktop aside, stacked on small screens |
|
||||
| Diagnostics disclosure | yes | collapsed/progressive disclosure only | support/raw detail | page payload/action visibility | no | Authorized and collapsed by default |
|
||||
|
||||
## 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 item should be cleared first and which source action is safest | highest-priority/selected item, decision, reason, impact, owner/due, evidence state, exception state, next action | source record, operation proof, evidence snapshot, audit/review context, diagnostics | Primary because it is the canonical workspace governance decision workbench | Follows pending governance decisions, not storage objects | Removes cross-page reconstruction before first action |
|
||||
| Queue/list sections | Secondary Context | Operator scans remaining items after priority decision is visible | decision/finding label, environment, severity/priority, owner/due where available, evidence/exception state, next action | source page detail and diagnostics after open | Secondary because workbench leads with decision summary | Keeps source families visible without making table the only experience | Reduces equal-priority section overload |
|
||||
| Diagnostics disclosure | Tertiary Evidence / Diagnostics | Support/operator confirms technical detail after the decision path | collapsed availability only | raw payloads/debug/support detail only if authorized and explicitly opened | Not primary; proof and diagnostics support decisions | Preserves audit depth without diagnostic dominance | Prevents default raw-console 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, auditor/support reviewer | decision title, reason, impact, owner/due, evidence state, accepted-risk state, environment scope, primary next action | collapsed source diagnostics and source-page detail | raw payloads, stack traces, provider secrets, internal exception traces, raw OperationRun payloads | context-aware source action such as review decision, open finding, review accepted risk, open evidence, or open operation proof | raw/support detail and unauthorized actions | main workbench states the blocker once; queue and panel add evidence/source proof |
|
||||
| Right detail panel | operator-MSP, support reviewer where authorized | decision summary, proof path, accepted-risk state, owner/due, next action | operation/evidence/review context behind links/disclosure | raw/support details remain hidden or routed to existing authorized surfaces | same primary next action as selected item | raw diagnostics and unsupported links | panel expands the same selected item instead of creating another decision |
|
||||
|
||||
## 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 | Workbench / Workspace Decision | Decision-first governance queue | Review/open the highest-priority item | explicit primary action plus selected/row source link | optional row/select only if repo-real | right/detail panel and source links | none introduced; any future mutation must be confirmed/audited | `/admin/governance/inbox` | existing source routes only | active workspace, optional `environment_id` chip | Governance Inbox | decision, reason, impact, owner/due, evidence, exception, next action | none |
|
||||
| Queue context | List / Table / Read-only decision queue | Secondary workbench context | Select/open source item | existing source route | allowed only if clear and accessible | row action or panel action | existing source surfaces only | `/admin/governance/inbox` | existing finding/exception/operation/alert/review routes | workspace + environment filter | Governance item | priority/severity/status, evidence/exception, owner/due | none |
|
||||
| Diagnostics disclosure | Diagnostics / Support Raw | Collapsed diagnostic context | Expand proof detail if authorized | disclosure component | N/A | below/inside detail panel | none | same page | existing source diagnostics routes if any | authorized-only label | Diagnostics | collapsed status only | none |
|
||||
|
||||
## 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 | Workspace operator / MSP operator | Decide which governance item clears first and open the safest existing action path | Workspace decision workbench | What decision clears the highest-priority governance item? | decision, reason, impact, owner, due, evidence, exception, environment, next action | raw payloads, provider debug, stack traces, raw OperationRun payload, internal exception/debug metadata | decision urgency, evidence availability, risk/exception state, owner/due, source family, operation follow-up | none on the page by default; existing source surfaces own mutations | review decision/open finding/review accepted risk/open evidence/open operation proof as supported | none introduced |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no.
|
||||
- **New persisted entity/table/artifact?**: no. `repo-truth-map.md` is a preparation artifact, not runtime truth.
|
||||
- **New abstraction?**: no public abstraction. Page-local private helpers are allowed only when they reduce Blade complexity and stay feature-local.
|
||||
- **New enum/state/reason family?**: no domain state. Display states must be derived from existing `Finding`, `FindingException`, `EvidenceSnapshot`, `OperationRun`, `AlertDelivery`, `EnvironmentReview`, and current builder truth.
|
||||
- **New cross-domain UI framework/taxonomy?**: no.
|
||||
- **Current operator problem**: Governance Inbox needs a decision-first runtime surface that consumes existing truth without making unsupported decision, owner, evidence, accepted-risk, or success claims.
|
||||
- **Existing structure is insufficient because**: Current page foundations show attention families but do not consistently elevate the selected/highest-priority decision, proof path, owner/due state, accepted risk, and one primary next action above queue diagnostics.
|
||||
- **Narrowest correct implementation**: Refactor the existing page layout and derived payloads, bind to existing sources, hide diagnostics, and add targeted tests/browser smoke.
|
||||
- **Ownership cost**: Feature-local layout/payload tests, one Browser smoke, and screenshot artifacts. No durable backend model or new framework cost.
|
||||
- **Alternative intentionally rejected**: new ticket/helpdesk system, new Decision Register foundation, new workflow engine, AI prioritization, broad design system work, and persisted inbox items.
|
||||
- **Release truth**: current-release runtime UI productization over existing governance inbox foundations.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes pre-production runtime posture. Backward compatibility, historical aliases, migration shims, dual-write logic, and legacy tenant query aliases are out of scope. Existing legacy query aliases (`tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`) must not be supported for Governance Inbox filtering.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature, Filament/Livewire/HTTP, Browser.
|
||||
- **Validation lane(s)**: confidence plus browser for critical workspace/environment UI/scope smoke.
|
||||
- **Why this classification and these lanes are sufficient**: The change is a user-facing Filament page productization with RBAC, source truth, scope, and disclosure behavior. Feature tests prove data/scope/action rules; Browser smoke proves shell/filter/reload/disclosure/selected-panel behavior.
|
||||
- **New or expanded test families**: additions under `tests/Feature/Governance/*` and `tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php`.
|
||||
- **Fixture / helper cost impact**: reuse existing helpers and factories in `tests/Pest.php`; do not widen expensive defaults.
|
||||
- **Heavy-family visibility / justification**: browser addition is explicit and named for Spec 327.
|
||||
- **Special surface test profile**: `global-context-shell` plus decision-first disclosure.
|
||||
- **Standard-native relief or required special coverage**: special coverage required for environment filter, clear/reload, highest-priority/selected decision, right detail panel, diagnostics default-hidden, and no platform-context tenant copy.
|
||||
- **Reviewer handoff**: confirm diagnostics are collapsed, RBAC actions hide/disable correctly, no false green, clean workspace entry, canonical filter, clear filter, cross-workspace guard, and table/queue remains available as secondary context.
|
||||
- **Budget / baseline / trend impact**: no expected material lane-cost shift beyond one targeted browser smoke.
|
||||
- **Escalation needed**: document-in-feature if browser coverage becomes too expensive or requires fixture broadening.
|
||||
- **Active feature PR close-out entry**: Smoke Coverage.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Governance tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter='GovernanceInbox|WorkspaceHub|EnvironmentFilter|ClearFilter|LegacyTenant|Spec322' --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Decide the highest-priority governance item (Priority: P1)
|
||||
|
||||
As a governance operator, I want the inbox to show the highest-priority or selected item with decision, reason, impact, owner, due, evidence, accepted-risk state, and one next action so I can clear work without reconstructing context across sections.
|
||||
|
||||
**Why this priority**: This is the core productization promise and delivers value even before optional detail refinements.
|
||||
|
||||
**Independent Test**: Seed findings, finding exceptions, evidence/operation links, and review follow-up; open Governance Inbox; verify the workbench question, selected/highest-priority item, reason, impact, evidence, exception state, owner/due or unavailable state, and primary action.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** visible governance attention exists, **When** the operator opens clean Governance Inbox, **Then** the page asks `What decision clears the highest-priority item?` and shows the top decision with reason, impact, evidence, exception, owner/due, and next action.
|
||||
2. **Given** owner, due date, or evidence data is absent, **When** the item is rendered, **Then** the page shows an honest unavailable/missing state rather than invented data.
|
||||
3. **Given** no visible attention exists, **When** the operator opens the page, **Then** the empty state says no governance decisions need action in the current scope without false green success claims.
|
||||
|
||||
### User Story 2 - Keep workspace/environment scope explicit (Priority: P1)
|
||||
|
||||
As a workspace operator, I want Governance Inbox to stay workspace-owned while accepting a canonical environment filter so clean, filtered, clear, reload, and cross-workspace behavior remain predictable.
|
||||
|
||||
**Why this priority**: Specs 314-322 made this a hard product contract for workspace hub surfaces.
|
||||
|
||||
**Independent Test**: Open clean `/admin/governance/inbox`, filtered `?environment_id=`, clear filter, reload, legacy aliases, and cross-workspace IDs; verify workspace shell and chip behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** clean Governance Inbox URL, **When** the page loads, **Then** no Environment chip or remembered Environment filter appears.
|
||||
2. **Given** `?environment_id={id}` for an entitled environment, **When** the page loads, **Then** Workspace shell remains active and a visible Environment filter chip appears.
|
||||
3. **Given** a cross-workspace environment ID or legacy query alias, **When** the page loads, **Then** the cross-workspace ID is denied as not found and aliases do not create filter state.
|
||||
|
||||
### User Story 3 - Inspect proof without raw diagnostics by default (Priority: P1)
|
||||
|
||||
As an operator or support reviewer, I want evidence path and operation proof to be visible as links/states while raw diagnostics remain secondary and capability-gated.
|
||||
|
||||
**Why this priority**: Governance decisions need proof, but default raw details create support-console drift and leakage risk.
|
||||
|
||||
**Independent Test**: Render items with linked evidence/operation proof and assert evidence state/path is visible while raw payload, stack trace, provider secret, debug metadata, and internal exception text are absent by default.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an item has linked evidence or operation proof, **When** the detail panel renders, **Then** the evidence path or operation proof state/link appears only if authorized.
|
||||
2. **Given** diagnostics are available, **When** the page first renders, **Then** raw diagnostics are collapsed or absent by default.
|
||||
3. **Given** the actor lacks diagnostics/evidence capability, **When** the page renders, **Then** protected links/actions are hidden/disabled without leaking sensitive details.
|
||||
|
||||
### User Story 4 - Preserve queue context as secondary workbench content (Priority: P2)
|
||||
|
||||
As a governance operator, I want the existing queue/table context to remain available below or beside the workbench so I can scan other items after the top decision is clear.
|
||||
|
||||
**Why this priority**: The workbench must not regress existing routing and filter functionality.
|
||||
|
||||
**Independent Test**: Render the existing Governance Inbox fixtures and assert source families/rows still appear as secondary context with filters/search/source links intact.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** multiple visible source families exist, **When** the workbench renders, **Then** existing family/queue context remains available and not replaced by an empty dashboard.
|
||||
2. **Given** a family filter is selected, **When** the page renders, **Then** the workbench and secondary queue align to the filtered family without losing clear-filter behavior.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- **FR-001**: Governance Inbox MUST remain on the existing `/admin/governance/inbox` route.
|
||||
- **FR-002**: The page MUST render a decision-first workbench before the secondary queue/table context.
|
||||
- **FR-003**: The workbench MUST include the question `What decision clears the highest-priority item?` or stable equivalent final copy.
|
||||
- **FR-004**: The selected/highest-priority item MUST show decision/title, reason, impact, owner/due state, evidence state, accepted-risk/exception state, and primary next action.
|
||||
- **FR-005**: Missing owner, due, evidence, decision, or accepted-risk data MUST render honest unavailable/missing states or be omitted; it MUST NOT be invented.
|
||||
- **FR-006**: Summary cards MUST use only repo-supported counts/states, such as open decisions, overdue, awaiting review, owner missing, evidence missing, or accepted-risk follow-up.
|
||||
- **FR-007**: Existing source-family/queue context MUST remain available as secondary workbench content.
|
||||
- **FR-008**: A right-side decision/detail panel MUST be visible on desktop and stack on smaller screens.
|
||||
- **FR-009**: Evidence/proof must be shown as proof path/state, not raw payload.
|
||||
- **FR-010**: Diagnostics/raw details MUST be collapsed, hidden, or capability-gated by default.
|
||||
- **FR-011**: Primary action MUST be singular and repo-real for the selected/highest-priority item.
|
||||
- **FR-012**: Unauthorized actions MUST be hidden, disabled with existing helper text convention, or replaced by unavailable state; UI visibility is not security.
|
||||
- **FR-013**: No new destructive or governance-impacting action may be added without updating spec/plan first and applying confirmation, server authorization, audit, notification, and tests.
|
||||
- **FR-014**: Clean entry MUST remain workspace-wide with no active Environment shell and no remembered Environment fallback.
|
||||
- **FR-015**: Filtered entry MUST use only `?environment_id={id}` with a visible chip and clear action.
|
||||
- **FR-016**: Legacy query aliases (`tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`) MUST NOT create filter state.
|
||||
- **FR-017**: Cross-workspace or unauthorized `environment_id` MUST be rejected as safe not-found/no-access.
|
||||
- **FR-018**: Runtime copy MUST avoid `tenant` as platform context copy. Provider-specific use remains allowed only when explicitly provider-bound.
|
||||
- **FR-019**: Implementation MUST update `repo-truth-map.md` before runtime changes if new source gaps are discovered.
|
||||
- **FR-020**: No migrations, seeders, packages, env vars, queues, scheduler, storage, deployment asset changes, backwards compatibility layer, or legacy alias support are expected.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: Page render MUST remain DB-only and MUST NOT perform Microsoft Graph or external provider calls.
|
||||
- **NFR-002**: The layout MUST remain Filament v5 / Livewire v4 compatible and use native Filament/Tailwind primitives before custom UI.
|
||||
- **NFR-003**: The workbench MUST remain responsive enough for the Filament shell; desktop gets a right-side detail panel and smaller viewports stack it below.
|
||||
- **NFR-004**: Decision priority MUST NOT rely on color alone; status/action meaning must also be expressed in text.
|
||||
- **NFR-005**: Raw diagnostics, provider payloads, stack traces, provider secrets, raw OperationRun payloads, and internal exception/debug metadata MUST NOT be default-visible.
|
||||
- **NFR-006**: Query and section counts MUST be scoped by workspace, environment entitlement, and capability before rendering.
|
||||
- **NFR-007**: Implementation MUST avoid broad fixture/helper defaults or hidden heavy-governance test cost.
|
||||
|
||||
## Auditability / Observability Requirements
|
||||
|
||||
- **AOR-001**: No new audit event is required for the read-first page productization unless implementation introduces a new mutation or extends an existing repo audit convention.
|
||||
- **AOR-002**: Any unexpected mutating action MUST be added only after spec/plan update and must include server-side authorization, confirmation when destructive/high-impact, audit logging, user feedback, and tests.
|
||||
- **AOR-003**: Operation proof links must use existing OperationRun truth and link helpers; this spec must not create or transition OperationRuns.
|
||||
- **AOR-004**: Evidence, decision, and operation proof states must distinguish available proof from unavailable/missing proof without implying audit success.
|
||||
|
||||
## Data / Truth Requirements
|
||||
|
||||
Each visible runtime element must be classified as one of:
|
||||
|
||||
- `repo-verified`
|
||||
- `foundation-real`
|
||||
- `derived from existing model`
|
||||
- `empty state / unavailable`
|
||||
- `deferred future capability`
|
||||
|
||||
The authoritative map is `repo-truth-map.md` in this spec directory. If implementation discovers that a planned element has no safe source, no authorization path, or would require new persisted truth, the element must become `empty state / unavailable` or `deferred future capability`.
|
||||
|
||||
## RBAC / Capability Requirements
|
||||
|
||||
At minimum, implementation must verify existing capabilities/policies for:
|
||||
|
||||
- view Governance Inbox / workspace membership
|
||||
- view findings
|
||||
- assign owner where supported
|
||||
- view/manage/approve finding exceptions or accepted risks
|
||||
- view evidence
|
||||
- view operation runs/proof
|
||||
- open review/customer review context
|
||||
- open decision record if supported
|
||||
- view diagnostics/support detail
|
||||
|
||||
Do not introduce new permission semantics unless repo analysis proves an existing capability cannot express the action and spec/plan are updated first.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing `GovernanceInboxSectionBuilder` can provide enough item-level source context for a first implementation without becoming a generic task engine.
|
||||
- Existing source routes for findings, finding exceptions, operations, alerts, reviews, and Customer Review Workspace remain the owning surfaces for mutations and deep diagnostics.
|
||||
- Existing capability resolvers and policies are sufficient for v1 action visibility and source-link access.
|
||||
- The internal `tenantId` property may remain as implementation detail only; runtime URL/copy/filter language must stay `environment_id` and `Environment`.
|
||||
- Spec 325 target images remain visual calibration and must not be treated as runtime data truth.
|
||||
|
||||
## Risks
|
||||
|
||||
- Implementation could overreach into a new workflow engine or duplicate Decision Register state instead of deriving a page-local workbench.
|
||||
- Cross-family priority mapping could imply a new decision taxonomy if it is not kept as derived display logic.
|
||||
- Evidence or accepted-risk labels could become false green claims if unavailable proof is not rendered honestly.
|
||||
- Capability-limited actors could learn hidden family existence through counts, empty states, or disabled action labels if scoping is applied too late.
|
||||
- Browser coverage could become expensive if fixtures grow beyond the targeted smoke path.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking preparation. Implementation must update `repo-truth-map.md` and spec/plan before runtime edits if it discovers a selected UI element requires new persisted truth, a new capability, or an unsupported source route.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No backend workflow rebuild.
|
||||
- No Decision Register rebuild.
|
||||
- No new ticket/helpdesk/PSA system.
|
||||
- No AI prioritization or recommendation engine.
|
||||
- No new remediation automation.
|
||||
- No broad redesign of findings pages, Customer Review Workspace, Operations Hub, Evidence Overview, Environment Dashboard, Baseline Compare, or Restore Safety Workflow.
|
||||
- No migrations by default.
|
||||
- No packages, env vars, queues, scheduler, storage, or deployment asset changes.
|
||||
- No legacy tenant query alias support.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Productization
|
||||
|
||||
- [ ] Governance Inbox has a decision-first layout.
|
||||
- [ ] Main decision question is visible.
|
||||
- [ ] Highest-priority/selected item is visible.
|
||||
- [ ] Reason and impact are visible.
|
||||
- [ ] Owner/due state is visible or honestly unavailable.
|
||||
- [ ] Evidence state is visible.
|
||||
- [ ] Accepted-risk/exception state is visible.
|
||||
- [ ] Primary next action is clear.
|
||||
- [ ] Diagnostics are secondary/collapsed.
|
||||
- [ ] Table/queue remains available as secondary workbench context.
|
||||
|
||||
### Customer / Operator Safety
|
||||
|
||||
- [ ] Raw diagnostics hidden by default.
|
||||
- [ ] Provider secrets not visible.
|
||||
- [ ] Internal exception/debug text not visible.
|
||||
- [ ] No false green success state.
|
||||
- [ ] Copy avoids tenant as platform context.
|
||||
- [ ] Empty/unavailable states are honest.
|
||||
|
||||
### Scope
|
||||
|
||||
- [ ] Clean URL is workspace-wide.
|
||||
- [ ] Shell is Workspace-only.
|
||||
- [ ] Environment filter uses `environment_id`.
|
||||
- [ ] Visible Environment chip appears when filtered.
|
||||
- [ ] Clear filter works.
|
||||
- [ ] Reload after clear is safe.
|
||||
- [ ] Legacy aliases do not create filter state.
|
||||
- [ ] Cross-workspace Environment is rejected.
|
||||
|
||||
### RBAC
|
||||
|
||||
- [ ] Unauthorized user cannot access protected data.
|
||||
- [ ] Unauthorized actions are hidden/disabled.
|
||||
- [ ] Assign owner action respects capability if shown.
|
||||
- [ ] Exception/accepted-risk action respects capability if shown.
|
||||
- [ ] Evidence access respects capability.
|
||||
- [ ] Diagnostics access respects capability.
|
||||
|
||||
### UI / Visual
|
||||
|
||||
- [ ] Layout uses premium direction from Spec 325 without treating target images as runtime truth.
|
||||
- [ ] Filament light mode remains readable.
|
||||
- [ ] No heavy one-off CSS.
|
||||
- [ ] Right-side detail panel exists on desktop.
|
||||
- [ ] Workbench table/queue is not the only default experience.
|
||||
- [ ] Page remains responsive enough for Filament shell.
|
||||
|
||||
### Tests / Validation
|
||||
|
||||
- [ ] Repo truth map exists.
|
||||
- [ ] Required Feature tests pass.
|
||||
- [ ] Required Browser smoke passes.
|
||||
- [ ] Relevant Spec 314-322 guards still pass.
|
||||
- [ ] `pint --dirty` passes.
|
||||
- [ ] `git diff --check` passes.
|
||||
- [ ] Full suite status is honestly reported if run/not run.
|
||||
|
||||
## Follow-Up Spec Candidates
|
||||
|
||||
- Spec 328 - Operations Hub Decision-First Workbench Productization.
|
||||
- Spec 329 - Evidence / Audit Log Disclosure Productization.
|
||||
- Spec 330 - Environment Dashboard / Baseline Compare Productization.
|
||||
- Spec 331 - Restore Safety Workflow Productization.
|
||||
|
||||
Do not start these inside Spec 327.
|
||||
@ -0,0 +1,168 @@
|
||||
# Tasks: Spec 327 - Governance Inbox Decision-First Workbench Productization
|
||||
|
||||
**Input**: Design documents from `/specs/327-governance-inbox-decision-first-workbench-productization/`
|
||||
**Prerequisites**: `spec.md`, `plan.md`, `repo-truth-map.md`
|
||||
|
||||
**Tests**: Required. This is a runtime UI/operator workbench Filament page productization with browser smoke.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New or changed tests stay in the smallest honest family, and 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 decision-first disclosure) is explicit.
|
||||
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||
|
||||
## Phase 1: Preparation And Repo Truth
|
||||
|
||||
**Purpose**: Confirm runtime truth and prevent invented claims before page edits.
|
||||
|
||||
- [x] T001 Re-read `specs/327-governance-inbox-decision-first-workbench-productization/spec.md`, `plan.md`, `tasks.md`, and `repo-truth-map.md`.
|
||||
- [x] T002 Re-read related completed context only: Specs 250, 257, and 314-326. Do not modify their artifacts.
|
||||
- [x] T003 Verify current `GovernanceInbox` route/class/view/builder and existing tests before editing.
|
||||
- [x] T004 Update `repo-truth-map.md` with any newly discovered source, capability, fallback, or classification before runtime changes.
|
||||
- [x] T005 Confirm no migration/package/env/queue/storage/deployment asset change is required; if one appears necessary, stop and update spec/plan first.
|
||||
- [x] T006 Confirm Filament v5 / Livewire v4.0+ compliance and no Livewire v3/Filament legacy API use.
|
||||
- [x] T007 Confirm panel provider registration remains `apps/platform/bootstrap/providers.php`.
|
||||
- [x] T008 Confirm related globally searchable resources stay disabled or have safe View/Edit pages; no global search change is expected.
|
||||
|
||||
## Phase 2: Feature Tests First
|
||||
|
||||
**Purpose**: Lock decision-first layout, scope, RBAC, evidence, accepted-risk, and diagnostics behavior before the UI refactor.
|
||||
|
||||
- [x] T009 Add or update a feature test asserting `repo-truth-map.md` exists and lists required data areas.
|
||||
- [x] T010 Add or update a Feature/Livewire/HTTP test for the decision-first layout text: `Governance Inbox`, `What decision clears the highest-priority item?`, `Reason`, `Impact`, `Evidence`, and `Next action`.
|
||||
- [x] T011 Add or update a Feature/Livewire/HTTP test asserting a highest-priority or selected item shows owner or owner unavailable, due date or due unavailable, evidence state, accepted-risk/exception state, and primary next action.
|
||||
- [x] T012 Add or update a Feature/Livewire/HTTP test asserting the right decision detail panel contains `Decision summary`, `Impact`, `Evidence path`, `Accepted risk` or exception state, owner/due state, and primary next action.
|
||||
- [x] T013 Add or update a test asserting existing Governance Inbox queue/sections remain available as secondary workbench context.
|
||||
- [x] T014 Add or update a test that raw diagnostics are hidden by default: `raw payload`, `stack trace`, `debug metadata`, `provider secret`, `internal exception`, and raw OperationRun payload text must not appear.
|
||||
- [x] T015 Add or update accepted-risk/exception state tests for repo-supported states such as no exception, pending exception, accepted risk active, expiring, expired, or follow-up required.
|
||||
- [x] T016 Add or update evidence state tests proving linked/missing/unavailable evidence appears without raw evidence payload.
|
||||
- [x] T017 Add or update RBAC tests covering primary action visibility/unavailability for assign owner, create/update exception or accepted risk, open evidence, open operation proof, and open diagnostics where supported.
|
||||
- [x] T018 Add or update canonical environment filter tests for `?environment_id=`, visible chip, workspace shell only, clear filter, and provable filtered data.
|
||||
- [x] T019 Add or update legacy alias rejection tests for `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, and `tableFilters`.
|
||||
- [x] T020 Add or update cross-workspace environment filter guard test returning safe 404/no-access.
|
||||
- [x] T021 Add or update tenant-copy guard asserting platform-context copy such as `current tenant`, `tenant filter`, `entitled tenant`, and `all tenants` is not visible on Governance Inbox.
|
||||
|
||||
## Phase 3: Page Skeleton Productization
|
||||
|
||||
**Purpose**: Refactor existing page layout without new backend foundation.
|
||||
|
||||
- [x] T022 Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` to expose a repo-truth-bounded payload for header/scope, selected/highest-priority item, summary cards, queue context, detail panel, actions, evidence path, exception state, and diagnostics disclosure.
|
||||
- [x] T023 Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to render the decision-first workbench before the secondary queue context.
|
||||
- [x] T024 Ensure the header/scope area shows workspace-wide vs environment-filtered context, visible environment chip when filtered, and concise queue purpose copy.
|
||||
- [x] T025 Ensure the main decision workbench shows the stable question, status badge, title, reason, impact, owner, due, evidence state, accepted-risk/exception state, and one primary next action.
|
||||
- [x] T026 Ensure summary cards show only repo-backed posture such as visible decisions, overdue, owner missing, evidence missing, or accepted-risk follow-up; show unavailable or omit unsupported cards.
|
||||
- [x] T027 Ensure the right-side decision/detail panel shows decision summary, impact, owner/due, evidence path, accepted-risk/exception state, linked review/operation proof where available, primary next action, and diagnostics disclosure.
|
||||
- [x] T028 Ensure the right-side detail panel is visible on desktop and stacks below on smaller screens.
|
||||
- [x] T029 Keep the existing queue/table/source-family context as secondary content; it must not be the only default experience.
|
||||
- [x] T030 Ensure diagnostics/internal details are collapsed, hidden, or capability-gated by default.
|
||||
|
||||
## Phase 4: Data Binding And Honest States
|
||||
|
||||
**Purpose**: Bind to repo-verified sources and avoid false claims.
|
||||
|
||||
- [x] T031 Map selected/highest-priority item state from existing `GovernanceInboxSectionBuilder` entries and source models without creating persisted state.
|
||||
- [x] T032 Bind owner and due display to `Finding` and `FindingException` fields where present; show `Owner missing`, `Owner unavailable`, or `Due date unavailable` when absent.
|
||||
- [x] T033 Bind evidence display to existing evidence fields/relations only; show `Evidence missing`, `Unavailable`, or omit unsupported proof paths.
|
||||
- [x] T034 Bind accepted-risk/exception display to existing `FindingException` and `Finding` truth; do not introduce new status families.
|
||||
- [x] T035 Bind operation proof links only through existing `OperationRunLinks` or authorized source routes.
|
||||
- [x] T036 Bind decision/review links only where existing source routes and authorization are repo-real.
|
||||
- [x] T037 Ensure no generic green success state appears without exact repo-backed proof.
|
||||
|
||||
## Phase 5: Actions, RBAC, And Safety
|
||||
|
||||
**Purpose**: Show only real, authorized actions and preserve read-first default behavior.
|
||||
|
||||
- [x] T038 Keep primary action singular and context-aware for the selected/highest-priority item.
|
||||
- [x] T039 Show open finding, review accepted risk, open evidence, open operation proof, open review context, or open decision record only when route and authorization are repo-real.
|
||||
- [x] T040 Ensure unauthorized actions are hidden or unavailable without leaking sensitive details.
|
||||
- [x] T041 Verify no default action approves, rejects, accepts risk, closes, deletes, restores, remediates, or mutates provider state.
|
||||
- [x] T042 If any high-impact action is unexpectedly required, update spec/plan first, then implement it with `Action::make(...)->action(...)`, `->requiresConfirmation()`, server-side authorization, audit, notification, and tests.
|
||||
|
||||
## Phase 6: Workspace / Environment Scope Contract
|
||||
|
||||
**Purpose**: Preserve Specs 314-322.
|
||||
|
||||
- [x] T043 Verify clean `/admin/governance/inbox` does not read remembered environment shell state or persisted table filters.
|
||||
- [x] T044 Verify `/admin/governance/inbox?environment_id={id}` filters only page data, shows visible chip, and keeps Workspace shell ownership.
|
||||
- [x] T045 Verify clear filter redirects to clean workspace URL and remains safe after reload.
|
||||
- [x] T046 Verify legacy aliases are removed/neutralized and do not set filter state.
|
||||
- [x] T047 Verify cross-workspace or unauthorized `environment_id` returns safe no-access/404.
|
||||
- [x] T048 Verify back/forward/reload behavior does not resurrect cleared environment filter state.
|
||||
|
||||
## Phase 7: Browser Smoke And Screenshots
|
||||
|
||||
**Purpose**: Prove the user-facing contract in the integrated browser lane.
|
||||
|
||||
- [x] T049 Create `apps/platform/tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php` using existing Pest Browser conventions.
|
||||
- [x] T050 Browser Flow A: clean workspace entry; assert Workspace shell only, no Environment chip, main decision question, right detail panel, diagnostics collapsed, screenshot.
|
||||
- [x] T051 Browser Flow B: filtered environment entry; assert Workspace shell only, visible chip, clear filter action, filtered scope copy, screenshot.
|
||||
- [x] T052 Browser Flow C: clear filter and reload; assert clean URL, chip does not return, no active Environment shell.
|
||||
- [x] T053 Browser Flow D: selected/highest-priority item detail; assert detail panel and primary action visible and raw diagnostics absent.
|
||||
- [x] T054 Browser Flow E: table/queue remains visible lower/secondary and no platform-context tenant wording appears.
|
||||
- [x] T055 Browser Flow F: light mode readability check if supported; capture optional screenshot.
|
||||
- [x] T056 Save screenshots under `specs/327-governance-inbox-decision-first-workbench-productization/artifacts/screenshots/` when generated and ensure they contain no secrets.
|
||||
|
||||
## Phase 8: UI Coverage And Documentation Artifacts
|
||||
|
||||
**Purpose**: Satisfy UI-COV without unrelated docs churn.
|
||||
|
||||
- [x] T057 Decide after runtime diff whether `docs/ui-ux-enterprise-audit/route-inventory.md` or `design-coverage-matrix.md` needs an update.
|
||||
- [x] T058 If coverage docs are not changed, add a close-out note explaining why existing UI-004 report plus Spec 325 target artifacts remain sufficient for the unchanged route/archetype.
|
||||
- [x] T059 Update `repo-truth-map.md` final classifications for implemented/empty/deferred elements.
|
||||
- [x] T060 Do not create general documentation files outside required Spec Kit/UI coverage artifacts.
|
||||
|
||||
## Phase 9: Validation
|
||||
|
||||
**Purpose**: Run narrow proof and report honestly.
|
||||
|
||||
- [x] T061 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Governance tests/Feature/Navigation/WorkspaceHubEnvironmentFilterContractTest.php tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php --compact`.
|
||||
- [x] T062 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Browser/Spec327GovernanceInboxProductizationSmokeTest.php --compact`.
|
||||
- [x] T063 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter='GovernanceInbox|WorkspaceHub|EnvironmentFilter|ClearFilter|LegacyTenant|Spec322' --compact`.
|
||||
- [x] T064 Run `cd apps/platform && ./vendor/bin/sail pint --dirty`.
|
||||
- [x] T065 Run `git diff --check`.
|
||||
- [x] T066 Report full-suite status honestly if not run.
|
||||
- [x] T067 Confirm no migrations, seeders, packages, env vars, queues, scheduler, storage, deployment assets, backwards compatibility layer, or legacy tenant alias support were added.
|
||||
|
||||
## Non-Goals Checklist
|
||||
|
||||
- [x] NT001 Do not rebuild Governance Inbox backend.
|
||||
- [x] NT002 Do not rebuild Decision Register.
|
||||
- [x] NT003 Do not build a ticketing/helpdesk/PSA system.
|
||||
- [x] NT004 Do not add AI prioritization or remediation automation.
|
||||
- [x] NT005 Do not redesign Customer Review Workspace, Operations Hub, Evidence Overview, Environment Dashboard, Baseline Compare, or Restore Safety Workflow.
|
||||
- [x] NT006 Do not add migrations unless spec/plan are updated first with proof.
|
||||
- [x] NT007 Do not rewrite completed Specs 250, 257, or 314-326.
|
||||
- [x] NT008 Do not add legacy tenant query alias support.
|
||||
|
||||
## Implementation Close-Out Notes
|
||||
|
||||
- Spec 327 implementation stayed bounded to the existing Governance Inbox page, section builder, Blade view, feature tests, browser smoke, and spec artifacts.
|
||||
- Follow-up refinement kept the same Spec 327 package and tightened the page hierarchy: the duplicate in-view heading was removed, Filament owns the single page title/subtitle, filter chips are secondary, zero metric cards no longer dominate the empty primary experience, and the decision/evidence workbench is the first dominant surface.
|
||||
- Browser screenshot artifacts were generated by Pest Browser and copied host-side into `specs/327-governance-inbox-decision-first-workbench-productization/artifacts/screenshots/`. The Sail container sees the repository spec path as read-only, so the browser helper keeps artifact copying best-effort while retaining Pest screenshots under `apps/platform/tests/Browser/Screenshots/`.
|
||||
- The UI coverage registry and route inventory were not changed because `/admin/governance/inbox` remains the existing UI-028 strategic surface and route/archetype classification did not change; this spec package carries the productization proof, repo truth map, tests, and screenshots.
|
||||
- No new mutating governance action was added. Primary actions remain repo-real navigation/source handoff links; destructive or provider-changing actions remain out of scope.
|
||||
- The broad Sail filter was rerun after the final asset fix. Spec327 passed inside that run. The remaining failures were outside Spec327 in `Spec316WorkspaceHubClearFilterSmokeTest`, where the Operations page screenshot already showed clean all-environment state at the click timeout. The same Spec316 browser file passed when rerun by itself, so this is recorded as browser-suite timing/interference residual rather than an in-scope Spec327 regression.
|
||||
- Requested refinement validation passed on Sail: `tests/Feature/Governance`, `WorkspaceHubEnvironmentFilterContractTest`, `WorkspaceHubClearFilterContractTest`, `Spec327GovernanceInboxProductizationSmokeTest.php`, `pint --dirty`, and `git diff --check`.
|
||||
- Final non-empty workbench proof uses a repo-backed finding fixture, asserts visible decision title/reason/impact/owner/due/evidence/accepted-risk/primary action fields, verifies the desktop right-side `<aside>` geometry, and writes `artifacts/screenshots/governance-inbox-decision-workbench.png`.
|
||||
|
||||
## Required Final Report Content
|
||||
|
||||
When implementation later completes, report:
|
||||
|
||||
- Changed behavior.
|
||||
- Decision-first workbench details.
|
||||
- Evidence / Accepted Risk / Owner / Due coverage.
|
||||
- Files changed.
|
||||
- Repo truth map status.
|
||||
- Tests run and results.
|
||||
- Browser verification and screenshots path.
|
||||
- Known gaps.
|
||||
- Remaining follow-ups.
|
||||
- Diagnostics default state.
|
||||
- RBAC-visible/hidden actions.
|
||||
- Repo-verified vs unavailable states.
|
||||
- Full suite run/not run.
|
||||
- Explicit no migrations/seeders/packages/env/queues/scheduler/storage/deployment assets/backcompat/legacy aliases statement.
|
||||
Loading…
Reference in New Issue
Block a user