feat: add governance inbox resolution intake (#460)

Automated PR created by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #460
This commit is contained in:
ahmido 2026-06-20 07:46:12 +00:00
parent 83c679cf85
commit 9912d94563
22 changed files with 4002 additions and 68 deletions

View File

@ -15,6 +15,7 @@
use App\Support\Auth\Capabilities;
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\GovernanceInbox\ReviewPublicationResolutionInboxProvider;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\WorkspaceHubEnvironmentFilter;
@ -115,6 +116,11 @@ class GovernanceInbox extends Page
*/
private ?array $unfilteredInboxPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $reviewPublicationResolutionUnfilteredPayload = null;
/**
* @var array<string, mixed>|null
*/
@ -135,6 +141,10 @@ class GovernanceInbox extends Page
public ?string $family = null;
public ?string $status = null;
public ?string $updated = null;
public function getSubheading(): ?string
{
return 'Daily operator queue for governance follow-up, accepted risk, evidence gaps, and review handoff.';
@ -166,6 +176,12 @@ public function mount(): void
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
$this->family = $this->resolveRequestedFamily();
$this->status = $this->family === ReviewPublicationResolutionInboxProvider::FAMILY_KEY
? $this->resolveRequestedReviewPublicationStatus()
: null;
$this->updated = $this->family === ReviewPublicationResolutionInboxProvider::FAMILY_KEY
? $this->resolveRequestedReviewPublicationUpdated()
: null;
$this->ensureAtLeastOneVisibleFamily();
$this->ensureRequestedFamilyIsVisible();
}
@ -186,6 +202,12 @@ public function appliedScope(): array
'family_label' => $this->family !== null
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
: 'All source families',
'status_key' => $this->status,
'status_label' => $this->status !== null
? ReviewPublicationResolutionInboxProvider::statusLabel($this->status)
: 'All active statuses',
'updated_key' => $this->updated,
'updated_label' => ReviewPublicationResolutionInboxProvider::updatedLabel($this->updated),
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
];
}
@ -355,7 +377,16 @@ public function calmEmptyState(): array
'title' => 'This source focus is hiding other governance work',
'body' => 'The current source-family focus is calm, but other repo-backed governance items remain open in this workspace.',
'action_label' => 'Show all source families',
'action_url' => $this->pageUrl(['family' => null]),
'action_url' => $this->pageUrl(['family' => null, 'status' => null, 'updated' => null]),
];
}
if ($this->reviewPublicationResolutionFiltersAloneExcludeRows()) {
return [
'title' => 'These review publication filters are hiding active preparation work',
'body' => 'The current status or updated-date focus has no matching review publication preparation items, but other active preparation items remain visible in this scope.',
'action_label' => 'Clear review publication filters',
'action_url' => $this->pageUrl(['status' => null, 'updated' => null]),
];
}
@ -377,6 +408,51 @@ public function isActiveFamily(?string $familyKey): bool
return $this->family === $familyKey;
}
/**
* @return list<array{key: string|null, label: string, active: bool, url: string}>
*/
public function reviewPublicationStatusFilters(): array
{
return collect([null, ...ReviewPublicationResolutionInboxProvider::STATUS_FILTERS])
->map(fn (?string $status): array => [
'key' => $status,
'label' => $status === null
? 'All active statuses'
: ReviewPublicationResolutionInboxProvider::statusLabel($status),
'active' => $this->status === $status,
'url' => $this->pageUrl([
'family' => ReviewPublicationResolutionInboxProvider::FAMILY_KEY,
'status' => $status,
]).'#source-detail',
])
->values()
->all();
}
/**
* @return list<array{key: string|null, label: string, active: bool, url: string}>
*/
public function reviewPublicationUpdatedFilters(): array
{
return collect([null, ...ReviewPublicationResolutionInboxProvider::UPDATED_FILTERS])
->map(fn (?string $updated): array => [
'key' => $updated,
'label' => ReviewPublicationResolutionInboxProvider::updatedLabel($updated),
'active' => $this->updated === $updated,
'url' => $this->pageUrl([
'family' => ReviewPublicationResolutionInboxProvider::FAMILY_KEY,
'updated' => $updated,
]).'#source-detail',
])
->values()
->all();
}
public function hasReviewPublicationResolutionFocus(): bool
{
return $this->family === ReviewPublicationResolutionInboxProvider::FAMILY_KEY;
}
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
@ -386,12 +462,21 @@ public function pageUrl(array $overrides = []): string
$resolvedFamily = array_key_exists('family', $overrides)
? $overrides['family']
: $this->family;
$hasReviewPublicationFocus = $resolvedFamily === ReviewPublicationResolutionInboxProvider::FAMILY_KEY;
$resolvedStatus = $hasReviewPublicationFocus
? (array_key_exists('status', $overrides) ? $overrides['status'] : $this->status)
: null;
$resolvedUpdated = $hasReviewPublicationFocus
? (array_key_exists('updated', $overrides) ? $overrides['updated'] : $this->updated)
: null;
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'environment_id' => (is_string($resolvedTenant) || is_numeric($resolvedTenant)) && (string) $resolvedTenant !== '' ? (string) $resolvedTenant : null,
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
'status' => is_string($resolvedStatus) && $resolvedStatus !== '' ? $resolvedStatus : null,
'updated' => is_string($resolvedUpdated) && $resolvedUpdated !== '' ? $resolvedUpdated : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
@ -561,6 +646,11 @@ private function classifyLane(array $entry): string
'intake_findings' => 'needs_triage',
'finding_exceptions' => 'risk_exception_review',
'stale_operations', 'alert_delivery_failures' => 'blocked',
ReviewPublicationResolutionInboxProvider::FAMILY_KEY => match ((string) ($entry['inbox_status'] ?? 'needs_attention')) {
'failed', 'blocked' => 'blocked',
'needs_attention', 'needs_recheck' => 'evidence_required',
default => 'requires_decision',
},
'assigned_findings' => (($entry['evidence_state'] ?? null) === 'missing')
? 'evidence_required'
: 'requires_decision',
@ -658,6 +748,10 @@ private function secondaryActionsForEntry(array $entry, ?ManagedEnvironment $ten
? (int) $entry['managed_environment_id']
: null;
foreach ($this->normalizeLinks($entry['secondary_actions'] ?? []) as $action) {
$this->appendUniqueLink($actions, $action['label'], $action['url'], [$primaryActionUrl]);
}
if (in_array($familyKey, ['assigned_findings', 'intake_findings', 'finding_exceptions'], true)) {
$this->appendUniqueLink(
$actions,
@ -685,6 +779,15 @@ private function secondaryActionsForEntry(array $entry, ?ManagedEnvironment $ten
);
}
if ($familyKey === ReviewPublicationResolutionInboxProvider::FAMILY_KEY && $tenant instanceof ManagedEnvironment) {
$this->appendUniqueLink(
$actions,
'Open environment',
ManagedEnvironmentLinks::viewUrl($tenant),
[$primaryActionUrl],
);
}
if (in_array($familyKey, ['stale_operations', 'alert_delivery_failures', 'assigned_findings', 'intake_findings'], true) && $tenant instanceof ManagedEnvironment) {
$this->appendUniqueLink(
$actions,
@ -724,6 +827,10 @@ private function linkedRecordsForEntry(array $entry, ?ManagedEnvironment $tenant
$familyKey = (string) ($entry['family_key'] ?? '');
$records = [];
foreach ($this->normalizeLinks($entry['linked_records'] ?? []) as $record) {
$this->appendUniqueLink($records, $record['label'], $record['url']);
}
$this->appendUniqueLink($records, 'Source record', $entry['destination_url'] ?? null);
$this->appendUniqueLink($records, 'Evidence path', $entry['evidence_path_url'] ?? null);
@ -742,6 +849,38 @@ private function linkedRecordsForEntry(array $entry, ?ManagedEnvironment $tenant
return array_slice($records, 0, 4);
}
/**
* @return list<array{label: string, url: string}>
*/
private function normalizeLinks(mixed $links): array
{
if (! is_array($links)) {
return [];
}
$normalized = [];
foreach ($links as $link) {
if (! is_array($link)) {
continue;
}
$label = $link['label'] ?? null;
$url = $link['url'] ?? null;
if (! is_string($label) || $label === '' || ! is_string($url) || $url === '') {
continue;
}
$normalized[] = [
'label' => $label,
'url' => $url,
];
}
return $normalized;
}
/**
* @param list<array{label: string, url: string}> $links
* @param list<string|null> $ignoredUrls
@ -1107,9 +1246,32 @@ private function resolveRequestedFamily(): ?string
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
ReviewPublicationResolutionInboxProvider::FAMILY_KEY,
], true) ? $family : null;
}
private function resolveRequestedReviewPublicationStatus(): ?string
{
$status = request()->query('status');
if (! is_string($status)) {
return null;
}
return in_array($status, ReviewPublicationResolutionInboxProvider::STATUS_FILTERS, true) ? $status : null;
}
private function resolveRequestedReviewPublicationUpdated(): ?string
{
$updated = request()->query('updated');
if (! is_string($updated)) {
return null;
}
return in_array($updated, ReviewPublicationResolutionInboxProvider::UPDATED_FILTERS, true) ? $updated : null;
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
@ -1156,6 +1318,8 @@ private function inboxPayload(): array
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family,
selectedReviewPublicationStatus: $this->status,
selectedReviewPublicationUpdated: $this->updated,
navigationContext: $this->navigationContext(),
);
}
@ -1191,6 +1355,45 @@ private function unfilteredInboxPayload(): array
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: null,
selectedFamily: null,
selectedReviewPublicationStatus: null,
selectedReviewPublicationUpdated: null,
navigationContext: $this->navigationContext(),
);
}
/**
* @return array<string, mixed>
*/
private function reviewPublicationResolutionUnfilteredPayload(): array
{
if (is_array($this->reviewPublicationResolutionUnfilteredPayload)) {
return $this->reviewPublicationResolutionUnfilteredPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->reviewPublicationResolutionUnfilteredPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->reviewPublicationResolutionUnfilteredPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: ReviewPublicationResolutionInboxProvider::FAMILY_KEY,
selectedReviewPublicationStatus: null,
selectedReviewPublicationUpdated: null,
navigationContext: $this->navigationContext(),
);
}
@ -1212,7 +1415,7 @@ private function selectedTenant(): ?ManagedEnvironment
private function tenantFilterAloneExcludesRows(): bool
{
if (! is_int($this->tenantId) || $this->family !== null) {
if (! is_int($this->tenantId) || $this->family !== null || $this->status !== null || $this->updated !== null) {
return false;
}
@ -1229,10 +1432,35 @@ private function familyFilterAloneExcludesRows(): bool
return false;
}
if ($this->status !== null || $this->updated !== null) {
return false;
}
if ($this->laneGroups() !== []) {
return false;
}
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
}
private function reviewPublicationResolutionFiltersAloneExcludeRows(): bool
{
if ($this->family !== ReviewPublicationResolutionInboxProvider::FAMILY_KEY) {
return false;
}
if ($this->status === null && $this->updated === null) {
return false;
}
if ($this->laneGroups() !== []) {
return false;
}
return (int) data_get(
$this->reviewPublicationResolutionUnfilteredPayload(),
'family_counts.'.ReviewPublicationResolutionInboxProvider::FAMILY_KEY,
0,
) > 0;
}
}

View File

@ -43,6 +43,7 @@
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
ReviewPublicationResolutionInboxProvider::FAMILY_KEY,
];
public function __construct(
@ -50,6 +51,7 @@ public function __construct(
private RestoreSafetyResolver $restoreSafetyResolver,
private ManagedEnvironmentTriageReviewStateResolver $managedEnvironmentTriageReviewStateResolver,
private EnvironmentReviewRegisterService $environmentReviewRegisterService,
private ReviewPublicationResolutionInboxProvider $reviewPublicationResolutionInboxProvider,
) {}
/**
@ -73,6 +75,8 @@ public function build(
bool $canViewFindingExceptions = false,
?ManagedEnvironment $selectedTenant = null,
?string $selectedFamily = null,
?string $selectedReviewPublicationStatus = null,
?string $selectedReviewPublicationUpdated = null,
?CanonicalNavigationContext $navigationContext = null,
): array {
$authorizedTenantsById = $this->indexTenants($authorizedTenants);
@ -175,6 +179,24 @@ public function build(
'count' => $reviewSection['count'],
];
$familyCounts[$reviewSection['key']] = $reviewSection['count'];
$reviewPublicationResolutionSection = $this->reviewPublicationResolutionInboxProvider->section(
user: $user,
workspace: $workspace,
reviewTenants: $reviewTenantsById,
selectedTenant: $selectedTenant,
selectedStatus: $selectedReviewPublicationStatus,
selectedUpdated: $selectedReviewPublicationUpdated,
navigationContext: $navigationContext,
previewLimit: self::PREVIEW_LIMIT,
);
$allSections[$reviewPublicationResolutionSection['key']] = $reviewPublicationResolutionSection;
$availableFamilies[] = [
'key' => $reviewPublicationResolutionSection['key'],
'label' => $reviewPublicationResolutionSection['label'],
'count' => $reviewPublicationResolutionSection['count'],
];
$familyCounts[$reviewPublicationResolutionSection['key']] = $reviewPublicationResolutionSection['count'];
}
$sections = [];

View File

@ -0,0 +1,821 @@
<?php
declare(strict_types=1);
namespace App\Support\GovernanceInbox;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\ReviewPublicationResolutionStep;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ReviewPublicationResolution\ResolutionProofCurrentness;
use App\Support\ReviewPublicationResolution\ResolutionProofEvaluation;
use App\Support\ReviewPublicationResolution\ResolutionProofUsability;
use App\Support\ReviewPublicationResolution\ResolutionProofVisibility;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepAuthorizer;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepKey;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepStatus;
use Illuminate\Support\Facades\Gate;
final readonly class ReviewPublicationResolutionInboxProvider
{
public const string FAMILY_KEY = 'review_publication_resolution';
/**
* @var list<string>
*/
public const array STATUS_FILTERS = [
'needs_attention',
'needs_recheck',
'waiting',
'ready_to_continue',
'failed',
'blocked',
];
/**
* @var list<string>
*/
public const array UPDATED_FILTERS = [
'last_24_hours',
'last_7_days',
'last_30_days',
];
public function __construct(
private ReviewPublicationResolutionStepAuthorizer $stepAuthorizer,
) {}
/**
* @param array<int, ManagedEnvironment> $reviewTenants
* @return array<string, mixed>
*/
public function section(
User $user,
Workspace $workspace,
array $reviewTenants,
?ManagedEnvironment $selectedTenant,
?string $selectedStatus,
?string $selectedUpdated,
?CanonicalNavigationContext $navigationContext,
int $previewLimit,
): array {
$tenantIds = $this->scopedTenantIds($reviewTenants, $selectedTenant);
if ($tenantIds === []) {
return $this->emptySection($selectedTenant, $selectedStatus, $selectedUpdated);
}
$query = ReviewPublicationResolutionCase::query()
->forWorkspace((int) $workspace->getKey())
->active()
->whereIn('managed_environment_id', $tenantIds);
if (in_array($selectedUpdated, self::UPDATED_FILTERS, true)) {
$query->where('updated_at', '>=', $this->updatedSince($selectedUpdated));
}
$previewEntries = collect();
$count = 0;
$statusCounts = [];
$shouldFilterStatus = in_array($selectedStatus, self::STATUS_FILTERS, true);
(clone $query)
->with([
'tenant',
'environmentReview.tenant',
'steps.operationRun',
'assignee',
'creator',
])
->chunkById(100, function ($cases) use (
$user,
$navigationContext,
$selectedStatus,
$shouldFilterStatus,
$previewLimit,
&$previewEntries,
&$count,
&$statusCounts,
): void {
foreach ($cases as $case) {
if (! $case instanceof ReviewPublicationResolutionCase || ! Gate::forUser($user)->allows('view', $case)) {
continue;
}
$entry = $this->entry($case, $user, $navigationContext);
if (! is_array($entry)) {
continue;
}
if ($shouldFilterStatus && ($entry['inbox_status'] ?? null) !== $selectedStatus) {
continue;
}
$count++;
$status = (string) ($entry['inbox_status'] ?? 'needs_recheck');
$statusCounts[$status] = (int) ($statusCounts[$status] ?? 0) + 1;
$previewEntries->push($entry);
$previewEntries = $this->sortEntries($previewEntries)
->take($previewLimit)
->values();
}
});
return [
'key' => self::FAMILY_KEY,
'label' => 'Review publication work',
'count' => $count,
'summary' => $this->summary($count, $statusCounts, $selectedStatus, $selectedUpdated),
'dominant_action_label' => 'Review publication work',
'dominant_action_url' => null,
'entries' => $previewEntries
->map(fn (array $entry): array => $this->withoutInternalSortKeys($entry))
->values()
->all(),
'empty_state' => $this->emptyState($selectedTenant, $selectedStatus, $selectedUpdated),
];
}
public static function statusLabel(string $status): string
{
return match ($status) {
'needs_attention' => 'Needs attention',
'needs_recheck' => 'Needs re-check',
'waiting' => 'Waiting',
'ready_to_continue' => 'Ready to continue',
'failed' => 'Failed',
'blocked' => 'Blocked',
default => 'Needs attention',
};
}
public static function updatedLabel(?string $updated): string
{
return match ($updated) {
'last_24_hours' => 'Last 24 hours',
'last_7_days' => 'Last 7 days',
'last_30_days' => 'Last 30 days',
default => 'Any time',
};
}
/**
* @param array<int, ManagedEnvironment> $reviewTenants
* @return list<int>
*/
private function scopedTenantIds(array $reviewTenants, ?ManagedEnvironment $selectedTenant): array
{
if ($selectedTenant instanceof ManagedEnvironment) {
return array_key_exists((int) $selectedTenant->getKey(), $reviewTenants)
? [(int) $selectedTenant->getKey()]
: [];
}
return array_map(
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
$reviewTenants,
);
}
/**
* @return array<string, mixed>|null
*/
private function entry(
ReviewPublicationResolutionCase $case,
User $user,
?CanonicalNavigationContext $navigationContext,
): ?array {
$tenant = $case->tenant;
$review = $case->environmentReview;
if (! $tenant instanceof ManagedEnvironment || ! $review instanceof EnvironmentReview) {
return null;
}
$currentStep = $case->currentStep();
$canExecute = $this->stepAuthorizer->canExecuteCurrentStep($user, $case);
$operationAction = $this->operationAction($case, $currentStep, $user, $tenant, $navigationContext);
$inboxStatus = $this->inboxStatus($case, $currentStep, $canExecute, $operationAction !== null);
$primaryAction = $this->primaryAction($inboxStatus, $operationAction);
$resolutionUrl = EnvironmentReviewResource::environmentScopedUrl(
'resolve-publication',
['record' => $review],
$tenant,
);
$reviewUrl = EnvironmentReviewResource::environmentScopedUrl(
'view',
['record' => $review],
$tenant,
);
return [
'family_key' => self::FAMILY_KEY,
'source_model' => ReviewPublicationResolutionCase::class,
'source_key' => (string) $case->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'tenant_label' => $tenant->name,
'headline' => $this->headline($inboxStatus),
'subline' => $this->subline($case, $review, $currentStep),
'urgency_rank' => $this->urgencyRank($inboxStatus),
'status_label' => self::statusLabel($inboxStatus),
'inbox_status' => $inboxStatus,
'destination_url' => $resolutionUrl,
'reason_label' => $this->reasonLabel($inboxStatus, $currentStep, $canExecute),
'impact_label' => $this->impactLabel($inboxStatus),
'owner_label' => $case->assignee?->name ?? $case->creator?->name ?? 'Owner unavailable',
'due_label' => 'No due date set',
'evidence_label' => $this->evidenceLabel($currentStep),
'exception_label' => 'Publication preparation',
'primary_action_label' => $primaryAction['label'],
'primary_action_url' => $primaryAction['url'] ?? $resolutionUrl,
'secondary_actions' => array_values(array_filter([
[
'label' => 'Open review',
'url' => $reviewUrl,
],
($operationAction['url'] ?? null) !== ($primaryAction['url'] ?? null) ? $operationAction : null,
])),
'linked_records' => array_values(array_filter([
[
'label' => 'Resolution preparation',
'url' => $resolutionUrl,
],
[
'label' => 'Review',
'url' => $reviewUrl,
],
$operationAction,
])),
'updated_sort' => $case->updated_at?->getTimestamp() ?? 0,
'back_label' => 'Back to governance inbox',
];
}
/**
* @return array{label: string, url: string}|null
*/
private function operationAction(
ReviewPublicationResolutionCase $case,
?ReviewPublicationResolutionStep $currentStep,
User $user,
ManagedEnvironment $tenant,
?CanonicalNavigationContext $navigationContext,
): ?array {
if (! $this->canDiscloseOperationRun($case, $currentStep, $user)) {
return null;
}
/** @var OperationRun $operationRun */
$operationRun = $currentStep->operationRun;
return [
'label' => OperationRunLinks::openLabel(),
'url' => OperationRunLinks::view($operationRun, $tenant, $navigationContext),
];
}
private function canDiscloseOperationRun(
ReviewPublicationResolutionCase $case,
?ReviewPublicationResolutionStep $currentStep,
User $user,
): bool {
if (! $currentStep instanceof ReviewPublicationResolutionStep) {
return false;
}
$operationRun = $currentStep->operationRun;
$stepKey = $currentStep->stepKeyEnum();
if (! $operationRun instanceof OperationRun || ! $stepKey instanceof ReviewPublicationResolutionStepKey) {
return false;
}
if (! is_numeric($currentStep->operation_run_id) || (int) $currentStep->operation_run_id !== (int) $operationRun->getKey()) {
return false;
}
if ((int) $operationRun->workspace_id !== (int) $case->workspace_id
|| (int) $operationRun->managed_environment_id !== (int) $case->managed_environment_id) {
return false;
}
if (! Gate::forUser($user)->allows('view', $operationRun)) {
return false;
}
if (! $this->operationTypeMatchesStep($operationRun, $stepKey)) {
return false;
}
if (! $this->operationStateMatchesStep($operationRun, $currentStep->statusEnum())) {
return false;
}
if (! $this->operationContextMatchesCase($operationRun, $case)) {
return false;
}
if (! $this->safeCurrentProofMetadata($currentStep)) {
return false;
}
return $this->proofMatchesReview($case, $currentStep, $operationRun);
}
private function operationTypeMatchesStep(OperationRun $operationRun, ReviewPublicationResolutionStepKey $stepKey): bool
{
$actualType = OperationCatalog::canonicalCode((string) $operationRun->type);
$expectedTypes = array_map(
static fn (string $type): string => OperationCatalog::canonicalCode($type),
$this->expectedOperationTypes($stepKey),
);
return in_array($actualType, $expectedTypes, true);
}
private function operationStateMatchesStep(OperationRun $operationRun, ReviewPublicationResolutionStepStatus $stepStatus): bool
{
if ($stepStatus === ReviewPublicationResolutionStepStatus::Running) {
return in_array((string) $operationRun->status, [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
], true)
&& (string) $operationRun->outcome === OperationRunOutcome::Pending->value;
}
if ($stepStatus === ReviewPublicationResolutionStepStatus::Failed) {
return (string) $operationRun->status === OperationRunStatus::Completed->value
&& in_array((string) $operationRun->outcome, [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::Failed->value,
], true);
}
return false;
}
private function operationContextMatchesCase(OperationRun $operationRun, ReviewPublicationResolutionCase $case): bool
{
$context = is_array($operationRun->context) ? $operationRun->context : [];
foreach ([
'workspace_id' => (int) $case->workspace_id,
'managed_environment_id' => (int) $case->managed_environment_id,
'review_publication_resolution_case_id' => (int) $case->getKey(),
] as $key => $expectedValue) {
$value = $context[$key] ?? null;
if (! is_numeric($value) || (int) $value !== $expectedValue) {
return false;
}
}
$reviewIds = collect([
$context['environment_review_id'] ?? null,
$context['review_id'] ?? null,
])
->filter(fn (mixed $value): bool => is_numeric($value))
->map(fn (mixed $value): int => (int) $value)
->unique()
->values();
if ($reviewIds->count() !== 1 || $reviewIds->first() !== (int) $case->environment_review_id) {
return false;
}
return ($context['trigger'] ?? null) === 'review_publication_resolution';
}
private function safeCurrentProofMetadata(ReviewPublicationResolutionStep $step): bool
{
if ((string) data_get($step->metadata, 'proof_currentness') !== ResolutionProofCurrentness::Current->value) {
return false;
}
if ((string) data_get($step->metadata, 'proof_visibility') !== ResolutionProofVisibility::OperatorVisible->value) {
return false;
}
if (! in_array((string) data_get($step->metadata, 'proof_usability'), [
ResolutionProofUsability::Usable->value,
ResolutionProofUsability::UsableWithWarning->value,
ResolutionProofUsability::InspectionOnly->value,
], true)) {
return false;
}
$summary = data_get($step->metadata, 'proof_summary');
if (! is_array($summary)) {
return false;
}
return ResolutionProofEvaluation::sanitizeSummary($summary) === $summary;
}
private function proofMatchesReview(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
OperationRun $operationRun,
): bool {
$stepKey = $step->stepKeyEnum();
if (! $stepKey instanceof ReviewPublicationResolutionStepKey || ! is_string($step->proof_type) || ! is_numeric($step->proof_id)) {
return false;
}
if ($stepKey === ReviewPublicationResolutionStepKey::CompleteRequiredReports) {
return $step->proof_type === 'operation_run'
&& (int) $step->proof_id === (int) $operationRun->getKey();
}
return match ($stepKey) {
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => $step->proof_type === 'evidence_snapshot'
&& EvidenceSnapshot::query()
->whereKey((int) $step->proof_id)
->where('workspace_id', (int) $case->workspace_id)
->where('managed_environment_id', (int) $case->managed_environment_id)
->where('operation_run_id', (int) $operationRun->getKey())
->exists(),
ReviewPublicationResolutionStepKey::RefreshReviewComposition => $step->proof_type === 'environment_review'
&& (int) $step->proof_id === (int) $case->environment_review_id
&& EnvironmentReview::query()
->whereKey((int) $case->environment_review_id)
->where('workspace_id', (int) $case->workspace_id)
->where('managed_environment_id', (int) $case->managed_environment_id)
->where('operation_run_id', (int) $operationRun->getKey())
->exists(),
ReviewPublicationResolutionStepKey::GenerateReviewPack => $step->proof_type === 'review_pack'
&& ReviewPack::query()
->whereKey((int) $step->proof_id)
->where('workspace_id', (int) $case->workspace_id)
->where('managed_environment_id', (int) $case->managed_environment_id)
->where('environment_review_id', (int) $case->environment_review_id)
->where('operation_run_id', (int) $operationRun->getKey())
->exists(),
default => false,
};
}
/**
* @return list<string>
*/
private function expectedOperationTypes(ReviewPublicationResolutionStepKey $stepKey): array
{
return match ($stepKey) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports => [
'provider.connection.check',
OperationRunType::EntraAdminRolesScan->value,
],
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => [
OperationRunType::EvidenceSnapshotGenerate->value,
],
ReviewPublicationResolutionStepKey::RefreshReviewComposition => [
OperationRunType::EnvironmentReviewCompose->value,
],
ReviewPublicationResolutionStepKey::GenerateReviewPack => [
OperationRunType::ReviewPackGenerate->value,
],
ReviewPublicationResolutionStepKey::ValidateReviewReadiness,
ReviewPublicationResolutionStepKey::ReturnToPublication => [],
};
}
/**
* @return array<string, mixed>
*/
private function emptySection(
?ManagedEnvironment $selectedTenant,
?string $selectedStatus,
?string $selectedUpdated,
): array {
return [
'key' => self::FAMILY_KEY,
'label' => 'Review publication work',
'count' => 0,
'summary' => $this->summary(0, [], $selectedStatus, $selectedUpdated),
'dominant_action_label' => 'Review publication work',
'dominant_action_url' => null,
'entries' => [],
'empty_state' => $this->emptyState($selectedTenant, $selectedStatus, $selectedUpdated),
];
}
/**
* @param array<string, int> $statusCounts
*/
private function summary(int $count, array $statusCounts, ?string $selectedStatus, ?string $selectedUpdated): string
{
if ($count === 0) {
return $this->filterSummaryPrefix($selectedStatus, $selectedUpdated).'No active review publication preparation is visible.';
}
$attentionCount = (int) ($statusCounts['needs_attention'] ?? 0);
$waitingCount = (int) ($statusCounts['waiting'] ?? 0);
$blockedCount = (int) ($statusCounts['failed'] ?? 0) + (int) ($statusCounts['blocked'] ?? 0);
return sprintf(
'%s%d active review publication preparation %s visible; %d need attention, %d waiting, %d failed or blocked.',
$this->filterSummaryPrefix($selectedStatus, $selectedUpdated),
$count,
$count === 1 ? 'item is' : 'items are',
$attentionCount,
$waitingCount,
$blockedCount,
);
}
private function filterSummaryPrefix(?string $selectedStatus, ?string $selectedUpdated): string
{
$parts = [];
if (in_array($selectedStatus, self::STATUS_FILTERS, true)) {
$parts[] = self::statusLabel($selectedStatus);
}
if (in_array($selectedUpdated, self::UPDATED_FILTERS, true)) {
$parts[] = self::updatedLabel($selectedUpdated);
}
return $parts === [] ? '' : implode(' / ', $parts).': ';
}
private function emptyState(?ManagedEnvironment $selectedTenant, ?string $selectedStatus, ?string $selectedUpdated): string
{
if (in_array($selectedStatus, self::STATUS_FILTERS, true) || in_array($selectedUpdated, self::UPDATED_FILTERS, true)) {
return 'No review publication preparation work matches these filters right now.';
}
if ($selectedTenant instanceof ManagedEnvironment) {
return 'No review publication preparation work matches this environment filter right now.';
}
return 'No active review publication preparation work needs attention right now.';
}
private function headline(string $status): string
{
return match ($status) {
'waiting' => 'Review preparation is running',
'ready_to_continue' => 'Review preparation can continue',
'failed' => 'Review preparation action failed',
'blocked' => 'Review preparation needs operator access',
'needs_recheck' => 'Review preparation needs re-check',
default => 'Review cannot be published yet',
};
}
private function subline(
ReviewPublicationResolutionCase $case,
EnvironmentReview $review,
?ReviewPublicationResolutionStep $currentStep,
): string {
$stepLabel = $this->stepLabel($currentStep?->stepKeyEnum());
$generatedAt = $review->generated_at?->format('M j, Y H:i') ?? 'date unavailable';
return sprintf(
'%s · Review generated %s · Case updated %s',
$stepLabel,
$generatedAt,
$case->updated_at?->diffForHumans() ?? 'recently',
);
}
private function inboxStatus(
ReviewPublicationResolutionCase $case,
?ReviewPublicationResolutionStep $currentStep,
bool $canExecute,
bool $hasValidatedOperation,
): string {
$caseStatus = $case->statusEnum();
$stepStatus = $currentStep?->statusEnum();
$stepKey = $currentStep?->stepKeyEnum();
if (! $currentStep instanceof ReviewPublicationResolutionStep || ! $stepStatus instanceof ReviewPublicationResolutionStepStatus) {
return 'needs_recheck';
}
if ($stepStatus === ReviewPublicationResolutionStepStatus::Running) {
return $hasValidatedOperation ? 'waiting' : 'needs_recheck';
}
if ($stepStatus === ReviewPublicationResolutionStepStatus::Failed) {
return $hasValidatedOperation ? 'failed' : 'needs_recheck';
}
if ($caseStatus === ReviewPublicationResolutionCaseStatus::ReadyToContinue
|| $stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication) {
if ($stepKey !== ReviewPublicationResolutionStepKey::ReturnToPublication
|| ! $this->safeReadyToContinueProofMetadata($case, $currentStep)) {
return 'needs_recheck';
}
return $canExecute ? 'ready_to_continue' : 'blocked';
}
if ($stepStatus === ReviewPublicationResolutionStepStatus::Actionable
|| $stepStatus === ReviewPublicationResolutionStepStatus::Pending) {
return $canExecute ? 'needs_attention' : 'blocked';
}
if ($caseStatus === ReviewPublicationResolutionCaseStatus::Blocked) {
return 'blocked';
}
return 'needs_recheck';
}
private function reasonLabel(string $status, ?ReviewPublicationResolutionStep $currentStep, bool $canExecute): string
{
if ($status === 'failed') {
return 'The linked preparation operation failed and needs inspection before retry.';
}
if ($status === 'waiting') {
return 'TenantPilot is waiting for the linked preparation operation to finish.';
}
if ($status === 'ready_to_continue') {
return 'All preparation checks are resolved and the review can continue from the existing workflow.';
}
if ($status === 'blocked') {
return $canExecute
? 'The preparation case is blocked and needs inspection.'
: 'You can inspect this preparation flow, but you cannot run the next action.';
}
if ($status === 'needs_recheck') {
return 'Current proof is missing, stale, or not safe enough to determine the next action from the inbox.';
}
return match ($currentStep?->stepKeyEnum()) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required reports are missing.',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'A current evidence snapshot is required.',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'The review must be refreshed from current evidence.',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'The customer-ready export must be prepared.',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'The review is ready to return to publication.',
default => 'Publication preparation needs an operator decision.',
};
}
private function impactLabel(string $status): string
{
return match ($status) {
'waiting' => 'No duplicate start action is exposed while preparation is already running.',
'ready_to_continue' => 'Publishing stays on the review page after the preparation flow returns there.',
'failed' => 'Publication remains blocked until the failed preparation operation is inspected and retried.',
'blocked' => 'Publication remains blocked until an authorized operator continues the preparation flow.',
'needs_recheck' => 'The source preparation page must refresh state before the inbox can classify the next action.',
default => 'Publication remains blocked until this preparation step is completed.',
};
}
private function evidenceLabel(?ReviewPublicationResolutionStep $currentStep): string
{
if (! $currentStep instanceof ReviewPublicationResolutionStep) {
return 'Proof needs re-check';
}
return match ($currentStep->stepKeyEnum()) {
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required reports',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Evidence snapshot',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Review composition',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Review export',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Publication readiness',
default => 'Readiness proof',
};
}
private function stepLabel(?ReviewPublicationResolutionStepKey $stepKey): string
{
return match ($stepKey) {
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Check readiness',
ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Update required reports',
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'Collect evidence',
ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Refresh review',
ReviewPublicationResolutionStepKey::GenerateReviewPack => 'Prepare export',
ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to review',
default => 'Preparation state',
};
}
private function urgencyRank(string $status): int
{
return match ($status) {
'failed' => 0,
'blocked' => 1,
'needs_attention' => 2,
'needs_recheck' => 3,
'ready_to_continue' => 4,
'waiting' => 5,
default => 99,
};
}
/**
* @param \Illuminate\Support\Collection<int, array<string, mixed>> $entries
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
*/
private function sortEntries(\Illuminate\Support\Collection $entries): \Illuminate\Support\Collection
{
return $entries
->sortBy([
fn (array $first, array $second): int => (int) ($first['urgency_rank'] ?? 999) <=> (int) ($second['urgency_rank'] ?? 999),
fn (array $first, array $second): int => (int) ($second['updated_sort'] ?? 0) <=> (int) ($first['updated_sort'] ?? 0),
])
->values();
}
/**
* @return array{label: string, url: string|null}
*/
private function primaryAction(string $inboxStatus, ?array $operationAction): array
{
if ($inboxStatus === 'waiting' && is_array($operationAction) && is_string($operationAction['url'] ?? null)) {
return [
'label' => 'Open operation',
'url' => $operationAction['url'],
];
}
return [
'label' => in_array($inboxStatus, ['needs_attention', 'ready_to_continue'], true)
? 'Continue preparation'
: 'Inspect preparation',
'url' => null,
];
}
private function safeReadyToContinueProofMetadata(
ReviewPublicationResolutionCase $case,
ReviewPublicationResolutionStep $step,
): bool {
if ($step->proof_type !== 'environment_review'
|| ! is_numeric($step->proof_id)
|| (int) $step->proof_id !== (int) $case->environment_review_id) {
return false;
}
if ((string) data_get($step->metadata, 'proof_currentness') !== ResolutionProofCurrentness::Current->value) {
return false;
}
if ((string) data_get($step->metadata, 'proof_visibility') !== ResolutionProofVisibility::OperatorVisible->value) {
return false;
}
if (! in_array((string) data_get($step->metadata, 'proof_usability'), [
ResolutionProofUsability::Usable->value,
ResolutionProofUsability::UsableWithWarning->value,
], true)) {
return false;
}
$summary = data_get($step->metadata, 'proof_summary');
if (! is_array($summary)) {
return false;
}
return ResolutionProofEvaluation::sanitizeSummary($summary) === $summary;
}
private function updatedSince(string $selectedUpdated): \Illuminate\Support\Carbon
{
return match ($selectedUpdated) {
'last_24_hours' => now()->subDay(),
'last_7_days' => now()->subDays(7),
'last_30_days' => now()->subDays(30),
default => now()->subYears(50),
};
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function withoutInternalSortKeys(array $entry): array
{
unset($entry['updated_sort']);
return $entry;
}
}

View File

@ -137,8 +137,12 @@ public function build(Workspace $workspace, User $user): array
'action_url' => $calmness['next_action']['url'] ?? ChooseEnvironment::getUrl(panel: 'admin'),
];
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $visibleFindingsTenantIds, $user);
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $visibleFindingsTenantIds);
$myFindingsSignal = $accessibleTenants->isEmpty()
? null
: $this->myFindingsSignal($workspaceId, $visibleFindingsTenantIds, $user);
$findingsHygieneSignal = $accessibleTenants->isEmpty()
? null
: $this->findingsHygieneSignal($workspace, $visibleFindingsTenantIds);
$zeroTenantState = null;

View File

@ -41,6 +41,16 @@ class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gra
Source focus: {{ $scope['family_label'] ?? 'All source families' }}
</x-filament::badge>
@if ($this->hasReviewPublicationResolutionFocus())
<x-filament::badge color="gray" size="sm">
Status: {{ $scope['status_label'] ?? 'All active statuses' }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Updated: {{ $scope['updated_label'] ?? 'Any time' }}
</x-filament::badge>
@endif
@if (filled($scope['tenant_label'] ?? null))
<x-filament::badge color="warning" size="sm">
Environment: {{ $scope['tenant_label'] }}
@ -468,6 +478,46 @@ class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font
@endforeach
</div>
@if ($this->hasReviewPublicationResolutionFocus())
<div class="grid gap-3 lg:grid-cols-2" data-testid="governance-inbox-review-publication-filters">
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
Review publication status
</p>
<div class="flex flex-wrap gap-2">
@foreach ($this->reviewPublicationStatusFilters() as $filter)
<a
href="{{ $filter['url'] }}"
class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font-medium transition {{ $filter['active'] ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
@if ($filter['active']) aria-current="page" @endif
>
{{ $filter['label'] }}
</a>
@endforeach
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">
Updated
</p>
<div class="flex flex-wrap gap-2">
@foreach ($this->reviewPublicationUpdatedFilters() as $filter)
<a
href="{{ $filter['url'] }}"
class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-xs font-medium transition {{ $filter['active'] ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
@if ($filter['active']) aria-current="page" @endif
>
{{ $filter['label'] }}
</a>
@endforeach
</div>
</div>
</div>
@endif
@if ($sections !== [])
<div class="space-y-4">
@foreach ($sections as $section)

View File

@ -5,61 +5,234 @@
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
$findingsHygieneSignal = $overview['findings_hygiene_signal'] ?? null;
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
$attentionItems = $overview['attention_items'] ?? [];
$summaryMetrics = $overview['summary_metrics'] ?? [];
$hasVisibleEnvironments = (int) ($overview['accessible_tenant_count'] ?? 0) > 0;
$priorityAttention = null;
$priorityAttentionIndex = null;
foreach ($attentionItems as $index => $candidateAttention) {
if (! is_array($candidateAttention)) {
continue;
}
$candidateDestination = $candidateAttention['destination'] ?? null;
$candidateActionUrl = is_array($candidateDestination) && ($candidateDestination['disabled'] ?? false) === false
? ($candidateDestination['url'] ?? null)
: null;
if (is_string($candidateActionUrl) && $candidateActionUrl !== '') {
$priorityAttention = $candidateAttention;
$priorityAttentionIndex = $index;
break;
}
}
$primaryQuickActions = array_values(array_filter(
$quickActions,
static fn (array $action): bool => ($action['key'] ?? null) === 'choose_environment',
));
$operationalQuickActions = array_values(array_filter(
$quickActions,
static fn (array $action): bool => in_array($action['key'] ?? null, ['operations', 'alerts'], true),
));
$adminQuickActions = array_values(array_filter(
$quickActions,
static fn (array $action): bool => in_array($action['key'] ?? null, ['switch_workspace', 'manage_workspaces'], true),
));
$listedAttentionItems = $attentionItems;
if ($priorityAttentionIndex !== null) {
unset($listedAttentionItems[$priorityAttentionIndex]);
$listedAttentionItems = array_values($listedAttentionItems);
}
@endphp
<div class="space-y-6">
<x-filament::section>
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 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-home" class="h-3.5 w-3.5" />
Workspace overview
</span>
@if (filled($workspace['slug'] ?? null))
<span class="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
{{ $workspace['slug'] }}
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 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-home" class="h-3.5 w-3.5" />
Workspace overview
</span>
@endif
@if (filled($workspace['slug'] ?? null))
<span class="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
{{ $workspace['slug'] }}
</span>
@endif
</div>
<h2 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
{{ $workspace['name'] ?? 'Workspace' }}
</h2>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
Workspace-scoped command center for visible environments. Attention signals are ranked before diagnostic activity.
</p>
</div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
{{ $workspace['name'] ?? 'Workspace' }}
</h1>
<div class="grid grid-cols-2 gap-3 sm:flex sm:items-center">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
Visible environments
</div>
<div class="mt-1 text-lg font-semibold text-gray-950 dark:text-white">
{{ $overview['accessible_tenant_count'] ?? 0 }}
</div>
</div>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This home stays workspace-scoped even when you were previously working in an environment. Governance risk is still ranked ahead of execution noise, backup health stays separate from recovery evidence, and calm wording only appears when visible environments are genuinely quiet across the checked domains.
</p>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
Scope
</div>
<div class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
Workspace
</div>
</div>
</div>
</div>
</x-filament::section>
@if ($quickActions !== [])
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
@foreach ($quickActions as $action)
<a
href="{{ $action['url'] }}"
class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition hover:border-primary-300 hover:shadow-sm dark:border-white/10 dark:bg-white/5 dark:hover:border-primary-500/40 dark:hover:bg-white/10"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {{ $action['color'] === 'primary' ? 'bg-primary-100 text-primary-700 dark:bg-primary-950/50 dark:text-primary-300' : 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200' }}">
<x-filament::icon :icon="$action['icon']" class="h-5 w-5" />
</div>
@if (is_array($priorityAttention))
@php
$priorityDestination = $priorityAttention['destination'] ?? null;
$priorityActionUrl = is_array($priorityDestination) && ($priorityDestination['disabled'] ?? false) === false
? ($priorityDestination['url'] ?? null)
: null;
$priorityIsCritical = ($priorityAttention['badge_color'] ?? null) === 'danger' || ($priorityAttention['urgency'] ?? null) === 'critical';
@endphp
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $action['label'] }}
</div>
<div class="mt-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $action['description'] }}
</div>
</div>
<section class="rounded-2xl border p-5 shadow-sm {{ $priorityIsCritical ? 'border-danger-200 bg-danger-50/70 dark:border-danger-700/50 dark:bg-danger-950/20' : 'border-warning-200 bg-warning-50/70 dark:border-warning-700/50 dark:bg-warning-950/20' }}">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium {{ $priorityIsCritical ? 'border-danger-200 bg-white text-danger-700 dark:border-danger-700/50 dark:bg-danger-500/10 dark:text-danger-200' : 'border-warning-200 bg-white text-warning-800 dark:border-warning-700/50 dark:bg-warning-500/10 dark:text-warning-100' }}">
<x-filament::icon icon="heroicon-o-exclamation-triangle" class="h-3.5 w-3.5" />
Priority attention
</span>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium text-gray-700 dark:border-white/10 dark:bg-white/10 dark:text-gray-200">
{{ $priorityAttention['tenant_label'] ?? 'Environment' }}
</span>
<x-filament::badge :color="$priorityAttention['badge_color'] ?? 'warning'" size="sm">
{{ $priorityAttention['badge'] ?? 'Needs attention' }}
</x-filament::badge>
</div>
</a>
@endforeach
</div>
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
{{ $priorityAttention['title'] ?? 'Workspace attention needed' }}
</h2>
<p class="max-w-3xl text-sm leading-6 text-gray-700 dark:text-gray-200">
{{ $priorityAttention['body'] ?? 'Review the highest-priority environment before treating recent operations as health.' }}
</p>
@if (filled($priorityAttention['supporting_message'] ?? null))
<p class="text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $priorityAttention['supporting_message'] }}
</p>
@endif
</div>
</div>
@if (is_string($priorityActionUrl) && $priorityActionUrl !== '')
<x-filament::button
tag="a"
:color="$priorityIsCritical ? 'danger' : 'warning'"
:href="$priorityActionUrl"
icon="heroicon-o-arrow-right"
class="w-full justify-center sm:w-auto"
>
{{ $priorityDestination['label'] ?? 'Review priority environment' }}
</x-filament::button>
@endif
</div>
</section>
@endif
@if (is_array($myFindingsSignal))
@livewire(\App\Filament\Widgets\Workspace\WorkspaceSummaryStats::class, [
'metrics' => $summaryMetrics,
], key('workspace-overview-summary-' . ($workspace['id'] ?? 'none')))
@if ($quickActions !== [])
<section class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<h2 class="text-sm font-semibold text-gray-950 dark:text-white">
Workspace shortcuts
</h2>
<p class="hidden text-sm leading-6 text-gray-600 dark:text-gray-300 sm:block">
Operational paths stay available without competing with the priority queue.
</p>
</div>
@if ($primaryQuickActions !== [])
<x-filament::button
tag="a"
color="primary"
:href="$primaryQuickActions[0]['url']"
:icon="$primaryQuickActions[0]['icon']"
class="w-full justify-center sm:w-auto"
>
{{ $primaryQuickActions[0]['label'] }}
</x-filament::button>
@endif
</div>
<div class="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,18rem)]">
@if ($operationalQuickActions !== [])
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach ($operationalQuickActions as $action)
<a
href="{{ $action['url'] }}"
class="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-left transition hover:border-primary-300 hover:bg-white hover:shadow-sm dark:border-white/10 dark:bg-white/5 dark:hover:border-primary-500/40 dark:hover:bg-white/10"
>
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
<x-filament::icon :icon="$action['icon']" class="h-5 w-5" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $action['label'] }}
</div>
<div class="mt-1 hidden text-xs leading-5 text-gray-600 dark:text-gray-300 sm:block">
{{ $action['description'] }}
</div>
</div>
</div>
</a>
@endforeach
</div>
@endif
@if ($adminQuickActions !== [])
<div class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
Workspace admin
</div>
<div class="mt-2 space-y-2">
@foreach ($adminQuickActions as $action)
<a
href="{{ $action['url'] }}"
class="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-700 transition hover:bg-white hover:text-primary-700 dark:text-gray-200 dark:hover:bg-white/10 dark:hover:text-primary-300"
>
<x-filament::icon :icon="$action['icon']" class="h-4 w-4 text-gray-500 dark:text-gray-400" />
<span>{{ $action['label'] }}</span>
</a>
@endforeach
</div>
</div>
@endif
</div>
</section>
@endif
@if ($hasVisibleEnvironments && is_array($myFindingsSignal))
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-3">
@ -102,11 +275,17 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
</section>
@endif
@if (is_array($findingsHygieneSignal))
@if ($hasVisibleEnvironments && is_array($findingsHygieneSignal))
@php
$hygieneIsCalm = (bool) ($findingsHygieneSignal['is_calm'] ?? false);
$brokenAssignmentCount = (int) ($findingsHygieneSignal['broken_assignment_count'] ?? 0);
$staleInProgressCount = (int) ($findingsHygieneSignal['stale_in_progress_count'] ?? 0);
@endphp
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-3">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-danger-200 bg-danger-50 px-3 py-1 text-xs font-medium text-danger-700 dark:border-danger-700/60 dark:bg-danger-950/40 dark:text-danger-200">
<div class="inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium {{ $hygieneIsCalm ? 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' : 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700/60 dark:bg-danger-950/40 dark:text-danger-200' }}">
<x-filament::icon icon="heroicon-o-wrench-screwdriver" class="h-3.5 w-3.5" />
Findings hygiene
</div>
@ -124,10 +303,10 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-3 py-1 font-medium text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Unique issues: {{ $findingsHygieneSignal['unique_issue_count'] }}
</span>
<span class="inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 font-medium text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200">
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ $brokenAssignmentCount > 0 ? 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200' : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' }}">
Broken assignments: {{ $findingsHygieneSignal['broken_assignment_count'] }}
</span>
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 font-medium text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200">
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ $staleInProgressCount > 0 ? 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200' : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' }}">
Stale in progress: {{ $findingsHygieneSignal['stale_in_progress_count'] }}
</span>
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ ($findingsHygieneSignal['is_calm'] ?? false) ? 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/50 dark:bg-success-950/30 dark:text-success-200' : 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200' }}">
@ -138,7 +317,7 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
<x-filament::button
tag="a"
color="danger"
:color="$hygieneIsCalm ? 'gray' : 'danger'"
:href="$findingsHygieneSignal['cta_url']"
icon="heroicon-o-arrow-right"
>
@ -188,13 +367,9 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
</span>
</div>
@livewire(\App\Filament\Widgets\Workspace\WorkspaceSummaryStats::class, [
'metrics' => $overview['summary_metrics'] ?? [],
], key('workspace-overview-summary-' . ($workspace['id'] ?? 'none')))
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
@livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [
'items' => $overview['attention_items'] ?? [],
'items' => $listedAttentionItems,
'emptyState' => $overview['attention_empty_state'] ?? [],
'triageReviewProgress' => $overview['triage_review_progress'] ?? [],
], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none')))

View File

@ -47,9 +47,16 @@
? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin');
$environmentTriggerLabel = $workspace ? $environmentLabel : __('localization.shell.choose_workspace');
$environmentTriggerAriaLabel = $workspace && $hasActiveEnvironment
? __('localization.shell.environment_scope')
: __('localization.shell.select_environment');
$managedEnvironmentSectionLabel = match (true) {
$hasActiveEnvironment => __('localization.shell.selected_environment'),
$environments->isNotEmpty() => __('localization.shell.choose_environment'),
default => __('localization.shell.managed_environments_title'),
};
$environmentTriggerAriaLabel = match (true) {
$workspace && $hasActiveEnvironment => __('localization.shell.environment_scope'),
$workspace && $environments->isEmpty() => __('localization.shell.managed_environments_title'),
default => __('localization.shell.select_environment'),
};
$localePlane = 'admin';
@endphp
@ -138,7 +145,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ $hasActiveEnvironment ? __('localization.shell.selected_environment') : __('localization.shell.choose_environment') }}
{{ $managedEnvironmentSectionLabel }}
</div>
</div>

View File

@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\User;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionService;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepKey;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(60_000);
it('Spec389 smokes review publication resolution intake from governance inbox to preparation detail', function (): void {
[$user, $environment] = spec389GovernanceInboxBrowserFixture();
spec389AuthenticateGovernanceInboxBrowser($this, $user, $environment);
$page = visit(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
'status' => 'needs_attention',
'updated' => 'last_24_hours',
]))
->resize(1366, 920)
->waitForText('Governance Inbox')
->assertSee('Review publication work')
->assertSee('Status: Needs attention')
->assertSee('Updated: Last 24 hours')
->assertSee('Review cannot be published yet')
->assertSee('A current evidence snapshot is required.')
->assertSee('Continue preparation')
->assertSee('Review publication status')
->assertDontSee('Operation #')
->assertDontSee('OperationRun')
->assertScript('(() => {
const sourceDetail = document.querySelector("[data-testid=\"governance-inbox-source-detail\"]");
const filters = document.querySelector("[data-testid=\"governance-inbox-review-publication-filters\"]");
const active = filters?.querySelectorAll("a[aria-current=\"page\"]") || [];
return sourceDetail?.open === true
&& filters !== null
&& [...active].some((link) => link.textContent.includes("Needs attention"))
&& [...active].some((link) => link.textContent.includes("Last 24 hours"))
&& document.documentElement.scrollWidth <= window.innerWidth;
})()', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec389GovernanceInboxScreenshot('review-publication-inbox'));
spec389CopyGovernanceInboxScreenshot('review-publication-inbox');
$page
->resize(390, 844)
->assertSee('Review cannot be published yet')
->assertSee('Continue preparation')
->assertScript('document.documentElement.scrollWidth <= window.innerWidth', true);
$page->screenshot(true, spec389GovernanceInboxScreenshot('review-publication-inbox-mobile'));
spec389CopyGovernanceInboxScreenshot('review-publication-inbox-mobile');
$page->resize(1366, 920);
$page->script('(() => {
const link = [...document.querySelectorAll("a[href*=\'resolve-publication\']")]
.find((element) => element.textContent.includes("Continue preparation"));
link?.click();
})()');
$detailPage = $page
->waitForText('Publication preparation')
->assertSee('Collect evidence')
->assertSee('will not publish the review')
->assertDontSee('OperationRun')
->assertDontSee('Artifact proof')
->assertScript('window.location.pathname.includes("/resolve-publication")', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$detailPage->screenshot(true, spec389GovernanceInboxScreenshot('review-publication-resolution-detail'));
spec389CopyGovernanceInboxScreenshot('review-publication-resolution-detail');
visit(CustomerReviewWorkspace::environmentFilterUrl($environment))
->resize(1366, 920)
->waitForText('Customer Review Workspace')
->assertDontSee('Review publication work')
->assertDontSee('Resolution Case')
->assertDontSee('OperationRun')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
/**
* @return array{0: User, 1: ManagedEnvironment, 2: EnvironmentReview, 3: ReviewPublicationResolutionCase}
*/
function spec389GovernanceInboxBrowserFixture(): array
{
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Browser Publication',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
$snapshot = spec389GovernanceInboxBrowserEvidence($environment);
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $user);
$case->loadMissing('steps');
foreach ($case->steps as $step) {
$step->forceFill([
'status' => $step->step_key === ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value
? ReviewPublicationResolutionStepStatus::Actionable->value
: ReviewPublicationResolutionStepStatus::Completed->value,
'summary' => array_replace(is_array($step->summary) ? $step->summary : [], [
'state_description' => $step->step_key === ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value
? 'A current evidence snapshot is required.'
: 'Requirement is satisfied.',
]),
'operation_run_id' => null,
])->save();
}
$case->forceFill([
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'updated_at' => now(),
])->save();
return [$user, $environment, $review->fresh(), $case->fresh('steps')];
}
function spec389GovernanceInboxBrowserEvidence(ManagedEnvironment $environment): EvidenceSnapshot
{
return seedPartialEnvironmentReviewEvidence(
tenant: $environment,
findingCount: 0,
driftCount: 0,
operationRunCount: 0,
);
}
function spec389AuthenticateGovernanceInboxBrowser(
mixed $test,
User $user,
ManagedEnvironment $environment,
): void {
$workspaceId = (int) $environment->workspace_id;
$session = [
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $environment->getKey(),
],
];
$test->actingAs($user)->withSession($session);
foreach ($session as $key => $value) {
session()->put($key, $value);
}
setAdminPanelContext($environment);
}
function spec389GovernanceInboxScreenshot(string $name): string
{
return 'spec389-governance-inbox-resolution-'.$name;
}
function spec389CopyGovernanceInboxScreenshot(string $name): void
{
$filename = spec389GovernanceInboxScreenshot($name).'.png';
$primarySource = base_path('tests/Browser/Screenshots/'.$filename);
$fallbackSource = \Pest\Browser\Support\Screenshot::path($filename);
$targetDirectory = repo_path('specs/389-governance-inbox-resolution-intake-v1/artifacts/screenshots');
if (! is_dir($targetDirectory)) {
@mkdir($targetDirectory, 0755, true);
}
$source = null;
for ($attempt = 0; $attempt < 50 && $source === null; $attempt++) {
foreach ([$primarySource, $fallbackSource] as $candidate) {
if (is_file($candidate)) {
$source = $candidate;
break;
}
}
if ($source !== null) {
break;
}
usleep(100_000);
clearstatcache(true, $primarySource);
clearstatcache(true, $fallbackSource);
}
if (is_string($source) && is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) {
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png');
}
}

View File

@ -34,7 +34,11 @@
->assertDontSee('This workspace is not calm or healthy yet because your current scope has no visible tenants.')
->assertSee('No recent operations yet')
->assertSee('Switch workspace')
->assertDontSee(__('localization.shell.choose_environment'));
->assertDontSee(__('localization.shell.choose_environment'))
->assertDontSee('Assigned work is calm')
->assertDontSee('Findings hygiene is calm')
->assertDontSee('aria-label="'.__('localization.shell.select_environment').'"', escape: false)
->assertSee('aria-label="'.__('localization.shell.managed_environments_title').'"', escape: false);
});
it('does not render a calm state when governance risk exists even if operations are quiet', function (): void {

View File

@ -6,6 +6,8 @@
use App\Models\ManagedEnvironment;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiTooltips;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
@ -51,7 +53,13 @@
]);
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('primeMemberships')->atLeast()->once();
$mock->shouldReceive('isMember')
->andReturnUsing(static function (\App\Models\User $user, ManagedEnvironment $resolvedTenant) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return true;
});
$mock->shouldReceive('can')
->andReturnUsing(static function (\App\Models\User $user, ManagedEnvironment $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
@ -70,6 +78,15 @@
expect($item['action_disabled'])->toBeTrue()
->and($item['destination']['kind'])->toBe('tenant_findings')
->and($item['helper_text'])->not->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.workspace.home', ['workspace' => $workspace]))
->assertOk()
->assertDontSee('Priority attention')
->assertSee('Overdue findings')
->assertSee('Open findings')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
});
it('omits hidden-tenant backup and recovery issues from workspace counts and calmness claims', function (): void {

View File

@ -0,0 +1,846 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\AuditLog;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\ReviewPublicationResolutionStep;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\GovernanceInbox\ReviewPublicationResolutionInboxProvider;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ReviewPublicationResolution\ResolutionProofCurrentness;
use App\Support\ReviewPublicationResolution\ResolutionProofUsability;
use App\Support\ReviewPublicationResolution\ResolutionProofVisibility;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepKey;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepStatus;
use App\Support\Workspaces\WorkspaceContext;
it('Spec389 renders active review publication resolution cases in the governance inbox', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Publishing Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: true);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Completed->value,
'current_step_key' => null,
'summary' => ['label' => 'Spec389 completed hidden case'],
], [
'step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
'status' => ReviewPublicationResolutionStepStatus::Completed->value,
]);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Cancelled->value,
'current_step_key' => null,
'summary' => ['label' => 'Spec389 cancelled hidden case'],
], [
'step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'status' => ReviewPublicationResolutionStepStatus::Superseded->value,
]);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Superseded->value,
'current_step_key' => null,
'summary' => ['label' => 'Spec389 superseded hidden case'],
], [
'step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'status' => ReviewPublicationResolutionStepStatus::Superseded->value,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Review publication work')
->assertSee('Review cannot be published yet')
->assertSee('A current evidence snapshot is required.')
->assertSee('Continue preparation')
->assertSee('Review publication status')
->assertSee('Updated: Any time')
->assertDontSee('Spec389 completed hidden case')
->assertDontSee('Spec389 cancelled hidden case')
->assertDontSee('Spec389 superseded hidden case')
->assertDontSee('Operation #');
});
it('Spec389 sorts publication work by severity before updated time', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Sort Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
[$failedCase, $failedStep, $failedReview] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
]);
$run = spec389ResolutionOperationRun($tenant, $failedCase, $failedReview, [
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
spec389AttachResolutionOperationProof($failedStep, $run, proofStatus: OperationRunOutcome::Failed->value);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'updated_at' => now()->subMinute(),
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
'summary' => ['missing_report_dimensions' => ['unsupported_report']],
]);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'updated_at' => now()->subMinutes(2),
], [
'step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'updated_at' => now()->subMinutes(3),
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
]);
[, $readyStep, $readyReview] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::ReadyToContinue->value,
'current_step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
'updated_at' => now()->subMinutes(4),
], [
'step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
spec389AttachReadyToContinueProof($readyStep, $readyReview);
[$waitingCase, $waitingStep, $waitingReview] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'updated_at' => now(),
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Running->value,
]);
$waitingRun = spec389ResolutionOperationRun($tenant, $waitingCase, $waitingReview, [
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
spec389AttachResolutionOperationProof($waitingStep, $waitingRun, proofStatus: OperationRunStatus::Running->value);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]));
$response
->assertOk()
->assertSee('Review publication work');
$section = spec389ReviewPublicationProviderSection($user, $tenant, previewLimit: 10);
$statuses = collect($section['entries'] ?? [])->pluck('inbox_status')->all();
expect($statuses)->toBe([
'failed',
'blocked',
'needs_attention',
'needs_recheck',
'ready_to_continue',
'waiting',
]);
$waitingEntry = collect($section['entries'] ?? [])
->firstWhere('inbox_status', 'waiting');
expect($waitingEntry['primary_action_label'] ?? null)->toBe('Open operation')
->and(collect($waitingEntry['secondary_actions'] ?? [])->pluck('label')->all())->not->toContain('Open operation');
});
it('Spec389 applies derived status and updated-date filters only to review publication work', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Filter Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: true);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'updated_at' => now()->subHours(2),
], [
'step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
'updated_at' => now()->subHours(2),
]);
[, $readyStep, $readyReview] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::ReadyToContinue->value,
'current_step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
'updated_at' => now()->subDays(10),
], [
'step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
'updated_at' => now()->subDays(10),
]);
spec389AttachReadyToContinueProof($readyStep, $readyReview);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
'status' => 'needs_attention',
'updated' => 'last_24_hours',
]))
->assertOk()
->assertSee('Status: Needs attention')
->assertSee('Updated: Last 24 hours')
->assertSee('Review cannot be published yet')
->assertDontSee('Review preparation can continue');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
'status' => 'ready_to_continue',
'updated' => 'last_24_hours',
]))
->assertOk()
->assertSee('These review publication filters are hiding active preparation work')
->assertSee('Clear review publication filters')
->assertDontSee('Review preparation can continue');
});
it('Spec389 falls back to needs re-check when ready-to-continue proof is stale', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Stale Ready Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
[, $step, $review] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::ReadyToContinue->value,
'current_step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::ReturnToPublication->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
spec389AttachReadyToContinueProof($step, $review, [
'proof_currentness' => ResolutionProofCurrentness::Stale->value,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Review preparation needs re-check')
->assertSee('Inspect preparation')
->assertDontSee('Review preparation can continue');
$section = spec389ReviewPublicationProviderSection($user, $tenant);
$entry = collect($section['entries'] ?? [])->first();
expect($entry['inbox_status'] ?? null)->toBe('needs_recheck');
});
it('Spec389 discloses operation links only for safe current linked runs', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Operation Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
[$case, $step, $review] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
]);
$run = spec389ResolutionOperationRun($tenant, $case, $review, [
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$step->forceFill([
'operation_run_id' => (int) $run->getKey(),
'proof_type' => 'operation_run',
'proof_id' => (int) $run->getKey(),
'proof_status' => OperationRunOutcome::Failed->value,
'metadata' => spec389SafeOperationProofMetadata(),
])->save();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
'status' => 'failed',
]))
->assertOk()
->assertSee('Review preparation action failed')
->assertSee('Open operation')
->assertDontSee('Operation #');
});
it('Spec389 hides operation links when proof currentness cannot be validated', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Stale Proof Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
[$case, $step, $review] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
]);
$run = spec389ResolutionOperationRun($tenant, $case, $review, [
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$step->forceFill([
'operation_run_id' => (int) $run->getKey(),
'proof_type' => 'operation_run',
'proof_id' => (int) $run->getKey(),
'proof_status' => OperationRunOutcome::Failed->value,
'metadata' => spec389SafeOperationProofMetadata([
'proof_currentness' => ResolutionProofCurrentness::Stale->value,
]),
])->save();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Review preparation needs re-check')
->assertSee('Inspect preparation')
->assertDontSee('Open operation')
->assertDontSee('Operation #');
});
it('Spec389 hides operation links when operation context or proof binding is invalid', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Invalid Operation Context Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
[$otherCase, , $otherReview] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Cancelled->value,
'current_step_key' => null,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Superseded->value,
]);
$otherRun = spec389ResolutionOperationRun($tenant, $otherCase, $otherReview, [
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$scenarios = [
'missing case context' => function (ReviewPublicationResolutionCase $case, EnvironmentReview $review) use ($tenant): array {
return [
'context' => [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_review_id' => (int) $review->getKey(),
'trigger' => 'review_publication_resolution',
],
];
},
'missing trigger' => function (ReviewPublicationResolutionCase $case, EnvironmentReview $review) use ($tenant): array {
return [
'context' => [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_review_id' => (int) $review->getKey(),
'review_publication_resolution_case_id' => (int) $case->getKey(),
],
];
},
'cross case' => function (ReviewPublicationResolutionCase $case, EnvironmentReview $review) use ($tenant, $otherCase): array {
return [
'context' => [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_review_id' => (int) $review->getKey(),
'review_publication_resolution_case_id' => (int) $otherCase->getKey(),
'trigger' => 'review_publication_resolution',
],
];
},
'cross review' => function (ReviewPublicationResolutionCase $case) use ($tenant, $otherReview): array {
return [
'context' => [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_review_id' => (int) $otherReview->getKey(),
'review_publication_resolution_case_id' => (int) $case->getKey(),
'trigger' => 'review_publication_resolution',
],
];
},
'wrong type' => fn (): array => [
'type' => OperationRunType::EvidenceSnapshotGenerate->value,
],
'wrong proof id' => fn (): array => [
'proof_id' => (int) $otherRun->getKey(),
],
];
foreach ($scenarios as $label => $runOverrides) {
[$case, $step, $review] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'updated_at' => now()->subMinutes(count($scenarios)),
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
'summary' => ['scenario' => $label],
]);
$overrides = $runOverrides($case, $review);
$proofId = is_numeric($overrides['proof_id'] ?? null) ? (int) $overrides['proof_id'] : null;
unset($overrides['proof_id']);
$run = spec389ResolutionOperationRun($tenant, $case, $review, array_replace([
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
], $overrides));
spec389AttachResolutionOperationProof($step, $run, proofId: $proofId, proofStatus: OperationRunOutcome::Failed->value);
}
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Review preparation needs re-check')
->assertDontSee('Open operation')
->assertDontSee('Operation #');
$section = spec389ReviewPublicationProviderSection($user, $tenant, previewLimit: 10);
$entries = collect($section['entries'] ?? []);
expect($entries)->toHaveCount(count($scenarios))
->and($entries->pluck('inbox_status')->unique()->values()->all())->toBe(['needs_recheck'])
->and(spec389EntryActionLabels($entries->all()))->not->toContain('Open operation');
});
it('Spec389 hides operation links when OperationRunPolicy denies the linked run', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Policy Denied Operation Tenant',
'slug' => 'spec389-policy-denied-operation-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
[$case, $step, $review] = spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::Blocked->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Failed->value,
]);
$run = spec389ResolutionOperationRun($tenant, $case, $review, [
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
spec389AttachResolutionOperationProof($step, $run, proofStatus: OperationRunOutcome::Failed->value);
$originalFixture = config('tenantpilot.backup_health.browser_smoke_fixture');
config([
'tenantpilot.backup_health.browser_smoke_fixture.user.email' => $user->email,
'tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough.tenant_external_id' => (string) $tenant->external_id,
'tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough.capability_denials' => [
Capabilities::PROVIDER_VIEW,
],
]);
app(CapabilityResolver::class)->clearCache();
try {
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Review preparation needs re-check')
->assertDontSee('Open operation')
->assertDontSee('Operation #');
$section = spec389ReviewPublicationProviderSection($user, $tenant);
$entry = collect($section['entries'] ?? [])->first();
expect($entry['inbox_status'] ?? null)->toBe('needs_recheck')
->and(spec389EntryActionLabels([$entry]))->not->toContain('Open operation');
} finally {
config(['tenantpilot.backup_health.browser_smoke_fixture' => $originalFixture]);
app(CapabilityResolver::class)->clearCache();
}
});
it('Spec389 hides resolution cases outside the viewer environment scope', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Visible Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$hiddenTenant = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec389 Hidden Tenant',
]);
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
spec389CreateResolutionCase($hiddenTenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Spec389 Visible Tenant')
->assertDontSee('Spec389 Hidden Tenant');
});
it('Spec389 hides resolution cases outside the active workspace', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Workspace Visible Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$foreignTenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Foreign Workspace Tenant',
]);
[$foreignUser, $foreignTenant] = createUserWithTenant($foreignTenant, role: 'owner', workspaceRole: 'owner');
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
spec389CreateResolutionCase($foreignTenant, $foreignUser, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Spec389 Workspace Visible Tenant')
->assertDontSee('Spec389 Foreign Workspace Tenant');
});
it('Spec389 does not surface resolution intake work on the customer review workspace', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Customer Surface Tenant',
]);
[$owner, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
[$customer] = createUserWithTenant($tenant, User::factory()->create(), role: 'readonly', workspaceRole: 'readonly');
spec389CreateResolutionCase($tenant, $owner, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CompleteRequiredReports->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
$this->actingAs($customer)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(CustomerReviewWorkspace::environmentFilterUrl($tenant))
->assertOk()
->assertDontSee('Review publication work')
->assertDontSee('Review cannot be published yet')
->assertDontSee('Continue preparation')
->assertDontSee('Open operation');
});
it('Spec389 renders governance inbox publication work without creating audit events', function (): void {
$tenant = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec389 Audit Neutral Tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
spec389CreateResolutionCase($tenant, $user, [
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
], [
'step_key' => ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot->value,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
]);
$auditCount = AuditLog::query()->count();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin', parameters: [
'family' => 'review_publication_resolution',
]))
->assertOk()
->assertSee('Continue preparation')
->assertDontSee('Publish review')
->assertDontSee('Cancel resolution')
->assertDontSee('Prepare export');
expect(AuditLog::query()->count())->toBe($auditCount);
});
/**
* @return array{0: ReviewPublicationResolutionCase, 1: ReviewPublicationResolutionStep, 2: EnvironmentReview}
*/
function spec389CreateResolutionCase(
ManagedEnvironment $tenant,
User $actor,
array $caseOverrides = [],
array $stepOverrides = [],
): array {
$now = now();
$snapshot = EvidenceSnapshot::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->latest('id')
->first();
if (! $snapshot instanceof EvidenceSnapshot) {
$snapshot = seedPartialEnvironmentReviewEvidence(
tenant: $tenant,
findingCount: 0,
driftCount: 0,
operationRunCount: 0,
);
}
$review = EnvironmentReview::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $actor->getKey(),
'generated_at' => $caseOverrides['review_generated_at'] ?? $now,
]);
$stepKey = (string) ($stepOverrides['step_key'] ?? ReviewPublicationResolutionStepKey::CompleteRequiredReports->value);
$caseUpdatedAt = $caseOverrides['updated_at'] ?? $now;
$stepUpdatedAt = $stepOverrides['updated_at'] ?? $caseUpdatedAt;
$defaultStepSummary = $stepKey === ReviewPublicationResolutionStepKey::CompleteRequiredReports->value
? ['missing_report_dimensions' => ['permission_posture']]
: [];
unset($caseOverrides['review_generated_at']);
$case = ReviewPublicationResolutionCase::query()->create(array_replace([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_review_id' => (int) $review->getKey(),
'action_key' => ReviewPublicationResolutionCase::ACTION_KEY,
'status' => ReviewPublicationResolutionCaseStatus::InProgress->value,
'current_step_key' => $stepKey,
'readiness_fingerprint' => hash('sha256', 'spec389-'.$tenant->getKey().'-'.$review->getKey().'-'.str()->uuid()),
'created_by_user_id' => (int) $actor->getKey(),
'assigned_to_user_id' => (int) $actor->getKey(),
'started_at' => $now,
'last_evaluated_at' => $now,
'summary' => $defaultStepSummary,
'metadata' => [],
'created_at' => $caseUpdatedAt,
'updated_at' => $caseUpdatedAt,
], $caseOverrides));
$step = ReviewPublicationResolutionStep::query()->create(array_replace([
'case_id' => (int) $case->getKey(),
'position' => 1,
'step_key' => $stepKey,
'status' => ReviewPublicationResolutionStepStatus::Actionable->value,
'primary_action_key' => ReviewPublicationResolutionStepKey::tryFrom($stepKey)?->primaryActionKey(),
'summary' => [],
'metadata' => [],
'created_at' => $stepUpdatedAt,
'updated_at' => $stepUpdatedAt,
], $stepOverrides));
return [$case->fresh(['tenant', 'environmentReview', 'steps.operationRun']), $step->fresh('operationRun'), $review->fresh()];
}
function spec389ResolutionOperationRun(
ManagedEnvironment $tenant,
ReviewPublicationResolutionCase $case,
EnvironmentReview $review,
array $overrides = [],
): OperationRun {
return OperationRun::factory()->forTenant($tenant)->create(array_replace([
'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'environment_review_id' => (int) $review->getKey(),
'review_publication_resolution_case_id' => (int) $case->getKey(),
'trigger' => 'review_publication_resolution',
],
], $overrides));
}
function spec389AttachResolutionOperationProof(
ReviewPublicationResolutionStep $step,
OperationRun $run,
array $metadata = [],
?int $proofId = null,
?string $proofStatus = null,
): ReviewPublicationResolutionStep {
$step->forceFill([
'operation_run_id' => (int) $run->getKey(),
'proof_type' => 'operation_run',
'proof_id' => $proofId ?? (int) $run->getKey(),
'proof_status' => $proofStatus ?? (string) $run->outcome,
'metadata' => array_replace(spec389SafeOperationProofMetadata(), $metadata),
])->save();
return $step->fresh('operationRun');
}
function spec389AttachReadyToContinueProof(
ReviewPublicationResolutionStep $step,
EnvironmentReview $review,
array $metadata = [],
): ReviewPublicationResolutionStep {
$step->forceFill([
'proof_type' => 'environment_review',
'proof_id' => (int) $review->getKey(),
'proof_status' => 'ready',
'metadata' => array_replace(spec389SafeReadyToContinueProofMetadata(), $metadata),
])->save();
return $step->fresh();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function spec389SafeOperationProofMetadata(array $overrides = []): array
{
return array_replace([
'proof_currentness' => ResolutionProofCurrentness::Current->value,
'proof_usability' => ResolutionProofUsability::InspectionOnly->value,
'proof_visibility' => ResolutionProofVisibility::OperatorVisible->value,
'proof_summary' => [
'message' => 'Safe current operation proof is available.',
],
], $overrides);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function spec389SafeReadyToContinueProofMetadata(array $overrides = []): array
{
return array_replace([
'proof_currentness' => ResolutionProofCurrentness::Current->value,
'proof_usability' => ResolutionProofUsability::Usable->value,
'proof_visibility' => ResolutionProofVisibility::OperatorVisible->value,
'proof_summary' => [
'message' => 'Current review proof is available.',
],
], $overrides);
}
function spec389ReviewPublicationProviderSection(
User $user,
ManagedEnvironment $tenant,
?string $selectedStatus = null,
?string $selectedUpdated = null,
int $previewLimit = 10,
): array {
$workspace = Workspace::query()->findOrFail((int) $tenant->workspace_id);
return app(ReviewPublicationResolutionInboxProvider::class)->section(
user: $user,
workspace: $workspace,
reviewTenants: [(int) $tenant->getKey() => $tenant->fresh()],
selectedTenant: null,
selectedStatus: $selectedStatus,
selectedUpdated: $selectedUpdated,
navigationContext: null,
previewLimit: $previewLimit,
);
}
/**
* @param list<array<string, mixed>|null> $entries
* @return list<string>
*/
function spec389EntryActionLabels(array $entries): array
{
return collect($entries)
->filter(fn (mixed $entry): bool => is_array($entry))
->flatMap(function (array $entry): array {
return array_merge(
collect($entry['secondary_actions'] ?? [])->pluck('label')->all(),
collect($entry['linked_records'] ?? [])->pluck('label')->all(),
);
})
->filter(fn (mixed $label): bool => is_string($label))
->values()
->all();
}

View File

@ -13,15 +13,15 @@ # UI-001 Workspace Overview
## First Five Seconds
The page clearly communicates a workspace home with operational and governance attention cards. The strongest next action is not always singular because several cards and links compete: choose environment, operations, alerts, backup attention, recovery attention, and findings links.
The page now opens with a compact workspace context, a priority-attention band when an actionable environment exists, and summary metrics before shortcuts. Operational and admin links remain available, but they no longer compete with the primary queue.
## Productization Review
- Decision-first: strong, with attention cards ahead of diagnostics.
- Decision-first: improved; priority attention and workspace metrics appear before shortcuts and diagnostics.
- Evidence-first: partially present through counts and posture explanations.
- Context: workspace shell is explicit and no environment is selected.
- Customer/auditor safety: not customer-facing, but copy is calm and mostly productized.
- Diagnostics: recent operations are correctly framed as diagnostic rather than governance health.
- Diagnostics: recent operations are still correctly framed as diagnostic rather than governance health.
## Information Inventory
@ -29,19 +29,19 @@ ## Information Inventory
## Dangerous Actions
No destructive action was visible on the first viewport. Main risk is false affordance or multiple equal-weight primary links, not immediate mutation.
No destructive action was visible on the first viewport. Main residual risk is repeated drill-through destinations across priority, metrics, and the lower attention list, not immediate mutation.
## Scores
| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 4 | 4 | 4 | 4 | 4 | 3 | 4 | 3 | 3 | 4 | 4 | 4 |
| 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
## Top Issues
1. Several high-value links compete for primary action hierarchy.
2. Backup/recovery counts need a target mockup that separates posture, evidence, and next action.
3. Responsive behavior was not captured in Spec 323.
1. Backup/recovery counts still need a deeper target treatment that separates posture, evidence, and next action.
2. Repeated drill-through links remain across priority, metric, and list surfaces; keep watching for link overload as data grows.
3. Responsive behavior now has implementation-level smoke evidence, but the durable audit screenshot set still only stores the desktop baseline.
## Target Direction

View File

@ -0,0 +1,279 @@
# Current Governance Inbox Inventory
**Feature**: 389 - Governance Inbox Resolution Intake v1
**Captured**: 2026-06-19
**Purpose**: Inventory the existing Governance Inbox before implementation.
## Repo Safety Snapshot
- Branch before preparation: `platform-dev`
- Latest baseline commit before preparation: `83c679cf feat: add review publication proof currentness contract (#459)`
- New preparation branch: `389-governance-inbox-resolution-intake-v1`
- Worktree before creating Spec 389 artifacts: clean
- Current dirty scope after preparation: `specs/389-governance-inbox-resolution-intake-v1/` only
- Spec 386 status in repo: present and merged into platform history through `ba7622a1 feat: implement ReviewPublicationResolutionWorkflow (Spec 386) (#457)`
- Spec 387 status in repo: present and merged into platform history through `aca0b106 feat: add review publication resolution ux spec and tests (#458)`
- Spec 388 status in repo: present on current baseline through `83c679cf feat: add review publication proof currentness contract (#459)`
## Existing Route / Page / Resource
The Governance Inbox is an existing Filament Page:
- Page class: `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
- View: `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
- Slug: `governance/inbox`
- Navigation group: workspace-wide Governance group
- Navigation label: `Governance inbox`
- Navigation sort: `5`
- Action surface declaration: list-only, read-only registry report
Spec 389 must reuse this page. It must not add a new top-level navigation item, CRUD Resource, global-search Resource, or independent Resolution Cases page.
## Existing Builder Pattern
The current page consumes `GovernanceInboxSectionBuilder`:
- Builder class: `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
- Public entry point: `build(...)`
- Inputs include current user, workspace, authorized environments, visible finding environments, review environments, alert/finding-exception visibility, selected environment, selected family, and canonical navigation context.
- Output includes:
- `sections`
- `available_families`
- `family_counts`
- `total_count`
The builder currently owns source-family sections and returns source entries. The Filament Page normalizes those entries into first-screen operator lanes.
## Existing Source Families
Current `FAMILY_ORDER`:
1. `assigned_findings`
2. `intake_findings`
3. `finding_exceptions`
4. `stale_operations`
5. `alert_delivery_failures`
6. `review_follow_up`
Spec 389 should add only one concrete family:
```text
review_publication_resolution
```
It should be ordered so failed/blocked review publication preparation appears with other high-attention governance work, without changing the meaning of existing families.
## Existing Lane Pattern
The first screen groups normalized entries into lanes in `GovernanceInbox::lanePayload()`:
- `needs_triage`
- `requires_decision`
- `risk_exception_review`
- `evidence_required`
- `blocked`
Entries are converted by `buildOperatorItem()` into a common display shape:
- lane key and label
- title
- status label
- reason heading
- reason label
- impact label
- source label
- environment label
- context label
- owner and due labels
- evidence and exception labels
- primary action
- secondary actions
- linked records
- urgency rank
Spec 389 can map Review Publication Resolution items into existing lanes:
- `failed`, `blocked`, unsafe waiting failures: `blocked`
- `needs_attention`, `needs_recheck`, `ready_to_continue`: `requires_decision` or `evidence_required` when the reason is specifically missing proof/evidence
- `waiting`: `requires_decision` or a low-rank item, unless the existing lane language is adjusted in implementation
Any lane copy changes must stay decision-first and read-only.
## Existing Filters
The page currently exposes:
- environment filter through `environment_id`
- source-family filter through `family`
The source detail section renders family filter links and counts. Empty states adapt to environment/family filters.
Spec 389 should use:
- `family=review_publication_resolution` as the type filter equivalent
- existing `environment_id` filtering
- bounded status filtering for Review Publication Resolution inbox states
- bounded updated-date filtering for Review Publication Resolution items, limited in v1 to `Any time`, `Last 24 hours`, `Last 7 days`, and `Last 30 days`
- no generic resolution-type registry
Status and updated-date filtering must follow existing page patterns or remain tightly bounded to this family.
## Existing Action / Link Pattern
The existing renderer supports:
- one primary action per operator item
- source-family dominant action
- secondary actions
- linked records inside collapsed context
- destination URLs on source entries
Existing source families use links such as:
- Finding view/resource URLs
- Finding exception queue/resource URLs
- OperationRun links through `OperationRunLinks`
- Review context links through `EnvironmentReviewResource`
- Customer Review Workspace links
Spec 389 should use:
- default primary action: Resolution Page
- secondary action: Review detail when authorized
- optional operation action: only after scope/currentness/context/RBAC validation
No action may mutate resolution, review, report, evidence, export, OperationRun, or publish state from the inbox.
## Existing Operation Link Rendering
The current stale operation family renders OperationRun entries as a source family and uses:
- `OperationRunLinks::identifier($run)`
- `OperationRunLinks::tenantlessView($run, $navigationContext)`
- `OperationRunLinks::index(...)`
That pattern is not sufficient by itself for Spec 389 operation disclosure. Review Publication Resolution operation links need extra validation:
- same workspace
- same environment
- same EnvironmentReview where applicable
- same Resolution Case where applicable
- current step or current safe proof summary
- expected operation type
- Spec 388 currentness/visibility/usability
- `OperationRunPolicy::view`
If any check fails, the inbox must not render the OperationRun ID or URL.
## Existing RBAC and Scope Enforcement
The page enforces workspace membership on mount. Source families receive prefiltered environment arrays:
- authorized environments
- visible finding environments
- review environments
`ReviewPublicationResolutionCasePolicy::view` already verifies:
- case tenant exists
- case review exists
- user can access tenant
- case workspace matches tenant workspace
- review workspace and environment match case/tenant
- current user has `ENVIRONMENT_REVIEW_VIEW`
`OperationRunPolicy::view` already verifies:
- workspace membership
- environment entitlement for environment-scoped operations
- operation-specific view capability
Spec 389 must use these policies but operation links require additional source-context checks beyond permission.
## Existing Empty States
The page has:
- summary empty state when no lanes are visible
- lane empty states
- source-family empty states
- environment filter empty states
- family filter empty states
Spec 389 required copy:
- `No review publication work needs attention`
- `No accessible review publication work`
- `No items match this filter`
Implementation should place this copy in the new source family and page-level empty state only where it does not misrepresent other source families.
## Existing Collapsed Details
The first-screen item shows:
- status badge
- environment badge
- title
- context label
- primary action
- reason and impact
Collapsed `More context` shows:
- source
- owner / due
- evidence
- accepted-risk / decision
- linked records
- secondary actions
This matches Spec 389's detail hierarchy. Technical proof/currentness internals should remain absent or collapsed behind already-authorized source surfaces.
## Existing Review Publication Resolution Foundations
Relevant runtime files for later implementation:
- `apps/platform/app/Models/ReviewPublicationResolutionCase.php`
- `apps/platform/app/Models/ReviewPublicationResolutionStep.php`
- `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionCaseStatus.php`
- `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionStepStatus.php`
- `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationProofResolver.php`
- `apps/platform/app/Support/ReviewPublicationResolution/ResolutionProofEvaluation.php`
- `apps/platform/app/Policies/ReviewPublicationResolutionCasePolicy.php`
- `apps/platform/app/Policies/OperationRunPolicy.php`
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ResolveReviewPublication.php`
Existing case statuses:
- `open`
- `in_progress`
- `waiting_for_run`
- `blocked`
- `ready_to_continue`
- `completed`
- `cancelled`
- `superseded`
Existing step statuses:
- `pending`
- `actionable`
- `running`
- `failed`
- `completed`
- `superseded`
Spec 389 should consume these states and Spec 388 proof evaluation. It should not add a new data model or persisted status family.
## Implementation Implications
- Best fit is either a concrete `ReviewPublicationResolutionInboxProvider` or a tightly scoped builder method.
- Reuse existing page rendering wherever possible.
- Add the family to `FAMILY_ORDER` and `available_families` only when visible.
- Add classifier support for the new family in `GovernanceInbox::classifyLane()`.
- Add secondary/linked record handling only for Resolution, Review, and validated Operation links.
- Keep technical details collapsed or absent.
- Keep customer-facing surfaces untouched.
- No panel provider registration changes are needed.
- No new Filament assets are expected.

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View File

@ -0,0 +1,68 @@
# Requirements Quality Checklist
**Feature**: 389 - Governance Inbox Resolution Intake v1
**Created**: 2026-06-19
**Purpose**: Validate that the Spec 389 artifacts are ready for a later implementation loop.
## Content Quality
- [x] No implementation code is mixed into the specification.
- [x] User value and operator workflow are stated clearly.
- [x] Non-goals explicitly exclude generic workflow/task/adapter engines.
- [x] Requirements are testable.
- [x] Acceptance criteria are measurable.
- [x] Customer-facing exclusion is explicit.
- [x] OperationRun disclosure constraints are explicit.
- [x] Currentness fallback behavior is explicit.
- [x] No unresolved clarification markers remain.
- [x] UI Action Matrix is present for the changed operator-facing surface.
- [x] UI coverage artifact decision is explicit for this pattern-reusing extension.
- [x] Updated-date filter presets are bounded for v1.
- [x] OperationRun primary action eligibility is constrained to validated waiting items.
- [x] No-migration validation is represented as a numbered implementation task.
## Scope Control
- [x] The spec targets the existing Governance Inbox.
- [x] No new top-level navigation is required.
- [x] No new global-search Resource is required.
- [x] No new persisted entity is required.
- [x] No migration is recommended by default.
- [x] Inline mutation, publish, cancel, refresh, report update, evidence collection, and export preparation actions are out of scope.
- [x] Future restore/provider/baseline/report-delivery intakes are deferred to later specs.
## Constitution and Product Guardrails
- [x] Governance Inbox remains read-only.
- [x] Spec 388 proof/currentness remains authoritative.
- [x] Unknown or unsafe state falls back to `Needs re-check`.
- [x] Viewer-relative inbox status is not persisted.
- [x] Workspace and environment isolation are required.
- [x] Capability-first RBAC is required.
- [x] Raw provider, Graph, evidence, report, exception, token, secret, fingerprint, proof reason, and raw operation metadata are excluded from default UI/audit.
- [x] OperationRun permission is necessary but not sufficient for link disclosure.
## Filament / UI Readiness
- [x] Filament v5 and Livewire v4.1.4 compatibility is documented in `plan.md`.
- [x] Panel provider registration impact is documented as none.
- [x] Global search impact is documented as none.
- [x] Destructive action impact is documented as none in the Inbox.
- [x] Asset strategy is documented as no new Filament assets expected.
- [x] Testing plan includes Feature/Filament tests and optional Browser smoke.
## Artifact Completeness
- [x] `spec.md` exists.
- [x] `plan.md` exists.
- [x] `tasks.md` exists.
- [x] `contracts/review-publication-resolution-inbox-item.md` exists.
- [x] `contracts/status-mapping.md` exists.
- [x] `artifacts/current-governance-inbox-inventory.md` exists.
## Residual Assumptions
- [x] Spec 386, 387, and 388 foundations are stable enough for consumption on the current baseline.
- [x] Existing Governance Inbox entry rendering can express the new source family without a new page.
- [x] Existing Spec 386 indexes are sufficient until implementation proves otherwise.
- [x] Browser harness availability is implementation-time dependent.

View File

@ -0,0 +1,230 @@
# Review Publication Resolution Inbox Item Contract
**Feature**: 389 - Governance Inbox Resolution Intake v1
**Status**: Contract for later implementation
**Scope**: One concrete Governance Inbox item type for Review Publication Resolution Cases
## Purpose
This contract maps existing `ReviewPublicationResolutionCase` records into operator-facing Governance Inbox items. It is a read-only intake contract. It must not become a generic workflow, task, adapter, or proof/currentness abstraction.
## Allowed Item Type
Only this item type is allowed in Spec 389:
```text
review_publication_resolution
```
Do not introduce generic item types such as `resolution_case`, `workflow_task`, `action_case`, `generic_readiness_item`, or `generic_resolution_item`.
## Source Truth
The item may consume:
- `ReviewPublicationResolutionCase` fields and relationships that are already scoped to the current workspace.
- The current `ReviewPublicationResolutionStep`, loaded through the case relationship.
- Existing safe summaries from Review Publication Resolution and Spec 388 proof/currentness logic.
- Existing policy/authorizer answers for the current viewer.
- Existing validated links to Resolution, Review, and OperationRun pages.
The item must not independently infer readiness or proof currentness from raw:
- OperationRun metadata.
- StoredReport metadata.
- EvidenceSnapshot metadata.
- ReviewOutput metadata.
- ReviewPack metadata.
- persisted `proof_summary`.
- persisted `operation_run_id`.
- `readiness_fingerprint`.
- internal step keys.
If safe classification cannot be completed cheaply in list rendering, the item status must be `needs_recheck` and the primary action must navigate to the Resolution Page.
## Existing Governance Inbox Entry Shape
The existing Governance Inbox renderer consumes section entries and normalizes them into operator items. A Review Publication Resolution entry should follow this shape unless implementation introduces a small source-specific DTO that is immediately normalized into the same fields:
| Existing entry key | Required | Contract |
| --- | --- | --- |
| `family_key` | Yes | `review_publication_resolution` |
| `source_model` | Yes | `App\Models\ReviewPublicationResolutionCase` |
| `source_key` | Yes | Case key as an internal source reference, not rendered as the item title. |
| `managed_environment_id` | Yes | Environment ID for environment-scoped cases. |
| `tenant_label` | Yes | Safe environment label visible to the viewer. |
| `headline` | Yes | Operator-facing title, for example `Review can't be published yet`. |
| `subline` | Optional | Safe review label, owner, or last-update context. No raw IDs unless already safe and useful. |
| `urgency_rank` | Yes | Sort rank derived from inbox status. |
| `status_label` | Yes | Human label for mapped inbox status. |
| `destination_url` | Yes when inspectable | Resolution Page URL or, only if primary operation action is safer and validated, OperationRun URL. |
| `decision_label` | Yes | Human decision label such as `Continue review publication preparation`. |
| `reason_label` | Yes | Human reason summary. No internal step/proof reason keys. |
| `impact_label` | Yes | Human impact summary, for example `Review publication remains blocked`. |
| `owner_label` | Optional | Assignee/creator/current owner if available and safe. |
| `due_label` | Optional | `Updated <relative time>` or existing due label pattern. |
| `evidence_label` | Optional | Safe proof availability summary. |
| `evidence_state` | Optional | Display state only, for example `available`, `missing`, `needs_recheck`. |
| `evidence_path_label` | Optional | Safe link label only. |
| `evidence_path_url` | Optional | Validated Review/Resolution/Operation URL only. |
| `exception_label` | Optional | Usually `No accepted-risk state`. |
| `primary_action_label` | Yes | `Continue preparation`, `Inspect preparation`, or validated `Open operation`. |
| `primary_action_url` | Yes when inspectable | URL to the allowed page. |
| `back_label` | Optional | Existing navigation context label. |
## Conceptual Item Fields
If a DTO is used, it must still represent only display-safe fields:
```php
final readonly class GovernanceInboxItem
{
public function __construct(
public string $type,
public string $key,
public string $status,
public string $severity,
public string $title,
public string $summary,
public string $nextActionLabel,
public ?string $nextActionUrl,
public ?string $environmentLabel,
public ?string $subjectLabel,
public ?CarbonImmutable $updatedAt,
public ?CarbonImmutable $createdAt,
public ?int $ownerId = null,
public array $badges = [],
public array $safeMetadata = [],
) {}
}
```
The DTO is optional. If added, it must be concrete to this source family or broadly display-only without creating a generic intake registry.
## Required Display Semantics
Each item must answer:
- What needs attention?
- Why?
- Which review/environment is affected?
- What is the next safe action?
- Is there current proof?
- Who owns it, if known?
- Where does the operator continue?
Default examples:
| Status | Title | Reason | Primary action |
| --- | --- | --- | --- |
| `needs_attention` | Review can't be published yet | Required reports are missing | Continue preparation |
| `waiting` | Review preparation running | Waiting for operation to finish | Continue preparation or validated Open operation |
| `failed` | Review preparation needs attention | Operation failed while preparing the review | Continue preparation |
| `ready_to_continue` | Review preparation can continue | Publication preparation can continue | Continue preparation |
| `needs_recheck` | Review preparation needs re-check | Preparation status needs to be refreshed | Continue preparation |
| `blocked` | Review preparation needs operator access | You can inspect this preparation, but cannot execute the next action | Inspect preparation |
## Safe Metadata Rules
`safe_metadata`, entry context, linked records, and secondary actions may include:
- display status.
- display severity.
- display step label.
- display proof label.
- environment label.
- review label.
- validated action labels and URLs.
- owner display label.
- last updated timestamp.
They must not include:
- raw provider payloads.
- raw Graph responses.
- raw evidence, report, review output, or review pack payloads.
- raw exception messages.
- secrets or tokens.
- `readiness_fingerprint`.
- internal proof reason codes by default.
- internal step keys by default.
- raw `operation_run_id` unless the operation link is fully validated and the ID is needed for an authorized link label.
- StoredReport IDs unless scope-safe and explicitly needed.
- EvidenceSnapshot IDs unless scope-safe and explicitly needed.
- OperationRun metadata.
## Link Contract
### Resolution Page
Use the existing Review Publication Resolution Page as the default destination:
```text
EnvironmentReviewResource::environmentScopedUrl('resolve-publication', ['record' => $review], $tenant)
```
The link is allowed only when the viewer may inspect the case. If the viewer can inspect but not execute, the label should be `Inspect preparation` or `Continue preparation` and the Resolution Page must remain inspection-only.
### Review Detail
`Open review` is secondary and allowed only when the viewer can view the Review in the same workspace/environment context.
### OperationRun Detail
`Open operation` is allowed only when all checks pass:
- same workspace.
- same environment where applicable.
- same EnvironmentReview context where applicable.
- same Review Publication Resolution Case context where available.
- operation belongs to the current step or current safe proof summary.
- operation/action type is expected for the current step.
- Spec 388 currentness/usability/visibility is acceptable for disclosure.
- current user can view the OperationRun.
Permission alone is not sufficient. Do not build operation URLs directly from persisted step metadata. `Open operation` may be the primary action only for a validated `waiting` item where opening the operation is the safest next action; otherwise it must be secondary or hidden. If any check fails, render a non-linked safe label such as `Operation is running` or fallback to `Needs re-check`.
## RBAC and Scope
The provider/query must hide items that the viewer cannot access. It must not reveal inaccessible counts.
Required visibility checks:
- workspace membership.
- environment entitlement.
- subject Review visibility.
- `ReviewPublicationResolutionCasePolicy::view`.
- safe summary visibility.
- customer-facing users and customer workspace surfaces receive no internal item.
Viewer-relative status must not be persisted back to the case.
## Sorting Contract
Default active sorting:
1. `failed`
2. `blocked`
3. `needs_attention`
4. `needs_recheck`
5. `ready_to_continue`
6. `waiting`
7. newest `updated_at` inside each group
If the existing Governance Inbox lane sorter is reused, map urgency ranks to preserve that order inside the new source family.
## Negative Contract
Spec 389 does not allow:
- inline provider checks.
- inline Entra scans.
- inline evidence collection.
- inline review refresh.
- inline export preparation.
- inline cancel resolution.
- inline publish.
- new top-level navigation.
- new global search Resource.
- generic workflow or task model.
- generic adapter registry.

View File

@ -0,0 +1,155 @@
# Status Mapping Contract
**Feature**: 389 - Governance Inbox Resolution Intake v1
**Status**: Contract for later implementation
## Allowed Inbox Statuses
V1 allows only these statuses:
| Status | Default visibility | Meaning |
| --- | --- | --- |
| `needs_attention` | Visible | Required input or preparation work needs operator attention. |
| `needs_recheck` | Visible | Persisted state may be stale, unsafe, unknown, hidden, or too expensive to classify in list view. |
| `waiting` | Visible | A current, scope-valid, context-valid, visible operation is running. |
| `ready_to_continue` | Visible | Current step can continue for this viewer. |
| `failed` | Visible | Current, scope-valid failure needs attention. |
| `blocked` | Visible | Current viewer cannot execute next action but may inspect, or case is blocked. |
| `completed` | Hidden by default | No active work. May appear only in explicit history/completed filters. |
| `cancelled` | Hidden by default | No active work. May appear only in explicit history filters. |
| `superseded` | Hidden by default | No active work. May appear only in explicit history filters. |
## Severity Mapping
| Inbox status | Severity |
| --- | --- |
| `failed` | `high` |
| `blocked` | `high` |
| `needs_attention` | `medium` |
| `needs_recheck` | `medium` |
| `ready_to_continue` | `medium` |
| `waiting` | `info` |
| `completed` | `info` |
| `cancelled` | `info` |
| `superseded` | `info` |
Do not use critical severity for ordinary review publication preparation gaps.
## Case and Step Mapping
| Source condition | Inbox status | Primary action | Notes |
| --- | --- | --- | --- |
| Case `completed` | `completed` | None by default | Hidden from active inbox. |
| Case `cancelled` | `cancelled` | None by default | Hidden from active inbox. |
| Case `superseded` | `superseded` | None by default | Hidden from active inbox. |
| Case `blocked` and viewer can inspect | `blocked` | Inspect preparation | Viewer-relative. Do not persist. |
| Current step actionable and viewer can execute | `ready_to_continue` | Continue preparation | Only if proof/currentness does not contradict continuation. |
| Current step actionable but viewer cannot execute and can inspect | `blocked` | Inspect preparation | Resolution Page remains inspection-only. |
| Current step running and linked operation is current, scope-valid, context-valid, expected type, and visible | `waiting` | Continue preparation or Open operation | Open operation can be primary only when safest. |
| Current step running but operation validation fails | `needs_recheck` | Continue preparation | Do not render operation ID or URL. |
| Current step failed and failed proof is current, scope-valid, context-valid, and visible | `failed` | Continue preparation | Do not infer from stale operation status. |
| Current step failed but failure cannot be proven current and safe | `needs_recheck` | Continue preparation | Conservative fallback. |
| Required proof/input missing or stale and safe summary says operator work is needed | `needs_attention` | Continue preparation | Human reason such as `Required reports are missing`. |
| Proof status hidden, unknown, operator-limited, inspection-only, failed, stale, or unsafe for completion | `needs_recheck` or `needs_attention` | Continue preparation | Never produce `ready_to_continue`. |
| Persisted state cannot be cheaply and safely classified | `needs_recheck` | Continue preparation | Default fail-closed behavior. |
## Needs-Recheck Fallback Rules
Use `needs_recheck` when any of the following is true:
- linked operation may be stale.
- linked operation belongs to another workspace, environment, review, case, or step.
- operation type is missing or unexpected.
- OperationRun completed or failed after the case/step state was written and no current safe summary resolves it.
- proof/currentness summary is missing, unknown, stale, hidden, failed, inspection-only, or operator-limited.
- proof summary contains only persisted raw metadata without Spec 388 validation.
- currentness validation is too expensive for list rendering.
- provider cannot safely decide between waiting, failed, and ready-to-continue.
`needs_recheck` is not an error. It is the safe list-view route back to the Resolution Page.
## OperationRun Link Eligibility
Render operation ID, operation label, operation URL, or `Open operation` only when every check is true:
| Check | Required rule |
| --- | --- |
| Workspace | `operation_runs.workspace_id` equals case workspace. |
| Environment | `operation_runs.managed_environment_id` equals case environment where environment-scoped. |
| Review context | Operation context or safe proof relation matches the case EnvironmentReview where applicable. |
| Case context | Operation context or safe proof relation matches the displayed ReviewPublicationResolutionCase where applicable. |
| Step context | Operation belongs to current step or current safe proof summary for that step. |
| Expected type | Operation type/action is expected for the current step. |
| Currentness | Spec 388 currentness/visibility/usability permits disclosure. |
| RBAC | Current user can view the OperationRun. |
If any check fails:
- do not render `operation_run_id`.
- do not render operation URL.
- do not render `Open operation`.
- show `Operation is running` without a link only when running state itself is safe.
- otherwise show `Needs re-check`.
## Viewer-Relative Behavior
Inbox status is computed for the current viewer:
- An executor may see `ready_to_continue`.
- A read-only operator may see `blocked` or inspection-only behavior.
- A customer-facing user must see no internal item.
The computed inbox status must never be written back to `review_publication_resolution_cases.status`.
## Human Summary Labels
Allowed default reason summaries:
- Required reports are missing.
- Evidence needs to be collected.
- Review needs to be refreshed.
- Export needs to be prepared.
- Operation failed while preparing the review.
- Waiting for operation to finish.
- Publication preparation can continue.
- Preparation status needs to be refreshed.
- You can inspect this preparation, but cannot execute the next action.
Forbidden default reason summaries:
- internal step keys.
- `proof_currentness=stale`.
- `StoredReport missing`.
- `OperationRun failed`.
- `resolution_step failed`.
- `readiness_fingerprint mismatch`.
- proof reason codes.
- `operator_limited`.
- `current_step_key`.
## Sorting Rank
Use this rank when adding the source family to the existing Governance Inbox ordering:
| Inbox status | Recommended urgency rank |
| --- | --- |
| `failed` | 0 |
| `blocked` | 1 |
| `needs_attention` | 2 |
| `needs_recheck` | 3 |
| `ready_to_continue` | 4 |
| `waiting` | 5 |
| inactive history statuses | not shown by default |
Inside equal rank, sort newest `updated_at` first if the existing inbox sort permits it.
## Updated-Date Filter Presets
V1 updated-date filtering is bounded to these presets:
- `Any time`
- `Last 24 hours`
- `Last 7 days`
- `Last 30 days`
Custom date ranges, free-form dates, saved filter views, and generic date-filter registries are out of scope for Spec 389.

View File

@ -0,0 +1,309 @@
# Implementation Plan: Governance Inbox Resolution Intake v1
**Branch**: `389-governance-inbox-resolution-intake-v1` | **Date**: 2026-06-19 | **Spec**: `specs/389-governance-inbox-resolution-intake-v1/spec.md`
**Input**: Feature specification from `/specs/389-governance-inbox-resolution-intake-v1/spec.md`
## Summary
Add active Review Publication Resolution Cases to the existing Governance Inbox as a concrete, read-only source family. The inbox should show operator-friendly review publication work items, map existing case/step/proof state into conservative viewer-relative statuses, and navigate only to existing authorized Resolution, Review, or Operation pages. It must reuse Spec 388 proof/currentness semantics or fail closed to `Needs re-check`, and it must not introduce a generic workflow/task engine, inline execution, publish action, customer-facing leakage, or a second proof evaluator.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52.0, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, Laravel Sail
**Storage**: PostgreSQL; no migration expected for v1
**Testing**: Pest 4 Feature, Filament/Livewire Feature, optional Browser smoke
**Validation Lanes**: fast-feedback + confidence; browser when UI smoke is available; PostgreSQL only if a later spec update adds schema/index work
**Target Platform**: Laravel monolith under `apps/platform`, Sail-first locally, Dokploy/container deployment for staging/production
**Project Type**: Web application, existing Filament admin panel
**Performance Goals**: Governance Inbox list rendering remains DB-only and bounded to the existing inbox page limit/profile; no per-row Graph/provider calls
**Constraints**: Read-only intake, no inline mutations, no raw payloads, no customer leakage, no unvalidated operation IDs or links, no generic registry/engine
**Scale/Scope**: One concrete Review Publication Resolution intake source over existing workspace/environment-scoped cases
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed existing operator-facing Governance Inbox surface.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` only if the existing rendering shape cannot express required labels/actions
- Existing Environment Review Resolution Page links through `EnvironmentReviewResource::environmentScopedUrl('resolve-publication', ...)`
- Existing OperationRun detail links through `OperationRunLinks` after validation
- **No-impact class, if applicable**: N/A.
- **Native vs custom classification summary**: Existing custom Governance Inbox Blade page using Filament components; reuse current layout and source-family/lanes pattern.
- **Shared-family relevance**: Governance Inbox source family, status messaging, action links, OperationRun links, review/evidence status summaries.
- **State layers in scope**: page source-family data, lane classification, source-detail disclosure, environment filter, query/provider mapping.
- **Audience modes in scope**: operator-MSP, read-only operator/inspector, customer-safe negative boundary.
- **Decision/diagnostic/raw hierarchy plan**: default item copy answers what/why/where/next safe action; operation/proof/source details stay collapsed or linked to existing authorized pages; raw payloads are absent.
- **Raw/support gating plan**: no raw/support detail in inbox. Use existing source detail pages for authorized technical depth.
- **One-primary-action / duplicate-truth control**: each item has one dominant primary action: `Continue preparation`, `Inspect preparation`, or in narrowly validated waiting cases `Open operation`.
- **Handling modes by drift class or surface**: review-mandatory for operation-link disclosure; hard-stop if inline mutation or customer leakage appears.
- **Repository-signal treatment**: existing Governance Inbox and Resolution Page are repo-real; no new navigation/page report unless implementation materially changes UI patterns.
- **Special surface test profiles**: governance workbench / standard-native-filament; browser smoke for representative states where harness exists.
- **Required tests or manual smoke**: functional-core mapping, RBAC/scope negative cases, operation-link disclosure, no inline mutation, no customer leakage, mobile/readability smoke where available.
- **Exception path and spread control**: none. A concrete provider class is allowed; a generic registry is not.
- **Active feature PR close-out entry**: Guardrail + Smoke Coverage if browser smoke runs.
- **UI/Productization coverage decision**: existing strategic surface changed; update audit registry only if runtime implementation materially changes page archetype, route, or design coverage beyond a source-family extension.
- **Coverage artifacts to update**: no coverage artifact update is required during preparation because Spec 389 reuses the existing Governance Inbox route, archetype, and source-family pattern. Implementation must re-check `docs/ui-ux-enterprise-audit/route-inventory.md` and `design-coverage-matrix.md` and update them if runtime work materially changes page archetype, route inventory, strategic-surface classification, or design coverage.
- **No-impact rationale**: N/A.
- **Navigation / Filament provider-panel handling**: no panel provider or navigation registration changes.
- **Screenshot or page-report need**: screenshots recommended under this spec if browser harness is available; no new page report by default.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**:
- Governance Inbox page and source-family builder.
- Review Publication Resolution case/step/proof state.
- Environment Review resource URLs.
- OperationRun link and policy systems.
- Capability-first RBAC and workspace/environment scope enforcement.
- **Shared abstractions reused**:
- Existing `GovernanceInboxSectionBuilder` entry shape and `GovernanceInbox` lane normalization.
- Existing `ReviewPublicationResolutionCasePolicy`.
- Existing `ReviewPublicationResolutionStepAuthorizer`.
- Existing Spec 388 `ResolutionProofEvaluation`/currentness metadata.
- Existing `OperationRunLinks` and `OperationRunPolicy`.
- **New abstraction introduced? why?**: Prefer a small concrete `ReviewPublicationResolutionInboxProvider` if direct builder growth would make scope/currentness mapping hard to review. It must not be a registry, interface hierarchy, or generic adapter framework.
- **Why the existing abstraction was sufficient or insufficient**: Existing Governance Inbox builder is sufficient for rendering and lane normalization; it lacks only a concrete source of Review Publication Resolution Cases.
- **Bounded deviation / spread control**: provider may return normalized display arrays or a lightweight DTO only for this source family. Future adapters need separate specs.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, link display only.
- **Central contract reused**: `OperationRunLinks` for labels/URLs and `OperationRunPolicy` for authorization after additional context/currentness validation.
- **Delegated UX behaviors**: tenant/workspace-safe URL resolution only; no queued toast, artifact link, browser event, dedupe/start failure, or terminal notification changes.
- **Surface-owned behavior kept local**: deciding whether a validated operation link is shown for an inbox item.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: N/A.
- **Exception path**: none.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no new shared provider boundary.
- **Provider-owned seams**: existing report/evidence/review-pack preparation remains owned by Review Publication Resolution and source services.
- **Platform-core seams**: Governance Inbox consumes safe work item summaries and links only.
- **Neutral platform terms / contracts preserved**: workspace, environment, review, operation, proof, preparation, governance inbox.
- **Retained provider-specific semantics and why**: required report dimensions may originate in Microsoft/Entra workflows, but the inbox presents human labels only and does not expose provider payloads.
- **Bounded extraction or follow-up path**: future restore/provider/baseline/report delivery intakes are follow-up specs only.
## Constitution Check
*GATE: Must pass before implementation. Re-check after implementation design is finalized.*
- Inventory-first: N/A for new inventory; consumes existing review/resolution artifacts only.
- Read/write separation: PASS. Governance Inbox remains read-only; all writes stay on source-owned Resolution/Review pages with existing confirmation/audit.
- Graph contract path: PASS. No Graph calls may occur during inbox render.
- Deterministic capabilities: PASS. Use existing capability resolvers/policies; no raw role strings.
- RBAC-UX: PASS. Workspace membership, environment entitlement, review/case permission, 404/403 semantics, and server-side policies stay authoritative.
- Workspace isolation: PASS. Queries must scope by workspace before mapping.
- Tenant/environment isolation: PASS. Environment-specific cases must not appear outside authorized environments or another environment filter.
- Run observability: PASS. No new operation starts; linked OperationRuns remain execution truth.
- OperationRun start UX: PASS. Only safe links are displayed; no start UX added.
- Ops-UX lifecycle: PASS. No OperationRun status/outcome changes.
- Summary counts contract: N/A.
- Ops-UX guards: N/A unless implementation touches operation guard patterns.
- Data minimization: PASS. Safe metadata is display-only and excludes raw payloads/secrets/tokens/exceptions/fingerprints/reason codes by default.
- Test governance: PASS if tasks add focused Feature/Filament/browser tests with explicit lane classification.
- Proportionality: PASS. One concrete provider/mapping is justified by an existing workflow visibility gap.
- No premature abstraction: PASS. No registry/engine/generic adapter is approved.
- Persisted truth: PASS. No new persistence.
- Behavioral state: PASS. Inbox status is derived viewer-relative display only.
- UI semantics: PASS if direct mapping from canonical case/step/safe proof state is used without a new taxonomy framework.
- Shared pattern first: PASS. Existing Governance Inbox and OperationRun links are reused.
- Provider boundary: PASS. No platform-core provider coupling added.
- V1 explicitness / few layers: PASS. Direct source-specific mapping preferred.
- Spec discipline / bloat check: PASS. Scope is one coherent, narrow spec.
- Filament-native UI: PASS. Existing Filament primitives and page pattern reused.
- Decision-first operating model: PASS. Existing Governance Inbox remains the primary decision surface; no new navigation.
- Audience-aware disclosure: PASS. Customer-facing surfaces do not receive internal resolution intake.
- UI/Productization coverage: PASS. Existing strategic surface change is classified.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature for mapping and RBAC/scope; Filament/Livewire Feature for page rendering; Browser for representative visual/user workflow and mobile/customer no-leakage if harness exists.
- **Affected validation lanes**: fast-feedback, confidence, optional browser.
- **Why this lane mix is the narrowest sufficient proof**: Runtime change is read-only mapping/UI. It does not need PostgreSQL unless indexes/migrations are later added.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/GovernanceInbox/Spec389GovernanceInboxResolutionIntakeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec389GovernanceInboxResolutionIntakeTest.php` if added
- `cd apps/platform && ./vendor/bin/pint --dirty --format agent`
- `git diff --check`
- **Fixture / helper / factory / seed / context cost risks**: Review/resolution/operation fixtures can be expensive. Keep helpers explicit and local; do not broaden global defaults.
- **Expensive defaults or shared helper growth introduced?**: no planned shared default growth.
- **Heavy-family additions, promotions, or visibility changes**: optional browser smoke must be explicit in file name and validation notes.
- **Surface-class relief / special coverage rule**: existing governance workbench surface; browser only for representative states and mobile clarity.
- **Closing validation and reviewer handoff**: verify no inline mutation, no publish, no generic engine, no operation ID leakage, no customer leakage, and correct needs-recheck fallback.
- **Budget / baseline / trend follow-up**: none expected; document-in-feature if browser fixture scope grows materially.
- **Review-stop questions**: Did every operation link pass more than permission? Did stale state fail closed? Did read-only users avoid execution? Did customers see nothing internal?
- **Escalation path**: document-in-feature for contained fixture cost; follow-up-spec for repeated generic-intake pressure.
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage.
- **Why no dedicated follow-up spec is needed**: v1 is source-specific and bounded. Generic intake for future adapters is explicitly rejected.
## Project Structure
### Documentation (this feature)
```text
specs/389-governance-inbox-resolution-intake-v1/
|-- artifacts/
| `-- current-governance-inbox-inventory.md
|-- checklists/
| `-- requirements.md
|-- contracts/
| |-- review-publication-resolution-inbox-item.md
| `-- status-mapping.md
|-- plan.md
|-- spec.md
`-- tasks.md
```
### Source Code (repository root)
Likely runtime surfaces for later implementation:
```text
apps/platform/
|-- app/
| |-- Filament/
| | |-- Pages/Governance/GovernanceInbox.php
| | `-- Resources/EnvironmentReviewResource/
| | `-- Pages/ResolveReviewPublication.php
| |-- Models/
| | |-- ReviewPublicationResolutionCase.php
| | `-- ReviewPublicationResolutionStep.php
| |-- Policies/
| | |-- OperationRunPolicy.php
| | `-- ReviewPublicationResolutionCasePolicy.php
| `-- Support/
| |-- GovernanceInbox/
| | |-- GovernanceInboxSectionBuilder.php
| | `-- ReviewPublicationResolutionInboxProvider.php (candidate)
| |-- OperationRunLinks.php
| `-- ReviewPublicationResolution/
| |-- ReviewPublicationProofResolver.php
| |-- ReviewPublicationResolutionCaseStatus.php
| |-- ReviewPublicationResolutionStepAuthorizer.php
| `-- ReviewPublicationResolutionStepStatus.php
|-- resources/views/filament/pages/governance/governance-inbox.blade.php
`-- tests/
|-- Feature/GovernanceInbox/Spec389GovernanceInboxResolutionIntakeTest.php
`-- Browser/Spec389GovernanceInboxResolutionIntakeTest.php (if browser coverage is added)
```
## Technical Approach
1. Extend the existing Governance Inbox data pipeline with a concrete Review Publication Resolution source family.
2. Query `ReviewPublicationResolutionCase` records scoped by workspace and authorized environment IDs before any item mapping.
3. Eager-load only display-safe relationships: `tenant`, `environmentReview`, `assignee`, `creator`, `steps.operationRun`, and current step data as needed.
4. Map each visible case into the existing Governance Inbox entry shape or a lightweight display DTO that is immediately normalized by the existing page.
5. Add bounded status, environment, type/source-family, and updated-date filtering using the existing Governance Inbox query-string and filter conventions. Updated-date v1 presets are `Any time`, `Last 24 hours`, `Last 7 days`, and `Last 30 days`.
6. Compute viewer-relative inbox status from existing case status, current step status, StepAuthorizer result, and Spec 388 proof/currentness metadata.
7. Revalidate any OperationRun link with:
- same workspace
- same environment
- same EnvironmentReview context where available
- same Resolution Case and current Step
- expected operation/action type for the step
- Spec 388 currentness/usability/visibility rules
- `OperationRunPolicy::view`
8. If validation cannot be done cheaply and safely in list rendering, show `Needs re-check` and link to the Resolution Page.
9. Render default item copy through existing Governance Inbox lanes/source detail without exposing internal keys or raw metadata.
## Domain / Model Implications
- No new persisted model is planned.
- `ReviewPublicationResolutionCase` remains workflow state, not inbox truth.
- `ReviewPublicationResolutionStep` remains step/proof state, not inbox truth.
- Inbox status is derived per viewer and must not be written back.
- `completed`, `cancelled`, and `superseded` are hidden by default.
- Existing `assigned_to_user_id`, `status`, `updated_at`, and `managed_environment_id` indexes from Spec 386 should be used before adding schema churn.
## UI / Filament Implications
- Livewire v4.0+ compliance: the app runs Livewire 4.1.4 and no Livewire v3 APIs are planned.
- Provider registration location: no panel provider changes; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
- Global search: no new Resource and no new global-search surface. Existing global search rules remain unchanged.
- Destructive/high-impact actions: none added to Governance Inbox. Existing Resolution Page actions keep confirmation, authorization, audit, and tests.
- Asset strategy: no new registered Filament assets expected; `filament:assets` is not newly required by this spec.
- Testing plan: Feature/Filament tests for the Governance Inbox item mapping/rendering and optional browser smoke for representative states.
## RBAC / Policy Implications
- Use existing workspace membership and environment entitlement patterns.
- Use `ReviewPublicationResolutionCasePolicy::view` for case visibility.
- Use `ReviewPublicationResolutionStepAuthorizer` only to derive viewer-relative `ready_to_continue` versus `blocked`/inspection behavior.
- Use `OperationRunPolicy::view` as necessary but not sufficient for operation disclosure.
- Hide inaccessible rows instead of exposing counts.
- Preserve read-only inspection behavior for users who can view cases but cannot execute steps.
## Audit / Logging / Evidence Implications
- Inbox list rendering does not emit audit events.
- Clicking a navigation link may rely on existing page-level behavior; no new audit event is required unless implementation adds persisted state, which is not planned.
- No raw provider, Graph, evidence, report, exception, token, secret, readiness fingerprint, proof reason code, or operation metadata should be logged or rendered from the inbox.
## Data / Migration Implications
- Preferred: no migration.
- Existing Spec 386 migration already added:
- `review_publication_cases_status_idx`
- `review_publication_cases_review_action_idx`
- `review_publication_cases_assigned_to_idx`
- step `operation_run_id` index
- active-current partial unique index
- summary GIN indexes
- A new index may be considered only if implementation proves the current query path is too slow and the spec/plan are updated first.
## Performance Plan
- Scope by workspace first.
- Filter active statuses by default.
- Filter environment IDs before mapping.
- Eager-load required references in one query path.
- Avoid per-row Graph/provider/API calls.
- Avoid full readiness recomputation for every row.
- Limit default result count to the existing inbox pattern or 25 if a new provider pagination boundary is required.
- Use `Needs re-check` when currentness validation would be expensive or unsafe.
## Implementation Phases
### Phase 1 - Inventory and Contract Verification
Re-read the existing Governance Inbox, Review Publication Resolution, policy, OperationRun link, and Spec 388 proof/currentness surfaces. Verify the mapping contracts in this package match current repo truth before code edits.
### Phase 2 - Provider / Mapping Core
Implement a concrete provider or builder extension that queries visible active cases, maps case/step/proof state into inbox entries, hides default inactive states, and sorts by severity/status.
### Phase 3 - Operation Link and Currentness Hardening
Add operation-link validation beyond `OperationRunPolicy::view`, including scope/context/current step/expected type/currentness checks. Fall back to `Needs re-check` when unsafe.
### Phase 4 - Governance Inbox UI Integration
Add the source family/filter/section to the existing inbox rendering with decision-first labels and existing lane/source-detail behavior. Keep customer-facing surfaces untouched.
### Phase 5 - Tests, Browser Smoke, and Close-Out
Add focused Feature/Filament tests, optional browser smoke/screenshots, run focused regression commands, run Pint/diff checks, and record implementation close-out without claiming unrun suite coverage.
## Risk Controls
- **Generic engine risk**: only one concrete item type and provider/family; no registry.
- **Inline action risk**: no step/publish/cancel actions in inbox; links only.
- **Currentness risk**: use Spec 388 semantics or `Needs re-check`.
- **Operation ID disclosure risk**: hide ID/link unless every validation passes.
- **Customer leakage risk**: add negative tests; customer workspace remains separate.
- **Performance risk**: DB-only scoped query, eager loading, no full recomputation loop.
- **Viewer-relative confusion risk**: never persist inbox status; label read-only users as inspect/blocked where appropriate.
## Spec Readiness Gate
This plan is ready for a later implementation loop when:
- `spec.md`, `plan.md`, `tasks.md`, contracts, checklist, and inventory artifacts exist.
- No unresolved clarification markers remain.
- The tasks cover mapping, UI integration, RBAC/scope, operation-link hardening, currentness fallback, safe metadata, empty states, tests, browser smoke, and validation.
- The implementation scope remains a bounded source-specific intake over existing Review Publication Resolution truth.

View File

@ -0,0 +1,390 @@
# Feature Specification: Governance Inbox Resolution Intake v1
**Feature Branch**: `389-governance-inbox-resolution-intake-v1`
**Created**: 2026-06-19
**Status**: Draft / Ready for implementation planning after Spec 388 stabilization
**Input**: User-provided consolidated Spec 389 draft: "Governance Inbox Resolution Intake v1"
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Active Review Publication Resolution Cases created by Specs 386-388 are discoverable mainly from the specific Environment Review detail page, so operators must already know which review is blocked before they can continue safe preparation.
- **Today's failure**: Failed, waiting, stale, blocked, or ready-to-continue review publication preparation work can remain hidden from the central Governance Inbox. Teams lack one prioritized triage surface for unresolved publication blockers across visible environments.
- **User-visible improvement**: Operators open the existing Governance Inbox and see active review publication preparation work with a decision-first title, reason, affected review/environment, current safe status, owner when known, and one safe next place to continue.
- **Smallest enterprise-capable version**: Add one concrete `review_publication_resolution` intake family/section over existing Review Publication Resolution Cases, reuse Spec 388 proof/currentness semantics and existing resolution/review/operation pages, and fall back to `Needs re-check` whenever list-view state cannot be validated cheaply and safely.
- **Explicit non-goals**: No generic workflow engine, task system, ticketing/PSA system, adapter registry, top-level navigation, global search resource, customer-facing inbox, inline execution, auto-publish, restore/provider/baseline/report-delivery intake, or second proof/currentness evaluator.
- **Permanent complexity imported**: A narrow Governance Inbox provider or builder extension, display-only mapping contract, focused tests, and optional contract artifacts. No new persistence, migration, global registry, or broad status enum is approved by default.
- **Why now**: Spec 386 created the workflow, Spec 387 made the resolution page decision-first, and Spec 388 hardened proof/currentness. The next operational gap is visibility in the existing operator decision surface without reopening the workflow engine.
- **Why not local**: The Resolution Page remains the source-owned execution context, but discovery must be central because the operator cannot triage hidden cases one review at a time.
- **Approval class**: Workflow Compression.
- **Red flags triggered**: Governance Inbox scope and status mapping could drift into a generic task system. Defense: v1 is limited to one concrete item type, no persisted inbox status, no registry, no inline mutation, and no new top-level surface.
- **Score**: Value: 2 | Urgency: 2 | Scope: 2 | Complexity: 1 | Product fit: 2 | Reuse: 2 | **Total: 11/12**
- **Decision**: approve for preparation; implementation waits on Spec 388 stabilization and focused review.
- **Candidate source**: Direct user-provided Spec 389 draft, manually promoted. `docs/product/spec-candidates.md` currently reports no safe automatic next-best-prep target.
- **Close alternatives deferred**: Restore readiness intake, provider onboarding readiness intake, evidence/baseline readiness intake, report delivery readiness intake, cross-tenant promotion intake, notification routing, and assignment/SLA workflows remain follow-up specs only.
- **Completed-spec guardrail**: Specs 386, 387, and 388 contain completion/validation/checklist signals and are dependency context only. They must not be rewritten by Spec 389.
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view / workspace-wide Governance Inbox with environment-scoped items.
- **Primary Routes**: Existing admin Governance Inbox at `GovernanceInbox` (`/admin/governance/inbox`), existing Environment Review Resolution Page, existing Environment Review detail page, existing OperationRun detail page where allowed.
- **Data Ownership**: Read-only consumption of existing workspace-owned and environment-owned records: `review_publication_resolution_cases`, `review_publication_resolution_steps`, `environment_reviews`, `operation_runs`, and source artifacts used only through safe summaries/currentness semantics.
- **RBAC**: Workspace membership is required. Environment entitlement and review/case view permission are required per item. Execution capability affects viewer-relative status and action label but is not persisted. Operation links require OperationRun view authorization plus case/step/scope/currentness/context validation.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: Governance Inbox keeps its explicit `environment_id` filter semantics. If an environment filter is active, only cases for that environment may appear.
- **Explicit entitlement checks preventing cross-tenant leakage**: Every case query is constrained by workspace and authorized environment IDs before mapping. Non-entitled workspace/environment/review/case rows are hidden with deny-as-not-found behavior where existing policies require it.
## 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
- [ ] 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"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
- **Route/page/surface**: Existing Governance Inbox page `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and view `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`.
- **Current or new page archetype**: Existing operator workbench / Primary Decision Surface from Specs 327 and 346.
- **Design depth**: Strategic Surface, pattern-reusing extension only.
- **Repo-truth level**: repo-verified through existing page, `GovernanceInboxSectionBuilder`, Specs 327/346, and browser smoke artifacts.
- **Existing pattern reused**: Existing section/family entries, operator lanes, source-detail disclosure, environment filter chip, native Filament badges/buttons, `OperationRunLinks`, `EnvironmentReviewResource::environmentScopedUrl()`, policies, and the resolution page.
- **New pattern required**: none. A concrete provider/section for one source family may be needed, but no generic UI framework.
- **Screenshot required**: yes if browser harness is available, stored under `specs/389-governance-inbox-resolution-intake-v1/artifacts/screenshots/`; otherwise document fixture limitations.
- **Page audit required**: no new page report by default; update existing Governance Inbox coverage artifacts if implementation materially changes the page beyond a pattern-reusing source family.
- **Customer-safe review required**: yes as negative coverage. Customer-facing workspace must not show internal resolution intake items.
- **Dangerous-action review required**: yes as a negative review. The inbox must not add publish, cancel, or step execution actions.
- **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`
- [x] No coverage artifact update required during preparation: Spec 389 reuses the existing Governance Inbox page archetype, route, and source-family pattern. Implementation must update the registry artifacts if runtime work materially changes page archetype, route inventory, strategic-surface classification, or design coverage beyond a pattern-reusing source-family extension.
- [ ] `N/A - no reachable UI surface impact`
- **No-impact rationale when applicable**: N/A. Existing reachable UI surface changes.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes.
- **Interaction class(es)**: Governance Inbox status messaging, operator action links, OperationRun links, review/evidence status summaries, source-family filters, environment scope signals.
- **Systems touched**: Existing Governance Inbox page/builder, Review Publication Resolution service/proof resolver, Environment Review resource page links, OperationRun links/policy, customer no-leakage boundary.
- **Existing pattern(s) to extend**: `GovernanceInboxSectionBuilder` family/entry arrays and `GovernanceInbox` lane normalization; `OperationRunLinks` only after validation; `ReviewPublicationResolutionCasePolicy`; `OperationRunPolicy`; Spec 388 proof/currentness safe summaries.
- **Shared contract / presenter / builder / renderer to reuse**: Existing Governance Inbox section/entry shape. If implementation adds a new class, it must be a concrete `ReviewPublicationResolutionInboxProvider` or equivalent, not a registry or generic adapter.
- **Why the existing shared path is sufficient or insufficient**: The existing builder already composes source families into a single decision-first inbox. It is sufficient for v1 if extended with a concrete family/provider; it lacks only the Review Publication Resolution case source.
- **Allowed deviation and why**: A small concrete provider is allowed to keep Review Publication Resolution mapping isolated and testable without bloating the main builder.
- **Consistency impact**: Titles, reasons, status labels, primary/secondary actions, environment labels, and source-detail disclosure must match existing inbox structure.
- **Review focus**: no generic task model, no second proof evaluator, no raw IDs/payloads, no inline mutation, no customer leakage.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes, link display only. It must not create, queue, resume, reconcile, or complete OperationRuns.
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` for URL labels/URLs after scope/context/currentness/RBAC validation; `OperationRunPolicy` for permission; existing resolution proof/currentness semantics for whether a linked run is current.
- **Delegated start/completion UX behaviors**: N/A for starts/completion. No queued toast, browser event, terminal notification, or dedupe behavior is introduced.
- **Local surface-owned behavior that remains**: Read-only decision copy and whether to hide/show a safe operation link.
- **Queued DB-notification policy**: N/A. No notifications.
- **Terminal notification path**: N/A.
- **Exception required?**: none.
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: no new provider/platform boundary.
- **Boundary classification**: existing Review Publication Resolution is review-publication-owned and environment-scoped.
- **Seams affected**: only display of existing resolution, proof, and OperationRun references in Governance Inbox.
- **Neutral platform terms preserved or introduced**: `workspace`, `environment`, `review`, `operation`, `proof`, `preparation`, `governance inbox`.
- **Provider-specific semantics retained and why**: existing report/evidence/review-pack step meanings remain inside Review Publication Resolution semantics; no Microsoft Graph or provider payload semantics are surfaced by default.
- **Why this does not deepen provider coupling accidentally**: the inbox consumes safe summaries and existing review-publication-specific state; it does not add Graph endpoints, provider registries, or provider-shaped platform-core contracts.
- **Follow-up path**: future adapters for restore/provider/baseline/report delivery require separate specs after those domains produce trustworthy readiness state.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Governance Inbox source family and lane entries for review publication resolution | yes | Existing custom Blade page using Filament primitives | Governance Inbox section family, status messaging, action links | page, source family, lane classification, filters | no | Pattern-reusing extension to existing strategic surface |
| OperationRun link disclosure from inbox item | yes | Existing link helper and policy | OperationRun link UX | linked-record/secondary-action display | no | Link only; no run start/completion UX |
| Customer Review Workspace boundary | no material customer feature change | N/A | customer-safe negative boundary | negative visibility tests | no | Customer workspace must not show internal intake |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Governance Inbox review publication resolution items | Primary Decision Surface | Operator triages blocked, failed, waiting, stale, or ready review publication preparation | Status, environment, review, reason, next safe action, owner if known, last update | Safe proof summary and operation/review/source links only when validated | Primary because the existing inbox is the operator work queue | Follows active governance work, not storage objects | Removes search across individual reviews and operation pages |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Governance Inbox review publication resolution item | operator-MSP, read-only operator, support via existing admin policies | What needs attention, why, environment/review, safe status, continue/inspect action | Collapsed source detail: current step human label, safe proof summary, linked operation if validated | Not shown in inbox; remains on existing authorized source/detail pages | Continue preparation or Inspect preparation; Open operation only when safest and validated | raw payloads, proof reason codes, readiness fingerprints, operation IDs unless link-valid, exception messages | The inbox summarizes the blocker once; resolution page/source pages own detailed proof |
| Customer Review Workspace | customer-read-only | No internal resolution item | none | none | Existing customer-safe review actions only | all internal resolution/proof/OperationRun data | negative boundary only |
## UI/UX Surface Classification *(mandatory when operator-facing list, detail, queue, audit, config, or report surface changes)*
| 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 review publication resolution items | Queue / Workbench / Read-only | Decision-first intake queue | Continue preparation | Primary button to existing Resolution Page | Existing page pattern; no new row contract required | Existing source-detail/secondary actions | none in inbox | existing Governance Inbox route | existing Resolution Page / Review / Operation pages | Workspace badge and Environment badge/filter | Review publication work | status, reason, review/environment, next safe action, current proof availability only as safe label | none |
## UI Action Matrix *(mandatory when operator-facing surfaces are changed)*
| Surface / Slot | Allowed Action(s) | Placement | Behavior | Authorization / Confirmation / Audit |
|---|---|---|---|---|
| Governance Inbox header actions | No new header action for Spec 389 | Existing page header only | No mutation and no new workflow entry point | Existing page authorization only; no confirmation or audit event because no new action |
| Review publication item primary action | `Continue preparation` or `Inspect preparation`; `Open operation` only when item status is validated `waiting` and opening the operation is the safest next action | Existing item primary button | Navigate only to the existing Resolution Page or validated OperationRun detail | Case/review visibility required; operation link also requires workspace/environment/review/case/step/type/currentness/RBAC validation; no confirmation or audit event because navigation only |
| Review publication item secondary actions | `Open review`; optional `Open operation` | Existing source-detail/secondary-action area | Navigate only to authorized existing pages | Hide when RBAC/scope/currentness/context validation fails; no confirmation or audit event because navigation only |
| Row click / identifier click | Existing Governance Inbox row/source-link pattern only | Existing source entry link behavior | Inspect/open source context, no inline execution | Same authorization as destination page; no confirmation or audit event because navigation only |
| Bulk actions | None | N/A | Not supported | N/A |
| Empty-state CTA | No new mutating CTA; optional existing navigation/filter-reset affordance only | Existing empty-state area | Calm empty state or non-mutating navigation | No confirmation or audit event |
| Destructive or mutating actions | None | Forbidden in Governance Inbox | No publish, cancel, step execution, provider check, Entra scan, report update, evidence collection, review refresh, or export preparation | Mutations remain source-owned on existing pages with their own authorization, confirmation, audit, and tests |
## Operator Surface Contract *(mandatory when operator-facing page changes)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Governance Inbox | MSP operator / workspace operator / read-only inspector | Decide which review publication preparation needs attention and where to continue safely | Read-only decision queue | Which review publication work needs attention now? | item type, status, severity/lane, environment, review label, reason, next safe action, last update, owner | step key, readiness fingerprint, proof reason codes, raw run/artifact metadata, raw payloads | inbox actionability, proof/currentness safety, operation running/failure only when validated | none; navigation only | Continue preparation / Inspect preparation; Open operation only if validated; Open review if allowed | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no.
- **New persisted entity/table/artifact?**: no. Spec artifacts only; runtime should prefer no migration.
- **New abstraction?**: yes, possibly one concrete provider/query class for Review Publication Resolution intake only.
- **New enum/state/reason family?**: no persisted family. Inbox statuses are derived viewer-relative labels for this surface only.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: active review publication resolution work is hidden unless an operator already opens the specific review.
- **Existing structure is insufficient because**: the existing Governance Inbox has no source family for Review Publication Resolution Cases, while the Resolution Page is intentionally a detail/continuation context.
- **Narrowest correct implementation**: one concrete read-only source family/provider that maps existing cases/steps/safe summaries into existing inbox entries.
- **Ownership cost**: focused mapping logic and tests for status, scope, RBAC, operation-link disclosure, and stale-safe fallback.
- **Alternative intentionally rejected**: generic task/workflow item model, adapter registry, generic resolution-type registry, new resource, or top-level page.
- **Release truth**: current-release truth for a single existing workflow.
### Compatibility posture
This feature assumes a pre-production environment. Backward compatibility shims are out of scope. Existing completed/cancelled/superseded cases are hidden by default rather than migrated.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature for provider/query mapping, RBAC/scope, and no-mutation assertions; Filament/Livewire Feature for Governance Inbox rendering; Browser smoke for visual/user workflow checks if harness is available.
- **Validation lane(s)**: fast-feedback + confidence; browser optional but expected if the page rendering changes; no PostgreSQL lane unless implementation adds indexes/migrations after updating this spec.
- **Why this classification and these lanes are sufficient**: The change is read-only UI/query mapping over existing records. Focused Feature/Filament tests prove state mapping and safety; browser smoke proves the operator surface is clear.
- **New or expanded test families**: one focused Spec 389 Governance Inbox feature family; optional browser smoke file.
- **Fixture / helper cost impact**: ReviewPublicationResolutionCase, Step, EnvironmentReview, OperationRun, and user capability fixtures. Helpers must stay explicit and local to Spec 389 or reuse existing Spec 386-388 factories without widening defaults.
- **Heavy-family visibility / justification**: Browser coverage is explicit because the Governance Inbox is a strategic decision surface and mobile clarity/customer non-leakage matter.
- **Special surface test profile**: governance workbench / standard-native-filament with read-only operation-link disclosure.
- **Standard-native relief or required special coverage**: ordinary Feature/Filament tests for most mapping; browser for representative visible states if fixtures are available.
- **Reviewer handoff**: verify no generic engine, no inline mutation, no stale operation disclosure, correct 404/403 semantics, no customer leakage, no raw proof/payload metadata, and no full-suite claim unless actually run.
- **Budget / baseline / trend impact**: low expected; document-in-feature if browser fixture scope or governance-lane runtime grows materially.
- **Escalation needed**: document-in-feature for contained fixture cost; follow-up-spec only if a generic inbox-source system becomes unavoidable.
- **Active feature PR close-out entry**: Guardrail + Smoke Coverage if browser is run.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/GovernanceInbox/Spec389GovernanceInboxResolutionIntakeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec389GovernanceInboxResolutionIntakeTest.php` if browser test exists
- `cd apps/platform && ./vendor/bin/pint --dirty --format agent`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Triage Active Resolution Work (Priority: P1)
As an operator, I can open the existing Governance Inbox and see active Review Publication Resolution work that needs attention, waiting, re-check, or continuation.
**Why this priority**: This is the core value: hidden resolution cases become visible without creating a new workflow center.
**Independent Test**: Seed active Review Publication Resolution Cases for an accessible workspace/environment and verify the Governance Inbox shows operator-friendly items, hides completed/cancelled/superseded cases by default, and sorts failed/blocked ahead of waiting/ready items.
**Acceptance Scenarios**:
1. **Given** an active resolution case with a missing required report step, **When** an authorized operator opens Governance Inbox, **Then** the item appears with a review-publication label, affected environment/review, "Required reports are missing", and "Continue preparation".
2. **Given** completed, cancelled, and superseded resolution cases, **When** the default inbox is rendered, **Then** those cases do not appear in active work.
3. **Given** failed, blocked, needs-attention, needs-recheck, ready, and waiting cases, **When** the inbox renders active work, **Then** failed and blocked items are prioritized before ordinary waiting items.
---
### User Story 2 - Continue Safely Without Inline Execution (Priority: P1)
As an operator, I can continue or inspect preparation from the inbox, but the inbox never executes a resolution step, starts a provider check, refreshes a review, prepares an export, cancels a resolution, or publishes a review.
**Why this priority**: The inbox must not bypass the decision context and confirmation model hardened by Specs 386 and 387.
**Independent Test**: Render the Governance Inbox for executable and read-only users and verify links navigate only to existing authorized pages while no mutating buttons or publish/cancel actions are present.
**Acceptance Scenarios**:
1. **Given** an operator can execute the current step, **When** they see the inbox item, **Then** the primary action is "Continue preparation" and navigates to the Resolution Page.
2. **Given** a read-only operator can inspect the case but cannot execute the step, **When** they see the inbox item, **Then** the action is "Inspect preparation" or a safe continue link and no executable step action is shown in the inbox.
3. **Given** any resolution item, **When** the inbox renders actions, **Then** there is no "Publish review", "Update required reports", "Collect evidence", "Refresh review", "Prepare export", or "Cancel resolution" action.
---
### User Story 3 - Enforce Scope, RBAC, and Customer Safety (Priority: P1)
As a workspace operator, I only see resolution items, review links, and operation links for environments and cases I am entitled to inspect; customer-facing users see none of this internal intake.
**Why this priority**: The inbox aggregates work across environments, so leakage risk is higher than on a single review detail page.
**Independent Test**: Seed foreign workspace/environment/review/case/operation combinations and customer-facing users, then verify hidden items and hidden operation links without counts or IDs leaking.
**Acceptance Scenarios**:
1. **Given** a resolution case belongs to another workspace or environment, **When** the user opens Governance Inbox, **Then** no item, count, operation ID, or link is disclosed.
2. **Given** a linked OperationRun exists but belongs to another case, review, environment, workspace, unexpected type, stale step, or inaccessible viewer, **When** the inbox renders, **Then** no operation URL or ID is shown.
3. **Given** a customer-facing workspace user opens customer surfaces, **When** internal resolution cases exist, **Then** no internal resolution item, proof state, operation link, or preparation metadata appears.
---
### User Story 4 - Fall Back Conservatively For Unsafe Currentness (Priority: P2)
As an operator, I would rather see "Needs re-check" than a stale failed/waiting/ready state presented as current truth.
**Why this priority**: Spec 388 exists because false readiness is dangerous. Inbox list rendering must not become a second proof engine.
**Independent Test**: Create stale failed/running/cross-scope/unknown proof scenarios and verify the inbox maps them to "Needs re-check" with a continuation link rather than optimistic failed/waiting/ready states.
**Acceptance Scenarios**:
1. **Given** a failed step references an operation that may be stale, **When** the inbox cannot validate currentness cheaply and safely, **Then** the item status is "Needs re-check".
2. **Given** a running operation link is not current/scope-valid/context-valid for the displayed case/step, **When** the inbox renders, **Then** it does not show "Open operation" and falls back to "Needs re-check" or non-linked safe text.
3. **Given** proof is hidden, operator-limited, inspection-only, failed, stale, or unknown, **When** the item is mapped, **Then** it does not produce "Ready to continue".
## Functional Requirements *(mandatory)*
- **FR-389-001**: The existing Governance Inbox MUST include active Review Publication Resolution Cases as work items using the item type `review_publication_resolution`.
- **FR-389-002**: The implementation MUST NOT add a new top-level navigation item, global search resource, generic CRUD resource, workflow engine, generic task model, adapter registry, or generic resolution-type registry.
- **FR-389-003**: The Governance Inbox MUST be read-only for this intake. It MUST NOT execute resolution steps, start provider checks, start Entra scans, collect evidence, refresh reviews, prepare exports, cancel resolution cases, mutate OperationRuns, or publish reviews.
- **FR-389-004**: The default active view MUST show `needs_attention`, `needs_recheck`, `waiting`, `ready_to_continue`, `failed`, and `blocked` items when visible to the viewer.
- **FR-389-005**: The default active view MUST hide `completed`, `cancelled`, and `superseded` cases unless an explicit completed/history filter is implemented.
- **FR-389-006**: Item status MUST be viewer-relative and MUST NOT be persisted back to `ReviewPublicationResolutionCase` or `ReviewPublicationResolutionStep`.
- **FR-389-007**: The inbox MUST use existing Spec 388 proof/currentness semantics, safe summaries, or persisted safe case/step state only when not contradicted by currentness rules.
- **FR-389-008**: The inbox MUST NOT infer readiness directly from raw OperationRun, StoredReport, EvidenceSnapshot, ReviewOutput, ReviewPack, persisted proof summary, operation IDs, readiness fingerprints, or internal step keys.
- **FR-389-009**: If the provider cannot cheaply and safely validate state currentness for list rendering, the item MUST show "Needs re-check" and link to the Resolution Page.
- **FR-389-010**: A failed step MUST map to `failed` only when the failure is current, scope-valid, context-valid, and safe for the displayed case/step/viewer; otherwise it MUST map to `needs_recheck`.
- **FR-389-011**: A running operation MUST map to `waiting` only when the OperationRun is current, scope-valid, context-valid, expected type, belongs to the current case/step, and is visible to the viewer; otherwise it MUST map to `needs_recheck` or non-linked safe waiting copy.
- **FR-389-012**: Ready-to-continue MUST NOT be shown when proof is unknown, hidden, operator-limited, stale, failed, inspection-only, or otherwise not usable to complete the relevant step.
- **FR-389-013**: Every query MUST be constrained by workspace, authorized environment IDs where applicable, and current user access before mapping.
- **FR-389-014**: A user may see an item only when they can access the workspace, the environment, the Environment Review, and the safe resolution summary.
- **FR-389-015**: Customer-facing users and customer workspace surfaces MUST NOT see internal resolution intake items, proof state, OperationRun details, internal reason codes, or internal preparation metadata.
- **FR-389-016**: Every actionable item MUST expose one primary next action. The default primary action is "Continue preparation"; read-only users may see "Inspect preparation".
- **FR-389-017**: `Continue preparation` and `Inspect preparation` MUST navigate to the existing Review Publication Resolution Page and MUST NOT execute a step from the inbox.
- **FR-389-018**: `Open review` may appear only when the viewer can access the review under existing RBAC/scope rules.
- **FR-389-019**: `Open operation` may appear only when operation disclosure passes workspace, environment, review, case, current step, expected operation/action type, currentness, and RBAC checks. It may be the primary action only for a validated `waiting` item where opening the operation is the safest next action; otherwise it must be secondary or hidden.
- **FR-389-020**: The implementation MUST NOT build operation URLs directly from persisted step metadata without revalidating the OperationRun relationship.
- **FR-389-021**: If operation disclosure fails, the inbox MUST hide operation IDs and URLs and show safe non-linked text such as "Operation is running" or "Preparation status needs to be refreshed".
- **FR-389-022**: Item copy MUST be operator-facing: "Review can't be published yet", "Required reports are missing", "Continue preparation", "Waiting for operation", and "Needs re-check" are valid; raw step keys, proof reason codes, `readiness_fingerprint`, and model names are not valid default copy.
- **FR-389-023**: Safe metadata MUST be display-oriented and MUST NOT include raw provider payloads, Graph responses, raw evidence/report contents, exception messages, secrets, tokens, readiness fingerprints, proof reason codes by default, internal step keys by default, or unvalidated operation IDs.
- **FR-389-024**: Default sorting MUST prioritize failed, blocked, needs-attention, needs-recheck, ready-to-continue, waiting, then newest updated item within each group.
- **FR-389-025**: The inbox MUST provide bounded filters for status, environment, type/source family, and updated date using the existing Governance Inbox pattern where possible, without introducing a generic resolution-type registry. Updated-date v1 options MUST be bounded presets: `Any time`, `Last 24 hours`, `Last 7 days`, and `Last 30 days`.
- **FR-389-026**: Empty states MUST cover no active resolution work, no accessible review publication work, and filter no results without revealing inaccessible counts.
- **FR-389-027**: Viewing inbox items MUST NOT emit audit events by default.
- **FR-389-028**: No schema migration should be added unless implementation proves an existing index is insufficient and this spec is updated with the justification.
## Key Entities *(include if feature involves data)*
- **ReviewPublicationResolutionCase**: Existing review-publication-specific workflow state from Spec 386. It remains source state for active/completed/cancelled/superseded lifecycle and current step reference.
- **ReviewPublicationResolutionStep**: Existing ordered step state with safe summaries, proof metadata, and optional OperationRun/artifact references.
- **EnvironmentReview**: Existing subject review affected by publication preparation.
- **OperationRun**: Existing execution truth. It may be linked only after scope/context/currentness/RBAC validation.
- **Governance Inbox Item**: Derived display item only. It is not persisted and must not become canonical readiness truth.
## Status and Severity Model
Allowed v1 inbox statuses:
- `needs_attention`
- `needs_recheck`
- `waiting`
- `ready_to_continue`
- `failed`
- `blocked`
- `completed`
- `cancelled`
- `superseded`
Recommended severity mapping:
- `failed` -> `high`
- `blocked` -> `high`
- `needs_attention` -> `medium`
- `needs_recheck` -> `medium`
- `ready_to_continue` -> `medium`
- `waiting` -> `info`
- `completed` -> `info`
- `cancelled` -> `info`
- `superseded` -> `info`
Critical severity is reserved for platform-wide or security-impacting issues and is not a normal review-preparation status.
## Out of Scope
- Generic workflow engine, generic adapter registry, generic provider system, generic task queue, or PSA/ticketing replacement.
- New top-level navigation, new global search resource, new Review Publication Resolution data model, new proof/currentness model, or customer-facing resolution inbox.
- Restore resolution intake, provider onboarding resolution intake, evidence/baseline readiness intake, report delivery readiness intake, cross-tenant promotion intake, notifications, email/Teams routing, assignment workflow, SLA/escalation engine, auto-publish, or inline mutating actions.
- Raw provider/evidence/report payload display, raw Graph payload display, raw exception messages, token/secret display, readiness-fingerprint display, and default proof reason-code display.
## Edge Cases
- Case has no current step: show `Needs re-check` and link to Resolution Page if visible.
- Step references an OperationRun that no longer exists: hide operation link and show `Needs re-check`.
- Step references an OperationRun from another workspace/environment/review/case: hide ID/link and show `Needs re-check`.
- Case status is completed but proof became stale after the last evaluation: hide from default if completed, or show `Needs re-check` if still active and unsafe.
- User can inspect but not execute: item remains visible only if the case/review is visible, but action label becomes inspection-safe.
- Selected environment filter excludes visible work elsewhere: reuse existing calm filtered empty-state behavior.
- User can access Governance Inbox but no Review Publication Resolution cases: show no active resolution work without implying inaccessible counts.
## Success Criteria *(mandatory)*
- **SC-389-001**: Operators can identify active review publication preparation work from the Governance Inbox without first opening the affected Review detail page.
- **SC-389-002**: In focused tests, completed, cancelled, and superseded cases are absent from the default active inbox.
- **SC-389-003**: In focused tests, no inline publish, cancel, provider-check, Entra scan, evidence collection, review refresh, report update, or export preparation action is rendered in the inbox.
- **SC-389-004**: In focused RBAC/scope tests, foreign workspace/environment/review/case records and invalid OperationRun links are hidden without ID or count leakage.
- **SC-389-005**: In currentness tests, stale/unknown/unsafe proof maps to `Needs re-check` rather than false `Failed`, `Waiting`, or `Ready to continue`.
- **SC-389-006**: In safe-metadata tests, rendered/default item metadata contains no readiness fingerprint, proof reason code by default, raw payload, raw exception, secret, token, or unvalidated operation ID.
- **SC-389-007**: Browser smoke, if available, shows desktop and mobile inbox items with decision-first copy and no customer-facing leakage.
## Acceptance Criteria
- **AC-389-01**: Active Review Publication Resolution Cases appear in the existing Governance Inbox.
- **AC-389-02**: Completed, cancelled, and superseded cases are hidden by default.
- **AC-389-03**: Inbox items use decision-first labels and not internal case terminology, step keys, proof reason codes, or currentness internals.
- **AC-389-04**: Each item has one primary next action.
- **AC-389-05**: Governance Inbox does not directly execute resolution steps.
- **AC-389-06**: Governance Inbox does not show or trigger Publish Review.
- **AC-389-07**: Foreign workspace/environment cases are hidden.
- **AC-389-08**: Users only see cases, operation links, and review links they are allowed to inspect.
- **AC-389-09**: Customer-facing workspace does not show internal Resolution Inbox items.
- **AC-389-10**: No workflow engine, adapter registry, generic tasks system, global search resource, or top-level navigation is introduced.
- **AC-389-11**: Governance Inbox does not independently infer proof/currentness from raw metadata and falls back to `Needs re-check` when unsafe.
- **AC-389-12**: OperationRun links are shown only when current, scope-valid, context-valid, expected type, and RBAC-authorized.
- **AC-389-13**: Inbox safe metadata contains no raw provider/evidence/report payloads, tokens, secrets, raw exception messages, readiness fingerprints, proof reason codes by default, or unvalidated operation IDs.
## Assumptions
- Spec 388 behavior is stable enough for read-only consumption before Spec 389 implementation starts.
- Existing Governance Inbox source-family patterns can support one concrete additional family without creating a generic registry.
- Existing Review Publication Resolution Page remains the only execution context for preparation steps.
- Existing `ReviewPublicationResolutionCasePolicy`, `OperationRunPolicy`, `OperationRunLinks`, and Environment Review resource URLs remain the baseline for navigation and authorization checks.
- No database migration is needed for v1 because Spec 386 already added status/assigned/index coverage.
## Open Questions
- No blocker open question. Implementation should verify whether the existing `GovernanceInboxSectionBuilder` should be extended directly or delegated to a concrete `ReviewPublicationResolutionInboxProvider`; either is acceptable if it remains concrete and non-generic.
## Follow-up Spec Candidates
- Restore Readiness Resolution Intake v1.
- Provider Onboarding & Permissions Resolution Intake v1.
- Evidence/Baseline Readiness Resolution Intake v1.
- Report Delivery Readiness Resolution v1.
- Cross-Tenant Promotion Resolution Intake v1.
- Notification or assignment workflows for governance work, only after source-specific intake proves trustworthy.
## Done Definition
Spec 389 is done when active Review Publication Resolution Cases appear in the Governance Inbox with correct scope/RBAC/currentness behavior; default active view hides completed/cancelled/superseded cases; failed/blocked cases sort before waiting/ready; `Needs re-check` is used for stale/unknown/unsafe states; labels are decision-first; actions only navigate; operation links are revalidated; no customer leakage, inline mutation, publish action, generic engine, global search resource, or top-level navigation exists; focused tests pass; browser smoke and screenshots are captured if available; and the git diff contains only Spec 389-related files.

View File

@ -0,0 +1,119 @@
# Tasks: Governance Inbox Resolution Intake v1
**Input**: Design documents from `specs/389-governance-inbox-resolution-intake-v1/`
**Prerequisites**: `spec.md`, `plan.md`, `contracts/`, `artifacts/current-governance-inbox-inventory.md`
## Execution Notes
- Work on this feature must start from the current feature branch and follow the repo's session-branch workflow.
- Do not implement a generic workflow engine, task model, adapter registry, or global-search Resource.
- Do not add a migration unless implementation proves the existing Spec 386 indexes are insufficient and `spec.md`/`plan.md` are updated first.
- Keep Governance Inbox read-only. All mutating actions remain on existing source-owned pages.
- Use Laravel Sail for local validation unless explicitly blocked.
- Implementation branch: `389-governance-inbox-resolution-intake-v1`; baseline commit observed before implementation: `83c679cf feat: add review publication proof currentness contract (#459)`.
- Initial dirty state was limited to the untracked Spec 389 artifact directory. No migration, panel provider registration, global-search Resource, top-level navigation, Filament asset registration, or customer workspace runtime change was added.
- Implemented tests live under `apps/platform/tests/Feature/Governance/Spec389GovernanceInboxResolutionIntakeTest.php` and `apps/platform/tests/Browser/Spec389GovernanceInboxResolutionIntakeSmokeTest.php`.
## Phase 1: Safety and Inventory
- [x] T001 Run repo safety commands from the repo root and record branch, dirty files, baseline commit, and Spec 386/387/388 baseline status in the implementation notes.
- [x] T002 Re-read `specs/389-governance-inbox-resolution-intake-v1/spec.md`, `plan.md`, `contracts/review-publication-resolution-inbox-item.md`, `contracts/status-mapping.md`, and `artifacts/current-governance-inbox-inventory.md`.
- [x] T003 Re-check existing Governance Inbox implementation in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`.
- [x] T004 Re-check Review Publication Resolution foundations in `apps/platform/app/Models/ReviewPublicationResolutionCase.php`, `apps/platform/app/Models/ReviewPublicationResolutionStep.php`, and `apps/platform/app/Support/ReviewPublicationResolution/`.
- [x] T005 Re-check authorization/link foundations in `apps/platform/app/Policies/ReviewPublicationResolutionCasePolicy.php`, `apps/platform/app/Policies/OperationRunPolicy.php`, and `apps/platform/app/Support/OperationRunLinks.php`.
- [x] T006 Confirm no implementation task requires panel provider registration, new top-level navigation, new global-search Resource, new Filament assets, or customer workspace changes.
## Phase 2: Tests First
- [x] T007 [P] Create `apps/platform/tests/Feature/Governance/Spec389GovernanceInboxResolutionIntakeTest.php` with factories/helpers for visible workspace, environment, review, resolution case, current step, and viewer.
- [x] T008 [P] Add a test proving an active Review Publication Resolution Case appears in the Governance Inbox with operator-facing title, reason, environment, review context, and `Continue preparation`.
- [x] T009 [P] Add tests proving `completed`, `cancelled`, and `superseded` cases are hidden by default.
- [x] T010 [P] Add tests proving failed/blocked cases sort above needs-attention, needs-recheck, ready, and waiting cases.
- [x] T011 [P] Add tests proving `needs_recheck` appears for stale, unknown, hidden, unsafe, or too-expensive-to-classify state instead of false waiting/failed/ready precision.
- [x] T012 [P] Add RBAC/scope tests proving foreign workspace, foreign environment, inaccessible review, and customer-facing user cases are hidden with no leaked counts.
- [x] T013 [P] Add operation-link tests proving current/scope-valid/context-valid/RBAC-authorized operations can be linked and stale/cross-scope/cross-case/cross-review operations are not linked or disclosed.
- [x] T014 [P] Add action-safety and audit-neutrality tests proving the Inbox renders no publish, cancel, update reports, collect evidence, refresh review, prepare export, provider check, or Entra scan action and that list rendering emits no new audit event by default.
## Phase 3: Provider and Mapping Core
- [x] T015 Implement a concrete `ReviewPublicationResolutionInboxProvider` in `apps/platform/app/Support/GovernanceInbox/` or a tightly scoped builder method if that better fits the existing class.
- [x] T016 Query `ReviewPublicationResolutionCase` through workspace-first and environment-filtered constraints, using active case statuses by default.
- [x] T017 Eager-load only display-safe relationships needed for list rendering: tenant, environment review, assignee/creator if used, current steps, and candidate operation relation when necessary.
- [x] T018 Enforce `ReviewPublicationResolutionCasePolicy::view` or equivalent batch-safe visibility before mapping each item.
- [x] T019 Map each visible case to existing Governance Inbox source-entry fields using `family_key=review_publication_resolution`.
- [x] T020 Ensure safe metadata excludes raw provider, Graph, evidence/report/review payloads, exception messages, secrets, tokens, readiness fingerprints, proof reason codes by default, internal step keys by default, and unvalidated operation IDs.
- [x] T021 Ensure provider returns no customer-facing items and is never called from customer workspace surfaces.
## Phase 4: Status, Currentness, and Operation Link Hardening
- [x] T022 Implement the status mapping in `contracts/status-mapping.md` with allowed statuses only.
- [x] T023 Use Spec 388 proof/currentness summaries or resolver output where available; do not infer readiness from raw persisted metadata.
- [x] T024 Fall back to `needs_recheck` when currentness cannot be safely and cheaply classified.
- [x] T025 Compute viewer-relative `ready_to_continue` versus `blocked` using existing capability/policy/step-authorizer behavior without persisting inbox status.
- [x] T026 Hide completed/cancelled/superseded cases from the default active list.
- [x] T027 Revalidate OperationRun links with workspace, environment, review, case, current step, expected type, Spec 388 currentness/visibility/usability, and `OperationRunPolicy::view`.
- [x] T028 Ensure failed and waiting statuses are shown only when current/scope-valid/context-valid; otherwise show `needs_recheck`.
- [x] T029 Ensure OperationRun ID, label, URL, and `Open operation` are absent whenever validation fails.
## Phase 5: Governance Inbox UI Integration
- [x] T030 Add `review_publication_resolution` to the existing Governance Inbox source-family ordering and available family filter only when visible.
- [x] T031 Add bounded status and updated-date filtering for Review Publication Resolution items, reusing existing page query-string/filter conventions and without a generic resolution-type registry. Updated-date presets are `Any time`, `Last 24 hours`, `Last 7 days`, and `Last 30 days`.
- [x] T032 Add lane classification for the new family while preserving existing lane semantics and sorting.
- [x] T033 Render decision-first copy: title, status badge, environment, review reference, reason summary, next safe action, owner if available, and last update.
- [x] T034 Add the primary action label rules: `Continue preparation`, `Inspect preparation`, and narrowly validated `Open operation`.
- [x] T035 Add secondary `Open review` and optional `Open operation` links only when RBAC/scope/currentness validation permits them.
- [x] T036 Keep technical details collapsed or absent by default; do not render internal step keys, proof reason codes, readiness fingerprints, raw operation metadata, or raw payloads.
- [x] T037 Add or verify empty states for no active review publication work, no accessible review publication work, and no filter results.
## Phase 6: Browser and UI Smoke
- [x] T038 [P] Add `apps/platform/tests/Browser/Spec389GovernanceInboxResolutionIntakeSmokeTest.php` if the browser harness is available for this feature.
- [x] T039 [P] Cover a visible review publication item with friendly title, reason, status, environment, and primary action.
- [x] T040 [P] Cover `Continue preparation` opening the existing Resolution Page.
- [x] T041 [P] Cover absence of publish and inline mutation buttons in the Inbox item.
- [x] T042 [P] Cover mobile viewport readability for the item.
- [x] T043 [P] Cover customer workspace or customer-facing route showing no internal resolution item.
- [x] T044 Capture screenshots under `specs/389-governance-inbox-resolution-intake-v1/artifacts/screenshots/` when browser smoke is run.
## Phase 7: Validation
- [x] T045 Run focused Governance Inbox feature tests: `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/Spec346GovernanceInboxOperatorWorkflowTest.php tests/Feature/Governance/Spec389GovernanceInboxResolutionIntakeTest.php`.
- [x] T046 Run focused regression tests: `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`.
- [x] T047 Run browser test if created: `cd apps/platform && ./vendor/bin/sail artisan test tests/Browser/Spec389GovernanceInboxResolutionIntakeSmokeTest.php`.
- [x] T048 Run formatting: `cd apps/platform && ./vendor/bin/sail pint app/Support/GovernanceInbox/ReviewPublicationResolutionInboxProvider.php app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php app/Filament/Pages/Governance/GovernanceInbox.php tests/Feature/Governance/Spec389GovernanceInboxResolutionIntakeTest.php tests/Browser/Spec389GovernanceInboxResolutionIntakeSmokeTest.php`.
- [x] T049 Run `git diff --check` from the repo root.
- [x] T050 Review the final diff for forbidden patterns: generic engine/registry, top-level nav, global-search Resource, inline mutation, publish action, customer leakage, raw payload leakage, operation ID disclosure, and unplanned migrations/schema changes.
- [x] T051 Confirm no migration/schema file was added, or verify `spec.md` and `plan.md` were updated with explicit index justification before the migration/schema change.
- [x] T052 Document validation commands actually run and any skipped browser/full-suite coverage in the PR close-out.
## Dependencies
- T001-T006 must complete before code edits.
- T007-T014 should be written before implementation when practical.
- T015-T021 provide the mapping core required by UI tasks.
- T022-T029 must complete before operation links are rendered.
- T030-T037 depend on provider/mapping output.
- T038-T044 depend on UI integration.
- T045-T052 close the implementation.
## Parallel Work
- T007-T014 can be split across test scenarios after fixtures are agreed.
- T022-T029 can be reviewed independently from UI rendering after provider output exists.
- T038-T043 can be added in parallel with final UI copy tuning once stable routes exist.
## Definition of Done
- Active Review Publication Resolution Cases appear in the existing Governance Inbox.
- Completed/cancelled/superseded cases are hidden by default.
- Failed/blocked items sort ahead of waiting/ready items.
- Unknown, stale, hidden, unsafe, or expensive-to-classify state shows `Needs re-check`.
- Items use decision-first labels.
- Primary actions navigate only to existing authorized pages.
- Operation links are scope/currentness/context/RBAC revalidated.
- No inline mutation, cancel, or publish action exists.
- Customer-facing users and surfaces see no internal resolution items.
- Focused tests pass.
- Browser smoke and screenshots are produced if the harness is available.
- Final diff contains only Spec 389-related runtime and test changes.