feat: add review publication proof currentness contract (#459)

Automated PR created by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #459
This commit is contained in:
ahmido 2026-06-19 19:10:35 +00:00
parent aca0b10658
commit 83c679cf85
44 changed files with 5318 additions and 485 deletions

View File

@ -30,16 +30,15 @@
use BackedEnum; use BackedEnum;
use DomainException; use DomainException;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use InvalidArgumentException;
use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use InvalidArgumentException;
use UnitEnum; use UnitEnum;
class CrossEnvironmentComparePage extends Page implements HasForms class CrossEnvironmentComparePage extends Page implements HasForms
@ -366,7 +365,7 @@ public function executePromotion(): void
scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.', scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.',
); );
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { if (in_array($result->status, ['started', 'deduped'], true) || ($result->status === 'scope_busy' && $result->canDiscloseRun)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
} }
@ -774,9 +773,6 @@ private function executePromotionConfirmationDescription(): string
); );
} }
/**
* @param mixed $value
*/
private function normalizeEnvironmentIdentifier(mixed $value): ?string private function normalizeEnvironmentIdentifier(mixed $value): ?string
{ {
if (! is_string($value) && ! is_int($value)) { if (! is_string($value) && ! is_int($value)) {
@ -789,7 +785,6 @@ private function normalizeEnvironmentIdentifier(mixed $value): ?string
} }
/** /**
* @param mixed $value
* @return list<string> * @return list<string>
*/ */
private function normalizePolicyTypes(mixed $value): array private function normalizePolicyTypes(mixed $value): array

View File

@ -6,8 +6,8 @@
use App\Models\ManagedEnvironment; use App\Models\ManagedEnvironment;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder; use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -278,7 +278,9 @@ public function runProviderVerification(): void
], ],
); );
OpsUxBrowserEvents::dispatchRunEnqueued($this); if ($result->status !== 'scope_busy' || $result->canDiscloseRun) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
}
app(ProviderOperationStartResultPresenter::class) app(ProviderOperationStartResultPresenter::class)
->notification( ->notification(

View File

@ -3999,11 +3999,20 @@ public function startVerification(): void
throw new RuntimeException('Verification start did not return a run result.'); throw new RuntimeException('Verification start did not return a run result.');
} }
$draft->state = array_merge($draft->state ?? [], [ $state = array_merge($draft->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $result->run->getKey(),
'connection_recently_updated' => false, 'connection_recently_updated' => false,
]); ]);
$disclosableRunId = $result->disclosableRunId();
if ($disclosableRunId === null) {
unset($state['verification_operation_run_id']);
} else {
$state['verification_operation_run_id'] = $disclosableRunId;
}
$draft->state = $state;
$draft->current_step = 'verify'; $draft->current_step = 'verify';
}, },
)); ));
@ -4027,39 +4036,51 @@ public function startVerification(): void
'scope_busy' => 'blocked', 'scope_busy' => 'blocked',
default => 'success', default => 'success',
}; };
$disclosableRunId = $result->disclosableRunId();
$verificationStartMetadata = [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'result' => (string) $result->status,
];
if ($disclosableRunId !== null) {
$verificationStartMetadata['operation_run_id'] = $disclosableRunId;
}
app(WorkspaceAuditLogger::class)->log( app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace, workspace: $this->workspace,
action: AuditActionId::ManagedEnvironmentOnboardingVerificationStart->value, action: AuditActionId::ManagedEnvironmentOnboardingVerificationStart->value,
context: [ context: [
'metadata' => [ 'metadata' => $verificationStartMetadata,
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'operation_run_id' => (int) $result->run->getKey(),
'result' => (string) $result->status,
],
], ],
actor: $user, actor: $user,
status: $auditStatus, status: $auditStatus,
resourceType: 'operation_run', resourceType: $disclosableRunId !== null ? 'operation_run' : 'managed_environment_onboarding_session',
resourceId: (string) $result->run->getKey(), resourceId: $disclosableRunId !== null
? (string) $disclosableRunId
: (string) ($this->onboardingSession?->getKey() ?? $tenant->getKey()),
); );
if ($this->onboardingSession instanceof ManagedEnvironmentOnboardingSession) { if ($this->onboardingSession instanceof ManagedEnvironmentOnboardingSession) {
$verificationPersistedMetadata = [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
];
if ($disclosableRunId !== null) {
$verificationPersistedMetadata['operation_run_id'] = $disclosableRunId;
}
app(WorkspaceAuditLogger::class)->log( app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace, workspace: $this->workspace,
action: AuditActionId::ManagedEnvironmentOnboardingVerificationPersisted->value, action: AuditActionId::ManagedEnvironmentOnboardingVerificationPersisted->value,
context: [ context: [
'metadata' => [ 'metadata' => $verificationPersistedMetadata,
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_run_id' => (int) $result->run->getKey(),
],
], ],
actor: $user, actor: $user,
status: 'success', status: $disclosableRunId !== null ? 'success' : 'blocked',
resourceType: 'managed_environment_onboarding_session', resourceType: 'managed_environment_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(), resourceId: (string) $this->onboardingSession->getKey(),
); );
@ -4068,11 +4089,13 @@ public function startVerification(): void
$notification = app(ProviderOperationStartResultPresenter::class)->notification( $notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result, result: $result,
blockedTitle: 'Verification blocked', blockedTitle: 'Verification blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $result->run->getKey()), runUrl: $disclosableRunId !== null ? $this->tenantlessOperationRunUrl($disclosableRunId) : null,
); );
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); if ($result->canDiscloseRun) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
}
$notification->send(); $notification->send();
@ -4237,9 +4260,10 @@ public function startBootstrap(array $operationTypes): void
$state = $draft->state ?? []; $state = $draft->state ?? [];
$existing = $state['bootstrap_operation_runs'] ?? []; $existing = $state['bootstrap_operation_runs'] ?? [];
$existing = is_array($existing) ? $existing : []; $existing = is_array($existing) ? $existing : [];
$disclosableRunId = $startResult->disclosableRunId();
if ($startResult->status !== 'scope_busy') { if ($startResult->status !== 'scope_busy' && $disclosableRunId !== null) {
$existing[$nextOperationType] = (int) $startResult->run->getKey(); $existing[$nextOperationType] = $disclosableRunId;
} }
$state['bootstrap_operation_runs'] = $existing; $state['bootstrap_operation_runs'] = $existing;
@ -4258,7 +4282,7 @@ public function startBootstrap(array $operationTypes): void
'status' => $startResult->status, 'status' => $startResult->status,
'start_result' => $startResult, 'start_result' => $startResult,
'operation_type' => $nextOperationType, 'operation_type' => $nextOperationType,
'run' => $startResult->run, 'run' => $startResult->disclosableRun(),
'remaining_types' => $remainingTypes, 'remaining_types' => $remainingTypes,
]; ];
}, },
@ -4291,7 +4315,11 @@ public function startBootstrap(array $operationTypes): void
$startResult = $result['start_result'] ?? null; $startResult = $result['start_result'] ?? null;
$run = $result['run'] ?? null; $run = $result['run'] ?? null;
if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || ! $run instanceof OperationRun || $operationType === '') { if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || $operationType === '') {
throw new RuntimeException('Bootstrap start did not return a canonical run result.');
}
if ($startResult->canDiscloseRun && ! $run instanceof OperationRun) {
throw new RuntimeException('Bootstrap start did not return a canonical run result.'); throw new RuntimeException('Bootstrap start did not return a canonical run result.');
} }
@ -4306,20 +4334,25 @@ public function startBootstrap(array $operationTypes): void
'scope_busy' => 'blocked', 'scope_busy' => 'blocked',
default => 'success', default => 'success',
}; };
$disclosableRunId = $startResult->disclosableRunId();
$bootstrapMetadata = [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_types' => $types,
'started_operation_type' => $operationType,
'result' => (string) $result['status'],
];
if ($disclosableRunId !== null) {
$bootstrapMetadata['operation_run_id'] = $disclosableRunId;
}
app(WorkspaceAuditLogger::class)->log( app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace, workspace: $this->workspace,
action: AuditActionId::ManagedEnvironmentOnboardingBootstrapStarted->value, action: AuditActionId::ManagedEnvironmentOnboardingBootstrapStarted->value,
context: [ context: [
'metadata' => [ 'metadata' => $bootstrapMetadata,
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_types' => $types,
'started_operation_type' => $operationType,
'operation_run_id' => (int) $run->getKey(),
'result' => (string) $result['status'],
],
], ],
actor: $user, actor: $user,
status: $auditStatus, status: $auditStatus,
@ -4331,14 +4364,14 @@ public function startBootstrap(array $operationTypes): void
$notification = app(ProviderOperationStartResultPresenter::class)->notification( $notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $startResult, result: $startResult,
blockedTitle: 'Bootstrap action blocked', blockedTitle: 'Bootstrap action blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $run->getKey()), runUrl: $run instanceof OperationRun ? $this->tenantlessOperationRunUrl((int) $run->getKey()) : null,
scopeBusyTitle: 'Bootstrap action busy', scopeBusyTitle: 'Bootstrap action busy',
scopeBusyBody: $remainingTypes !== [] scopeBusyBody: $remainingTypes !== []
? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.' ? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.'
: 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.', : 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.',
); );
if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) { if (in_array($result['status'], ['started', 'deduped'], true) || ($result['status'] === 'scope_busy' && $startResult->canDiscloseRun)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
} }

View File

@ -70,7 +70,7 @@ protected function getHeaderActions(): array
runUrl: OperationRunLinks::view($result->run, $tenant), runUrl: OperationRunLinks::view($result->run, $tenant),
); );
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { if (in_array($result->status, ['started', 'deduped'], true) || ($result->status === 'scope_busy' && $result->canDiscloseRun)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
} }

View File

@ -7,17 +7,25 @@
use App\Filament\Resources\EnvironmentReviewResource; use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\EvidenceSnapshotResource; use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource; use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\StoredReportResource;
use App\Models\EnvironmentReview; use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment; use App\Models\ManagedEnvironment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\ReviewPublicationResolutionCase; use App\Models\ReviewPublicationResolutionCase;
use App\Models\ReviewPublicationResolutionStep; use App\Models\ReviewPublicationResolutionStep;
use App\Models\StoredReport;
use App\Models\User; use App\Models\User;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips; use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\ReviewPublicationResolution\ResolutionProofCurrentness;
use App\Support\ReviewPublicationResolution\ResolutionProofEvaluation;
use App\Support\ReviewPublicationResolution\ResolutionProofUsability;
use App\Support\ReviewPublicationResolution\ResolutionProofVisibility;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService; use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionActionService;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus; use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionCaseStatus;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionService; use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionService;
@ -477,14 +485,80 @@ private function stepState(ReviewPublicationResolutionStep $step, ?ManagedEnviro
'proof_type' => $step->proof_type, 'proof_type' => $step->proof_type,
'proof_id' => $step->proof_id, 'proof_id' => $step->proof_id,
'proof_status' => $step->proof_status, 'proof_status' => $step->proof_status,
'proof_label' => (string) data_get($step->summary, 'proof_label', $this->proofLabelFromState($step)),
'proof_state_description' => (string) data_get($step->summary, 'proof_state_description', ''),
'proof_currentness' => (string) data_get($step->metadata, 'proof_currentness', ''),
'proof_usability' => (string) data_get($step->metadata, 'proof_usability', ''),
'proof_visibility' => (string) data_get($step->metadata, 'proof_visibility', ''),
'proof_reason_code' => (string) data_get($step->metadata, 'proof_reason_code', ''),
'proof_summary' => is_array(data_get($step->metadata, 'proof_summary')) ? data_get($step->metadata, 'proof_summary') : [],
'proof_url' => $this->proofUrl($step, $tenant), 'proof_url' => $this->proofUrl($step, $tenant),
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, 'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
'operation_url' => $operationRun instanceof OperationRun && $tenant instanceof ManagedEnvironment 'operation_url' => $this->operationUrl($step, $operationRun, $tenant),
? OperationRunLinks::view($operationRun, $tenant)
: null,
]; ];
} }
private function operationUrl(
ReviewPublicationResolutionStep $step,
?OperationRun $operationRun,
?ManagedEnvironment $tenant,
): ?string {
if (! $this->canDiscloseOperationRun($step, $operationRun, $tenant)) {
return null;
}
return OperationRunLinks::view($operationRun, $tenant);
}
private function canDiscloseOperationRun(
ReviewPublicationResolutionStep $step,
?OperationRun $operationRun,
?ManagedEnvironment $tenant,
): bool {
if (! $operationRun instanceof OperationRun || ! $tenant instanceof ManagedEnvironment) {
return false;
}
if (! is_numeric($step->operation_run_id) || (int) $step->operation_run_id !== (int) $operationRun->getKey()) {
return false;
}
if ((int) $operationRun->workspace_id !== (int) $tenant->workspace_id
|| (int) $operationRun->managed_environment_id !== (int) $tenant->getKey()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User || ! $user->can('view', $operationRun)) {
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,
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 proofUrl(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): ?string private function proofUrl(ReviewPublicationResolutionStep $step, ?ManagedEnvironment $tenant): ?string
{ {
if (! $tenant instanceof ManagedEnvironment || ! is_numeric($step->proof_id) || ! is_string($step->proof_type)) { if (! $tenant instanceof ManagedEnvironment || ! is_numeric($step->proof_id) || ! is_string($step->proof_type)) {
@ -492,13 +566,58 @@ private function proofUrl(ReviewPublicationResolutionStep $step, ?ManagedEnviron
} }
return match ($step->proof_type) { return match ($step->proof_type) {
'environment_review' => EnvironmentReviewResource::environmentScopedUrl('view', ['record' => (int) $step->proof_id], $tenant), 'environment_review' => $this->environmentReviewProofUrl($step, $tenant),
'evidence_snapshot' => EvidenceSnapshotResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant), 'stored_report' => $this->storedReportProofUrl($step, $tenant),
'review_pack' => ReviewPackResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant), 'evidence_snapshot' => $this->evidenceSnapshotProofUrl($step, $tenant),
'review_pack' => $this->reviewPackProofUrl($step, $tenant),
default => null, default => null,
}; };
} }
private function environmentReviewProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string
{
$review = EnvironmentReview::query()->whereKey((int) $step->proof_id)->first();
if (! $review instanceof EnvironmentReview || ! EnvironmentReviewResource::canView($review)) {
return null;
}
return EnvironmentReviewResource::environmentScopedUrl('view', ['record' => (int) $step->proof_id], $tenant);
}
private function storedReportProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string
{
$report = StoredReport::query()->whereKey((int) $step->proof_id)->first();
if (! $report instanceof StoredReport || ! StoredReportResource::canView($report)) {
return null;
}
return StoredReportResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant);
}
private function evidenceSnapshotProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string
{
$snapshot = EvidenceSnapshot::query()->whereKey((int) $step->proof_id)->first();
if (! $snapshot instanceof EvidenceSnapshot || ! EvidenceSnapshotResource::canView($snapshot)) {
return null;
}
return EvidenceSnapshotResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant);
}
private function reviewPackProofUrl(ReviewPublicationResolutionStep $step, ManagedEnvironment $tenant): ?string
{
$reviewPack = ReviewPack::query()->whereKey((int) $step->proof_id)->first();
if (! $reviewPack instanceof ReviewPack || ! ReviewPackResource::canView($reviewPack)) {
return null;
}
return ReviewPackResource::getUrl('view', ['record' => (int) $step->proof_id], tenant: $tenant);
}
private function statusLabel(string $status): string private function statusLabel(string $status): string
{ {
return str($status)->replace('_', ' ')->title()->toString(); return str($status)->replace('_', ' ')->title()->toString();
@ -646,4 +765,14 @@ private function operatorStepDescription(?ReviewPublicationResolutionStepKey $st
default => 'TenantPilot prepares the next safe publication prerequisite.', default => 'TenantPilot prepares the next safe publication prerequisite.',
}; };
} }
private function proofLabelFromState(ReviewPublicationResolutionStep $step): string
{
return match ((string) $step->status) {
ReviewPublicationResolutionStepStatus::Running->value => 'Operation running',
ReviewPublicationResolutionStepStatus::Failed->value => 'Action failed',
ReviewPublicationResolutionStepStatus::Completed->value => 'Current proof',
default => is_string($step->proof_type) ? 'Proof cannot be verified' : 'Proof missing',
};
}
} }

View File

@ -10,10 +10,10 @@
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\EntraRoleDefinition; use App\Models\EntraRoleDefinition;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment; use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentOnboardingSession; use App\Models\ManagedEnvironmentOnboardingSession;
use App\Models\ManagedEnvironmentTriageReview; use App\Models\ManagedEnvironmentTriageReview;
use App\Models\ProviderConnection;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
@ -43,16 +43,17 @@
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\ManagedEnvironmentLinks; use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
use App\Support\Navigation\RelatedContextEntry; use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\UnavailableRelationState; use App\Support\Navigation\UnavailableRelationState;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewStateResolver;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreSafety\RestoreSafetyResolver; use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantActionDescriptor; use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionSurface; use App\Support\Tenants\TenantActionSurface;
@ -61,7 +62,6 @@
use App\Support\Tenants\TenantOperabilityOutcome; use App\Support\Tenants\TenantOperabilityOutcome;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantRecoveryTriagePresentation; use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -73,7 +73,6 @@
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -655,7 +654,9 @@ private static function handleVerifyConfigurationAction(
); );
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); if ($result->canDiscloseRun) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
$notification->send(); $notification->send();
@ -944,11 +945,11 @@ public static function table(Table $table): Table
->icon(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path') ->icon(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
->url(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->url(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (ManagedEnvironment $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'), ->visible(fn (ManagedEnvironment $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
Actions\Action::make('openTenant') Actions\Action::make('openTenant')
->label('Open') ->label('Open')
->icon('heroicon-o-arrow-right') ->icon('heroicon-o-arrow-right')
->color('primary') ->color('primary')
->url(function (?ManagedEnvironment $record = null, mixed $livewire = null): string { ->url(function (?ManagedEnvironment $record = null, mixed $livewire = null): string {
if (! $record instanceof ManagedEnvironment) { if (! $record instanceof ManagedEnvironment) {
return '#'; return '#';
} }
@ -970,323 +971,323 @@ public static function table(Table $table): Table
}) })
->visible(fn (ManagedEnvironment $record): bool => $record->isActive() && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), ->visible(fn (ManagedEnvironment $record): bool => $record->isActive() && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
ActionGroup::make([ ActionGroup::make([
Actions\Action::make('related_onboarding_overflow') Actions\Action::make('related_onboarding_overflow')
->label(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding') ->label(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
->icon(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye') ->icon(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
->url(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->url(fn (ManagedEnvironment $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (ManagedEnvironment $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor ->visible(fn (ManagedEnvironment $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
Actions\Action::make('compareEnvironments') Actions\Action::make('compareEnvironments')
->label('Compare tenants') ->label('Compare tenants')
->icon('heroicon-o-scale') ->icon('heroicon-o-scale')
->color('gray') ->color('gray')
->url(function (ManagedEnvironment $record, mixed $livewire): string { ->url(function (ManagedEnvironment $record, mixed $livewire): string {
$triageState = $livewire instanceof Pages\ListManagedEnvironments $triageState = $livewire instanceof Pages\ListManagedEnvironments
? static::currentPortfolioTriageState($livewire) ? static::currentPortfolioTriageState($livewire)
: []; : [];
if (! static::hasActivePortfolioTriageState( if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []), static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []), static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []), static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null), static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) { )) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query()); $triageState = static::portfolioReturnFiltersFromRequest(request()->query());
} }
return static::crossEnvironmentCompareOpenUrl($record, $triageState); return static::crossEnvironmentCompareOpenUrl($record, $triageState);
}) })
->visible(fn (ManagedEnvironment $record): bool => static::crossEnvironmentCompareActionVisible($record)), ->visible(fn (ManagedEnvironment $record): bool => static::crossEnvironmentCompareActionVisible($record)),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('edit') Actions\Action::make('edit')
->label('Edit') ->label('Edit')
->icon('heroicon-o-pencil-square') ->icon('heroicon-o-pencil-square')
->visible(fn (ManagedEnvironment $record): bool => static::canEdit($record)) ->visible(fn (ManagedEnvironment $record): bool => static::canEdit($record))
->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record])) ->url(fn (ManagedEnvironment $record) => static::getUrl('edit', ['record' => $record]))
) )
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->apply(), ->apply(),
static::makeAdminConsentAction(), static::makeAdminConsentAction(),
static::makeOpenInEntraAction(), static::makeOpenInEntraAction(),
static::makeSyncTenantAction(), static::makeSyncTenantAction(),
static::makeVerifyConfigurationAction(), static::makeVerifyConfigurationAction(),
Actions\Action::make('markReviewed') Actions\Action::make('markReviewed')
->label('Mark reviewed') ->label('Mark reviewed')
->icon('heroicon-o-check-circle') ->icon('heroicon-o-check-circle')
->color('success') ->color('success')
->requiresConfirmation()
->modalHeading('Mark reviewed')
->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
$record,
static::currentPortfolioTriageState($livewire),
ManagedEnvironmentTriageReview::STATE_REVIEWED,
))
->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
$record,
static::currentPortfolioTriageState($livewire),
) !== null && static::userCanSeeTriageReviewAction($record))
->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
->before(function (ManagedEnvironment $record): void {
static::authorizeTriageReviewAction($record);
})
->action(function (
ManagedEnvironment $record,
mixed $livewire,
ManagedEnvironmentTriageReviewService $service,
): void {
static::handleTriageReviewMutation(
tenant: $record,
triageState: static::currentPortfolioTriageState($livewire),
targetManualState: ManagedEnvironmentTriageReview::STATE_REVIEWED,
service: $service,
);
}),
Actions\Action::make('markFollowUpNeeded')
->label('Mark follow-up needed')
->icon('heroicon-o-exclamation-triangle')
->color('warning')
->requiresConfirmation()
->modalHeading('Mark follow-up needed')
->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
$record,
static::currentPortfolioTriageState($livewire),
ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
))
->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
$record,
static::currentPortfolioTriageState($livewire),
) !== null && static::userCanSeeTriageReviewAction($record))
->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
->before(function (ManagedEnvironment $record): void {
static::authorizeTriageReviewAction($record);
})
->action(function (
ManagedEnvironment $record,
mixed $livewire,
ManagedEnvironmentTriageReviewService $service,
): void {
static::handleTriageReviewMutation(
tenant: $record,
triageState: static::currentPortfolioTriageState($livewire),
targetManualState: ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
service: $service,
);
}),
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
static::makeRestoreTenantToWorkspaceAction(),
static::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (?ManagedEnvironment $record): bool => (bool) $record?->trashed()) ->modalHeading('Mark reviewed')
->action(function (?ManagedEnvironment $record, AuditLogger $auditLogger): void { ->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
if ($record === null) { $record,
return; static::currentPortfolioTriageState($livewire),
} ManagedEnvironmentTriageReview::STATE_REVIEWED,
))
$user = auth()->user(); ->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
$record,
if (! $user instanceof User) { static::currentPortfolioTriageState($livewire),
abort(403); ) !== null && static::userCanSeeTriageReviewAction($record))
} ->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
/** @var CapabilityResolver $resolver */ ->before(function (ManagedEnvironment $record): void {
$resolver = app(CapabilityResolver::class); static::authorizeTriageReviewAction($record);
})
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) { ->action(function (
abort(403); ManagedEnvironment $record,
} mixed $livewire,
ManagedEnvironmentTriageReviewService $service,
$tenant = ManagedEnvironment::withTrashed()->find($record->id); ): void {
static::handleTriageReviewMutation(
if (! $tenant?->trashed()) { tenant: $record,
Notification::make() triageState: static::currentPortfolioTriageState($livewire),
->title('ManagedEnvironment must be archived first') targetManualState: ManagedEnvironmentTriageReview::STATE_REVIEWED,
->danger() service: $service,
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'tenant.force_deleted',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['managed_environment_id' => $tenant->managed_environment_id]]
); );
$tenant->forceDelete();
Notification::make()
->title('ManagedEnvironment permanently deleted')
->success()
->send();
}), }),
) Actions\Action::make('markFollowUpNeeded')
->preserveVisibility() ->label('Mark follow-up needed')
->requireCapability(Capabilities::TENANT_DELETE) ->icon('heroicon-o-exclamation-triangle')
->apply(), ->color('warning')
static::makeRemoveTenantFromWorkspaceAction(), ->requiresConfirmation()
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow), ->modalHeading('Mark follow-up needed')
]) ->modalDescription(fn (ManagedEnvironment $record, mixed $livewire): string => static::triageReviewActionModalDescription(
$record,
static::currentPortfolioTriageState($livewire),
ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
))
->visible(fn (ManagedEnvironment $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
$record,
static::currentPortfolioTriageState($livewire),
) !== null && static::userCanSeeTriageReviewAction($record))
->disabled(fn (ManagedEnvironment $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (ManagedEnvironment $record): ?string => static::triageReviewActionTooltip($record))
->before(function (ManagedEnvironment $record): void {
static::authorizeTriageReviewAction($record);
})
->action(function (
ManagedEnvironment $record,
mixed $livewire,
ManagedEnvironmentTriageReviewService $service,
): void {
static::handleTriageReviewMutation(
tenant: $record,
triageState: static::currentPortfolioTriageState($livewire),
targetManualState: ManagedEnvironmentTriageReview::STATE_FOLLOW_UP_NEEDED,
service: $service,
);
}),
static::makeRestoreTenantAction(TenantActionSurface::TenantIndexRow),
static::makeRestoreTenantToWorkspaceAction(),
static::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (?ManagedEnvironment $record): bool => (bool) $record?->trashed())
->action(function (?ManagedEnvironment $record, AuditLogger $auditLogger): void {
if ($record === null) {
return;
}
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$tenant = ManagedEnvironment::withTrashed()->find($record->id);
if (! $tenant?->trashed()) {
Notification::make()
->title('ManagedEnvironment must be archived first')
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'tenant.force_deleted',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['managed_environment_id' => $tenant->managed_environment_id]]
);
$tenant->forceDelete();
Notification::make()
->title('ManagedEnvironment permanently deleted')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
static::makeRemoveTenantFromWorkspaceAction(),
static::makeArchiveTenantAction(TenantActionSurface::TenantIndexRow),
])
->label('More') ->label('More')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
->color('gray'), ->color('gray'),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
Actions\BulkAction::make('compareSelected') Actions\BulkAction::make('compareSelected')
->label('Compare selected') ->label('Compare selected')
->icon('heroicon-o-scale') ->icon('heroicon-o-scale')
->color('gray') ->color('gray')
->visible(fn (): bool => auth()->user() instanceof User) ->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User) ->authorize(fn (): bool => auth()->user() instanceof User)
->extraAttributes(fn (mixed $livewire): array => [ ->extraAttributes(fn (mixed $livewire): array => [
'x-bind:aria-disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' ? true : null', 'x-bind:aria-disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' ? true : null',
'x-bind:disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire), 'x-bind:disabled' => static::crossEnvironmentCompareBulkClientDisabledExpression($livewire),
'x-bind:title' => static::crossEnvironmentCompareBulkClientTooltipExpression($livewire), 'x-bind:title' => static::crossEnvironmentCompareBulkClientTooltipExpression($livewire),
'x-bind:class' => "{ 'fi-disabled': ".static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' }', 'x-bind:class' => "{ 'fi-disabled': ".static::crossEnvironmentCompareBulkClientDisabledExpression($livewire).' }',
]) ])
->action(function (Collection $records, mixed $livewire): void { ->action(function (Collection $records, mixed $livewire): void {
$disabledReason = static::crossEnvironmentCompareBulkDisabledReason($records); $disabledReason = static::crossEnvironmentCompareBulkDisabledReason($records);
if ($disabledReason !== null) { if ($disabledReason !== null) {
Notification::make() Notification::make()
->title($disabledReason) ->title($disabledReason)
->danger() ->danger()
->send();
return;
}
if (method_exists($livewire, 'redirect')) {
$livewire->redirect(static::crossEnvironmentCompareBulkOpenUrl($records, $livewire), navigate: true);
}
}),
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User)
->disabled(function (Collection $records): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if ($records->isEmpty()) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $records
->filter(fn ($record) => $record instanceof ManagedEnvironment)
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
})
->tooltip(function (Collection $records): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return UiTooltips::insufficientPermission();
}
if ($records->isEmpty()) {
return null;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$isDenied = $records
->filter(fn ($record) => $record instanceof ManagedEnvironment)
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
return $isDenied ? UiTooltips::insufficientPermission() : null;
})
->action(function (Collection $records, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$eligible = $records
->filter(fn ($record) => $record instanceof ManagedEnvironment && $record->isActive())
->filter(fn (ManagedEnvironment $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
if ($eligible->isEmpty()) {
Notification::make()
->title('Bulk sync skipped')
->body('No eligible tenants selected.')
->icon('heroicon-o-information-circle')
->info()
->send();
return;
}
$tenantContext = ManagedEnvironment::current() ?? $eligible->first();
if (! $tenantContext) {
return;
}
$ids = $eligible->pluck('id')->toArray();
$count = $eligible->count();
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenantContext,
type: 'tenant.sync',
targetScope: [
'entra_tenant_id' => (string) ($tenantContext->managed_environment_id ?? $tenantContext->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
BulkTenantSyncJob::dispatch(
tenantId: (int) $tenantContext->getKey(),
userId: (int) $user->getKey(),
tenantIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'tenant_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('tenant.sync')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url(OperationRunLinks::view($opRun, $tenantContext)),
])
->send(); ->send();
})
return; ->deselectRecordsAfterCompletion(),
}
if (method_exists($livewire, 'redirect')) {
$livewire->redirect(static::crossEnvironmentCompareBulkOpenUrl($records, $livewire), navigate: true);
}
}),
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User)
->disabled(function (Collection $records): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if ($records->isEmpty()) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $records
->filter(fn ($record) => $record instanceof ManagedEnvironment)
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
})
->tooltip(function (Collection $records): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return UiTooltips::insufficientPermission();
}
if ($records->isEmpty()) {
return null;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$isDenied = $records
->filter(fn ($record) => $record instanceof ManagedEnvironment)
->contains(fn (ManagedEnvironment $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
return $isDenied ? UiTooltips::insufficientPermission() : null;
})
->action(function (Collection $records, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$eligible = $records
->filter(fn ($record) => $record instanceof ManagedEnvironment && $record->isActive())
->filter(fn (ManagedEnvironment $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
if ($eligible->isEmpty()) {
Notification::make()
->title('Bulk sync skipped')
->body('No eligible tenants selected.')
->icon('heroicon-o-information-circle')
->info()
->send();
return;
}
$tenantContext = ManagedEnvironment::current() ?? $eligible->first();
if (! $tenantContext) {
return;
}
$ids = $eligible->pluck('id')->toArray();
$count = $eligible->count();
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenantContext,
type: 'tenant.sync',
targetScope: [
'entra_tenant_id' => (string) ($tenantContext->managed_environment_id ?? $tenantContext->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
BulkTenantSyncJob::dispatch(
tenantId: (int) $tenantContext->getKey(),
userId: (int) $user->getKey(),
tenantIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'tenant_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('tenant.sync')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url(OperationRunLinks::view($opRun, $tenantContext)),
])
->send();
})
->deselectRecordsAfterCompletion(),
])->label('More'), ])->label('More'),
]) ])
->headerActions([]) ->headerActions([])

View File

@ -3084,7 +3084,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
return; return;
} }
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { if (in_array($result->status, ['started', 'deduped'], true) || ($result->status === 'scope_busy' && $result->canDiscloseRun)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
} }

View File

@ -7,8 +7,8 @@
use App\Filament\Resources\ManagedEnvironmentResource\Pages\ViewManagedEnvironment; use App\Filament\Resources\ManagedEnvironmentResource\Pages\ViewManagedEnvironment;
use App\Filament\Support\VerificationReportChangeIndicator; use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer; use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment; use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User; use App\Models\User;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification; use App\Services\Verification\StartVerification;
@ -16,12 +16,9 @@
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action; use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
class ManagedEnvironmentVerificationReport extends Widget class ManagedEnvironmentVerificationReport extends Widget
@ -79,7 +76,9 @@ public function startVerification(StartVerification $verification): void
); );
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); if ($result->canDiscloseRun) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
}
$notification->send(); $notification->send();

View File

@ -2,9 +2,9 @@
namespace App\Services\Providers; namespace App\Services\Providers;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -91,6 +91,7 @@ public function start(
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $binding, $resolution): ProviderOperationStartResult { return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $binding, $resolution): ProviderOperationStartResult {
$connection = $resolution->connection; $connection = $resolution->connection;
$resolutionIdentity = $this->resolutionOperationIdentity($extraContext);
if (! $connection instanceof ProviderConnection) { if (! $connection instanceof ProviderConnection) {
throw new InvalidArgumentException('Resolved provider connection is missing.'); throw new InvalidArgumentException('Resolved provider connection is missing.');
@ -101,15 +102,15 @@ public function start(
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$activeRun = OperationRun::query() $activeRuns = OperationRun::query()
->where('managed_environment_id', $tenant->getKey()) ->where('managed_environment_id', $tenant->getKey())
->active() ->active()
->where('context->provider_connection_id', (int) $lockedConnection->getKey()) ->where('context->provider_connection_id', (int) $lockedConnection->getKey())
->orderByDesc('id') ->orderByDesc('id')
->lockForUpdate() ->lockForUpdate()
->first(); ->get();
if ($activeRun instanceof OperationRun) { foreach ($activeRuns as $index => $activeRun) {
if ($this->runs->isStaleQueuedRun($activeRun)) { if ($this->runs->isStaleQueuedRun($activeRun)) {
$this->runs->failStaleQueuedRun($activeRun); $this->runs->failStaleQueuedRun($activeRun);
@ -123,16 +124,33 @@ public function start(
$activeRun->refresh(); $activeRun->refresh();
} }
$activeRun = null; $activeRuns->forget($index);
} }
} }
if ($activeRun instanceof OperationRun) { $activeRuns = $activeRuns->values();
if ($activeRun->type === $operationType) {
return ProviderOperationStartResult::deduped($activeRun);
}
return ProviderOperationStartResult::scopeBusy($activeRun); $mismatchedIdentityRun = $activeRuns->first(
fn (OperationRun $run): bool => ! $this->operationHasSameResolutionIdentity($run, $resolutionIdentity),
);
if ($mismatchedIdentityRun instanceof OperationRun) {
return ProviderOperationStartResult::scopeBusyWithoutDisclosure($mismatchedIdentityRun);
}
$blockingRun = $activeRuns->first(static fn (OperationRun $run): bool => (string) $run->type !== $operationType);
if ($blockingRun instanceof OperationRun) {
return ProviderOperationStartResult::scopeBusy($blockingRun);
}
$dedupeRun = $activeRuns->first(
fn (OperationRun $run): bool => (string) $run->type === $operationType
&& $this->operationHasSameResolutionIdentity($run, $resolutionIdentity),
);
if ($dedupeRun instanceof OperationRun) {
return ProviderOperationStartResult::deduped($dedupeRun);
} }
$providerCapabilityResults = $this->providerCapabilityEvaluator->evaluateForOperation( $providerCapabilityResults = $this->providerCapabilityEvaluator->evaluateForOperation(
@ -180,9 +198,9 @@ public function start(
$run = $this->runs->ensureRunWithIdentity( $run = $this->runs->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: $operationType, type: $operationType,
identityInputs: [ identityInputs: array_replace([
'provider_connection_id' => (int) $lockedConnection->getKey(), 'provider_connection_id' => (int) $lockedConnection->getKey(),
], ], $resolutionIdentity),
context: $context, context: $context,
initiator: $initiator, initiator: $initiator,
); );
@ -226,10 +244,10 @@ private function startBlocked(
: $this->targetScopeContextForTenant($tenant, $provider), : $this->targetScopeContextForTenant($tenant, $provider),
]); ]);
$identityInputs = [ $identityInputs = array_replace([
'provider' => $provider, 'provider' => $provider,
'reason_code' => $reasonCode, 'reason_code' => $reasonCode,
]; ], $this->resolutionOperationIdentity($extraContext));
if (is_string($extensionReasonCode) && $extensionReasonCode !== '') { if (is_string($extensionReasonCode) && $extensionReasonCode !== '') {
$context['reason_code_extension'] = $extensionReasonCode; $context['reason_code_extension'] = $extensionReasonCode;
@ -271,6 +289,47 @@ private function startBlocked(
return ProviderOperationStartResult::blocked($run); return ProviderOperationStartResult::blocked($run);
} }
/**
* @param array<string, mixed> $extraContext
* @return array<string, int|string>
*/
private function resolutionOperationIdentity(array $extraContext): array
{
$identity = [];
foreach (['review_publication_resolution_case_id', 'environment_review_id'] as $key) {
$value = data_get($extraContext, $key);
if (is_numeric($value)) {
$identity[$key] = (int) $value;
}
}
$trigger = data_get($extraContext, 'trigger');
if ($identity !== [] && is_string($trigger) && $trigger !== '') {
$identity['trigger'] = $trigger;
}
return $identity;
}
/**
* @param array<string, int|string> $identity
*/
private function operationHasSameResolutionIdentity(OperationRun $run, array $identity): bool
{
return $this->operationResolutionIdentity($run) === $identity;
}
/**
* @return array<string, int|string>
*/
private function operationResolutionIdentity(OperationRun $run): array
{
return $this->resolutionOperationIdentity(is_array($run->context) ? $run->context : []);
}
/** /**
* @param array<int, ProviderCapabilityResult> $results * @param array<int, ProviderCapabilityResult> $results
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>

View File

@ -10,6 +10,7 @@ private function __construct(
public readonly string $status, public readonly string $status,
public readonly OperationRun $run, public readonly OperationRun $run,
public readonly bool $dispatched, public readonly bool $dispatched,
public readonly bool $canDiscloseRun = true,
) {} ) {}
public static function started(OperationRun $run, bool $dispatched): self public static function started(OperationRun $run, bool $dispatched): self
@ -27,8 +28,25 @@ public static function scopeBusy(OperationRun $run): self
return new self('scope_busy', $run, false); return new self('scope_busy', $run, false);
} }
public static function scopeBusyWithoutDisclosure(OperationRun $run): self
{
return new self('scope_busy', $run, false, false);
}
public static function blocked(OperationRun $run): self public static function blocked(OperationRun $run): self
{ {
return new self('blocked', $run, false); return new self('blocked', $run, false);
} }
public function disclosableRun(): ?OperationRun
{
return $this->canDiscloseRun ? $this->run : null;
}
public function disclosableRunId(): ?int
{
$run = $this->disclosableRun();
return $run instanceof OperationRun ? (int) $run->getKey() : null;
}
} }

View File

@ -23,11 +23,15 @@ public function __construct(
public function notification( public function notification(
ProviderOperationStartResult $result, ProviderOperationStartResult $result,
string $blockedTitle, string $blockedTitle,
string $runUrl, ?string $runUrl,
array $extraActions = [], array $extraActions = [],
string $scopeBusyTitle = 'Scope busy', string $scopeBusyTitle = 'Scope busy',
string $scopeBusyBody = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.', string $scopeBusyBody = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
): FilamentNotification { ): FilamentNotification {
if ($result->status === 'scope_busy' && ! $result->canDiscloseRun) {
$scopeBusyBody = 'Another provider-backed operation is already running for this scope. Retry after it finishes.';
}
$notification = match ($result->status) { $notification = match ($result->status) {
'started' => OperationUxPresenter::queuedToast((string) $result->run->type), 'started' => OperationUxPresenter::queuedToast((string) $result->run->type),
'deduped' => OperationUxPresenter::alreadyRunningToast((string) $result->run->type), 'deduped' => OperationUxPresenter::alreadyRunningToast((string) $result->run->type),
@ -46,13 +50,15 @@ public function notification(
* @param array<int, Action> $extraActions * @param array<int, Action> $extraActions
* @return array<int, Action> * @return array<int, Action>
*/ */
private function actionsFor(ProviderOperationStartResult $result, string $runUrl, array $extraActions): array private function actionsFor(ProviderOperationStartResult $result, ?string $runUrl, array $extraActions): array
{ {
$actions = [ $actions = [];
Action::make('view_run')
if ($result->canDiscloseRun && is_string($runUrl) && trim($runUrl) !== '') {
$actions[] = Action::make('view_run')
->label(OperationRunLinks::openLabel()) ->label(OperationRunLinks::openLabel())
->url($runUrl), ->url($runUrl);
]; }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$nextStep = $this->firstNextStep($result); $nextStep = $this->firstNextStep($result);

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
enum ResolutionProofCurrentness: string
{
case Current = 'current';
case Stale = 'stale';
case Superseded = 'superseded';
case NotApplicable = 'not_applicable';
case Unknown = 'unknown';
}

View File

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
use App\Models\EnvironmentReview;
use Carbon\CarbonInterface;
final readonly class ResolutionProofEvaluation
{
/**
* @param array<string, mixed> $safeSummary
*/
public function __construct(
public ReviewPublicationResolutionStepKey $actionKey,
public string $subjectType,
public int $subjectId,
public ResolutionProofStatus $status,
public ResolutionProofCurrentness $currentness,
public ResolutionProofUsability $usability,
public ResolutionProofVisibility $visibility,
public string $reasonCode,
public ?ResolutionProofReference $reference = null,
public ?int $operationRunId = null,
public ?CarbonInterface $evaluatedAt = null,
public array $safeSummary = [],
) {}
public static function missing(
ReviewPublicationResolutionStepKey $actionKey,
EnvironmentReview $review,
string $reasonCode = 'proof.missing',
?CarbonInterface $evaluatedAt = null,
): self {
return new self(
actionKey: $actionKey,
subjectType: EnvironmentReview::class,
subjectId: (int) $review->getKey(),
status: ResolutionProofStatus::Missing,
currentness: ResolutionProofCurrentness::Unknown,
usability: ResolutionProofUsability::NotUsable,
visibility: ResolutionProofVisibility::OperatorVisible,
reasonCode: $reasonCode,
evaluatedAt: $evaluatedAt ?? now(),
safeSummary: [
'message' => 'Proof is missing for the current resolution step.',
],
);
}
public function canCompleteStep(): bool
{
if ($this->status !== ResolutionProofStatus::Available) {
return false;
}
if (! in_array($this->visibility, [
ResolutionProofVisibility::OperatorVisible,
], true)) {
return false;
}
if (! in_array($this->currentness, [
ResolutionProofCurrentness::Current,
ResolutionProofCurrentness::NotApplicable,
], true)) {
return false;
}
return in_array($this->usability, [
ResolutionProofUsability::Usable,
ResolutionProofUsability::UsableWithWarning,
], true);
}
/**
* @return array{
* proof_type:?string,
* proof_id:?int,
* proof_status:?string,
* operation_run_id:?int,
* proof_currentness:string,
* proof_usability:string,
* proof_visibility:string,
* proof_reason_code:string,
* proof_evaluated_at:?string,
* proof_timestamp:?string,
* proof_summary:array<string, mixed>
* }
*/
public function toStepPayload(): array
{
return [
'proof_type' => $this->reference?->proofType,
'proof_id' => $this->reference?->proofId,
'proof_status' => $this->reference?->sourceStatus ?? $this->status->value,
'operation_run_id' => $this->operationRunId,
'proof_currentness' => $this->currentness->value,
'proof_usability' => $this->usability->value,
'proof_visibility' => $this->visibility->value,
'proof_reason_code' => $this->reasonCode,
'proof_evaluated_at' => ($this->evaluatedAt ?? now())->toIso8601String(),
'proof_timestamp' => $this->reference?->proofTimestamp?->toIso8601String(),
'proof_summary' => self::sanitizeSummary($this->safeSummary),
];
}
/**
* @param array<string, mixed> $summary
* @return array<string, mixed>
*/
public static function sanitizeSummary(array $summary): array
{
$safe = [];
foreach ($summary as $key => $value) {
$key = (string) $key;
if (self::isUnsafeKey($key)) {
continue;
}
$sanitized = self::sanitizeValue($value);
if ($sanitized !== null) {
$safe[$key] = $sanitized;
}
}
return $safe;
}
private static function isUnsafeKey(string $key): bool
{
return preg_match('/(payload|graph|token|secret|password|credential|exception|raw|content|response|authorization|headers?|body|stack|trace|access[\\s_-]?token|client[\\s_-]?secret|full[\\s_-]?(report|evidence))/i', $key) === 1;
}
private static function isUnsafeString(string $value): bool
{
return preg_match('/(access[\\s_-]?token|refresh[\\s_-]?token|id[\\s_-]?token|client[\\s_-]?secret|secret[-_ ]?token|authorization\\s*:|bearer\\s+[a-z0-9._-]+|raw\\s*(graph|provider|http|response|payload)|graph\\s*(response|payload)|provider\\s*(response|payload)|http\\s*(response|body)|clientexception|serverexception|requestexception|exception\\b|stack\\s*trace|trace\\s*:)/i', $value) === 1;
}
private static function sanitizeValue(mixed $value): mixed
{
if ($value === null || is_bool($value) || is_int($value) || is_float($value)) {
return $value;
}
if (is_string($value)) {
$value = trim($value);
if ($value === '' || self::isUnsafeString($value)) {
return null;
}
return mb_substr($value, 0, 240);
}
if (! is_array($value)) {
return null;
}
$nested = [];
$count = 0;
foreach ($value as $nestedKey => $nestedValue) {
if ($count >= 12) {
break;
}
$nestedKey = (string) $nestedKey;
if (self::isUnsafeKey($nestedKey)) {
continue;
}
$sanitized = self::sanitizeValue($nestedValue);
if ($sanitized === null) {
continue;
}
$nested[$nestedKey] = $sanitized;
$count++;
}
return $nested;
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
use Carbon\CarbonInterface;
final readonly class ResolutionProofReference
{
public function __construct(
public string $proofType,
public int $proofId,
public ?string $sourceStatus = null,
public ?CarbonInterface $proofTimestamp = null,
) {}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
enum ResolutionProofStatus: string
{
case Missing = 'missing';
case Available = 'available';
case Running = 'running';
case Succeeded = 'succeeded';
case Failed = 'failed';
case Cancelled = 'cancelled';
case Unavailable = 'unavailable';
case NotAccessible = 'not_accessible';
case Unknown = 'unknown';
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
enum ResolutionProofUsability: string
{
case Usable = 'usable';
case UsableWithWarning = 'usable_with_warning';
case NotUsable = 'not_usable';
case InspectionOnly = 'inspection_only';
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
enum ResolutionProofVisibility: string
{
case OperatorVisible = 'operator_visible';
case OperatorLimited = 'operator_limited';
case CustomerSafeSummaryOnly = 'customer_safe_summary_only';
case Hidden = 'hidden';
}

View File

@ -8,11 +8,14 @@
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem; use App\Models\EvidenceSnapshotItem;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate; use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
use App\Services\ReviewPackService;
use App\Support\EnvironmentReviewCompletenessState; use App\Support\EnvironmentReviewCompletenessState;
use App\Support\EnvironmentReviewStatus; use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance; use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
final class ReviewPublicationReadinessEvaluator final class ReviewPublicationReadinessEvaluator
@ -22,8 +25,14 @@ final class ReviewPublicationReadinessEvaluator
'entra_admin_roles', 'entra_admin_roles',
]; ];
private const array REQUIRED_REPORT_TYPES = [
'permission_posture' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'entra_admin_roles' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
];
public function __construct( public function __construct(
private readonly EnvironmentReviewReadinessGate $readinessGate, private readonly EnvironmentReviewReadinessGate $readinessGate,
private readonly ReviewPackService $reviewPacks,
) {} ) {}
/** /**
@ -45,6 +54,7 @@ public function __construct(
* guidance:array<string, mixed>, * guidance:array<string, mixed>,
* has_ready_export:bool, * has_ready_export:bool,
* current_export_review_pack_id:?int, * current_export_review_pack_id:?int,
* review_pack_current:bool,
* current_evidence_snapshot_id:?int, * current_evidence_snapshot_id:?int,
* review_operation_run_id:?int, * review_operation_run_id:?int,
* scope:array<string, int> * scope:array<string, int>
@ -66,28 +76,37 @@ public function evaluate(EnvironmentReview $review): array
$pack = $review->currentExportReviewPack; $pack = $review->currentExportReviewPack;
$publicationBlockers = $this->publicationBlockers($review); $publicationBlockers = $this->publicationBlockers($review);
$canPublish = $this->readinessGate->canPublish($review); $canPublish = $this->readinessGate->canPublish($review);
$reportDimensionStates = $this->reportDimensionStates($snapshot); $latestReports = $this->latestRequiredReports($review);
$staleReportDimensions = $snapshot instanceof EvidenceSnapshot
? $this->staleReportDimensions($snapshot, $latestReports)
: [];
$reportDimensionStates = $this->reportDimensionStates($snapshot, $latestReports);
$missingReportDimensions = $this->missingReportDimensions($reportDimensionStates); $missingReportDimensions = $this->missingReportDimensions($reportDimensionStates);
$snapshotMissingCurrentReportReferences = $this->snapshotMissingCurrentReportReferences($reportDimensionStates);
$evidenceState = $snapshot instanceof EvidenceSnapshot $evidenceState = $snapshot instanceof EvidenceSnapshot
? (string) $snapshot->completeness_state ? (string) $snapshot->completeness_state
: EvidenceCompletenessState::Missing->value; : EvidenceCompletenessState::Missing->value;
$evidenceIncomplete = $snapshot === null $evidenceIncomplete = $snapshot === null
|| $evidenceState !== EvidenceCompletenessState::Complete->value || $evidenceState !== EvidenceCompletenessState::Complete->value
|| (int) data_get($snapshot->summary, 'missing_dimensions', 0) > 0 || (int) data_get($snapshot->summary, 'missing_dimensions', 0) > 0
|| (int) data_get($snapshot->summary, 'stale_dimensions', 0) > 0; || (int) data_get($snapshot->summary, 'stale_dimensions', 0) > 0
|| $staleReportDimensions !== []
|| $snapshotMissingCurrentReportReferences !== [];
$reviewStatus = (string) $review->status; $reviewStatus = (string) $review->status;
$reviewCompleteness = (string) $review->completeness_state; $reviewCompleteness = (string) $review->completeness_state;
$hasReadyExport = (bool) ($readiness['has_ready_export'] ?? false); $hasReadyExport = (bool) ($readiness['has_ready_export'] ?? false);
$reviewPackCurrent = $this->reviewPackMatchesCurrentOutput($review, $pack);
$canReturnToPublication = $canPublish && $reviewStatus === EnvironmentReviewStatus::Ready->value; $canReturnToPublication = $canPublish && $reviewStatus === EnvironmentReviewStatus::Ready->value;
$reviewRequiresRefresh = ! $canReturnToPublication $reviewRequiresRefresh = $evidenceIncomplete
|| ! $canReturnToPublication
|| $reviewCompleteness !== EnvironmentReviewCompletenessState::Complete->value; || $reviewCompleteness !== EnvironmentReviewCompletenessState::Complete->value;
$hasCurrentReadyExport = $hasReadyExport && $reviewPackCurrent && ! $reviewRequiresRefresh && $publicationBlockers === [];
$payload = [ $payload = [
'review_id' => (int) $review->getKey(), 'review_id' => (int) $review->getKey(),
'review_status' => $reviewStatus, 'review_status' => $reviewStatus,
'review_completeness_state' => $reviewCompleteness, 'review_completeness_state' => $reviewCompleteness,
'review_fingerprint' => (string) $review->fingerprint, 'review_fingerprint' => (string) $review->fingerprint,
'review_updated_at' => $review->updated_at?->toJSON(),
'evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null, 'evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null,
'evidence_fingerprint' => $snapshot instanceof EvidenceSnapshot ? (string) $snapshot->fingerprint : null, 'evidence_fingerprint' => $snapshot instanceof EvidenceSnapshot ? (string) $snapshot->fingerprint : null,
'evidence_state' => $evidenceState, 'evidence_state' => $evidenceState,
@ -97,10 +116,11 @@ public function evaluate(EnvironmentReview $review): array
'publication_blockers' => $publicationBlockers, 'publication_blockers' => $publicationBlockers,
'readiness_state' => (string) ($readiness['readiness_state'] ?? ''), 'readiness_state' => (string) ($readiness['readiness_state'] ?? ''),
'guidance_state' => (string) ($guidance['state'] ?? ''), 'guidance_state' => (string) ($guidance['state'] ?? ''),
'has_ready_export' => $hasReadyExport, 'has_ready_export' => $hasCurrentReadyExport,
'review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null, 'review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null,
'review_pack_status' => $pack instanceof ReviewPack ? (string) $pack->status : null, 'review_pack_status' => $pack instanceof ReviewPack ? (string) $pack->status : null,
'review_pack_fingerprint' => $pack instanceof ReviewPack ? (string) $pack->fingerprint : null, 'review_pack_fingerprint' => $pack instanceof ReviewPack ? (string) $pack->fingerprint : null,
'review_pack_current' => $reviewPackCurrent,
]; ];
return [ return [
@ -119,8 +139,9 @@ public function evaluate(EnvironmentReview $review): array
'guidance_state' => (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN), 'guidance_state' => (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN),
'readiness' => $readiness, 'readiness' => $readiness,
'guidance' => $guidance, 'guidance' => $guidance,
'has_ready_export' => $hasReadyExport, 'has_ready_export' => $hasCurrentReadyExport,
'current_export_review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null, 'current_export_review_pack_id' => $pack instanceof ReviewPack ? (int) $pack->getKey() : null,
'review_pack_current' => $reviewPackCurrent,
'current_evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null, 'current_evidence_snapshot_id' => $snapshot instanceof EvidenceSnapshot ? (int) $snapshot->getKey() : null,
'review_operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null, 'review_operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
'scope' => [ 'scope' => [
@ -131,6 +152,18 @@ public function evaluate(EnvironmentReview $review): array
]; ];
} }
private function reviewPackMatchesCurrentOutput(EnvironmentReview $review, ?ReviewPack $pack): bool
{
if (! $pack instanceof ReviewPack || ! is_string($pack->fingerprint) || $pack->fingerprint === '') {
return false;
}
$options = is_array($pack->options) ? $pack->options : [];
$expectedFingerprint = $this->reviewPacks->computeFingerprintForReview($review, $options);
return hash_equals($expectedFingerprint, (string) $pack->fingerprint);
}
/** /**
* @return list<string> * @return list<string>
*/ */
@ -144,9 +177,10 @@ private function publicationBlockers(EnvironmentReview $review): array
} }
/** /**
* @return array<string, array{state:string,source_record_id:?int}> * @param Collection<string, StoredReport> $latestReports
* @return array<string, array{state:string,source_record_id:?int,current_report_id:?int,current_report_status:?string,snapshot_source_record_id:?int}>
*/ */
private function reportDimensionStates(?EvidenceSnapshot $snapshot): array private function reportDimensionStates(?EvidenceSnapshot $snapshot, Collection $latestReports): array
{ {
$items = $snapshot instanceof EvidenceSnapshot $items = $snapshot instanceof EvidenceSnapshot
? $snapshot->items->keyBy('dimension_key') ? $snapshot->items->keyBy('dimension_key')
@ -156,12 +190,34 @@ private function reportDimensionStates(?EvidenceSnapshot $snapshot): array
foreach (self::REQUIRED_REPORT_DIMENSIONS as $dimension) { foreach (self::REQUIRED_REPORT_DIMENSIONS as $dimension) {
$item = $items->get($dimension); $item = $items->get($dimension);
$report = $latestReports->get($dimension);
$itemSourceRecordId = $item instanceof EvidenceSnapshotItem && is_numeric($item->source_record_id)
? (int) $item->source_record_id
: null;
$itemComplete = $item instanceof EvidenceSnapshotItem
&& (string) $item->state === EvidenceCompletenessState::Complete->value;
$latestReportId = $report instanceof StoredReport ? (int) $report->getKey() : null;
$latestReportReady = $report instanceof StoredReport
&& (string) $report->status === StoredReport::STATUS_READY;
$hasCurrentReadyReport = $latestReportReady
&& $itemComplete
&& $itemSourceRecordId === $latestReportId;
$itemReferencesLatestFailedReport = $report instanceof StoredReport
&& ! $latestReportReady
&& $itemSourceRecordId === $latestReportId;
$states[$dimension] = [ $states[$dimension] = [
'state' => $item instanceof EvidenceSnapshotItem ? (string) $item->state : EvidenceCompletenessState::Missing->value, 'state' => $hasCurrentReadyReport
'source_record_id' => $item instanceof EvidenceSnapshotItem && is_numeric($item->source_record_id) ? EvidenceCompletenessState::Complete->value
? (int) $item->source_record_id : ($itemReferencesLatestFailedReport
: null, ? EvidenceCompletenessState::Missing->value
: ($item instanceof EvidenceSnapshotItem ? (string) $item->state : EvidenceCompletenessState::Missing->value)),
'source_record_id' => $hasCurrentReadyReport
? $latestReportId
: ($itemReferencesLatestFailedReport ? null : $itemSourceRecordId),
'current_report_id' => $latestReportId,
'current_report_status' => $report instanceof StoredReport ? (string) $report->status : null,
'snapshot_source_record_id' => $itemSourceRecordId,
]; ];
} }
@ -169,7 +225,7 @@ private function reportDimensionStates(?EvidenceSnapshot $snapshot): array
} }
/** /**
* @param array<string, array{state:string,source_record_id:?int}> $states * @param array<string, array{state:string,source_record_id:?int,current_report_id:?int,current_report_status:?string}> $states
* @return list<string> * @return list<string>
*/ */
private function missingReportDimensions(array $states): array private function missingReportDimensions(array $states): array
@ -177,7 +233,8 @@ private function missingReportDimensions(array $states): array
$missing = []; $missing = [];
foreach ($states as $dimension => $state) { foreach ($states as $dimension => $state) {
if (($state['state'] ?? EvidenceCompletenessState::Missing->value) !== EvidenceCompletenessState::Complete->value || ($state['source_record_id'] ?? null) === null) { if (! is_numeric($state['current_report_id'] ?? null)
|| (string) ($state['current_report_status'] ?? '') !== StoredReport::STATUS_READY) {
$missing[] = $dimension; $missing[] = $dimension;
} }
} }
@ -185,6 +242,133 @@ private function missingReportDimensions(array $states): array
return $missing; return $missing;
} }
/**
* @param array<string, array{state:string,source_record_id:?int,current_report_id:?int,current_report_status:?string}> $states
* @return list<string>
*/
private function snapshotMissingCurrentReportReferences(array $states): array
{
$missing = [];
foreach ($states as $dimension => $state) {
$currentReportId = $state['current_report_id'] ?? null;
if (! is_numeric($currentReportId)
|| (string) ($state['current_report_status'] ?? '') !== StoredReport::STATUS_READY) {
continue;
}
if (($state['state'] ?? EvidenceCompletenessState::Missing->value) !== EvidenceCompletenessState::Complete->value
|| ! is_numeric($state['source_record_id'] ?? null)
|| (int) $state['source_record_id'] !== (int) $currentReportId) {
$missing[] = $dimension;
}
}
return $missing;
}
/**
* @return Collection<string, StoredReport>
*/
private function latestRequiredReports(EnvironmentReview $review): Collection
{
return StoredReport::query()
->where('workspace_id', (int) $review->workspace_id)
->where('managed_environment_id', (int) $review->managed_environment_id)
->whereIn('report_type', array_values(self::REQUIRED_REPORT_TYPES))
->orderByDesc('generated_at')
->orderByDesc('updated_at')
->orderByDesc('id')
->get()
->unique('report_type')
->mapWithKeys(function (StoredReport $report): array {
$dimension = array_search((string) $report->report_type, self::REQUIRED_REPORT_TYPES, true);
return is_string($dimension) ? [$dimension => $report] : [];
});
}
/**
* @param Collection<string, StoredReport> $latestReports
* @return list<string>
*/
private function staleReportDimensions(EvidenceSnapshot $snapshot, Collection $latestReports): array
{
$stale = [];
foreach (self::REQUIRED_REPORT_DIMENSIONS as $dimension) {
$report = $latestReports->get($dimension);
$item = $snapshot->items->firstWhere('dimension_key', $dimension);
if (! $report instanceof StoredReport || ! $item instanceof EvidenceSnapshotItem) {
continue;
}
if ((string) $item->state !== EvidenceCompletenessState::Complete->value
|| ! is_numeric($item->source_record_id)) {
continue;
}
if ((string) $report->status !== StoredReport::STATUS_READY) {
$stale[] = $dimension;
continue;
}
if ((int) $item->source_record_id === (int) $report->getKey()) {
continue;
}
if ($this->reportIsNewerThanSnapshot($report, $snapshot, $item->source_record_id)) {
$stale[] = $dimension;
}
}
return $stale;
}
private function reportIsNewerThanSnapshot(StoredReport $report, ?EvidenceSnapshot $snapshot, mixed $itemSourceRecordId): bool
{
if ($snapshot === null) {
return true;
}
if (is_numeric($itemSourceRecordId) && (int) $itemSourceRecordId === (int) $report->getKey()) {
return false;
}
$reportTimestamp = $this->latestTimestamp($report->generated_at, $report->updated_at, $report->created_at);
$snapshotTimestamp = $this->latestTimestamp($snapshot->generated_at, $snapshot->updated_at, $snapshot->created_at);
if (! $reportTimestamp instanceof CarbonInterface) {
return false;
}
if (! $snapshotTimestamp instanceof CarbonInterface) {
return true;
}
if ($reportTimestamp->greaterThan($snapshotTimestamp)) {
return true;
}
if ($reportTimestamp->lessThan($snapshotTimestamp)) {
return false;
}
return ! is_numeric($itemSourceRecordId)
|| (int) $report->getKey() > (int) $itemSourceRecordId;
}
private function latestTimestamp(?CarbonInterface ...$timestamps): ?CarbonInterface
{
return collect($timestamps)
->filter(static fn (?CarbonInterface $timestamp): bool => $timestamp instanceof CarbonInterface)
->sortByDesc(static fn (CarbonInterface $timestamp): int => $timestamp->getTimestamp())
->first();
}
/** /**
* @return list<array{key:string,state:string,required:bool,source_fingerprint:?string}> * @return list<array{key:string,state:string,required:bool,source_fingerprint:?string}>
*/ */

View File

@ -162,6 +162,10 @@ private function completeRequiredReports(
'environment_review_id' => (int) $case->environment_review_id, 'environment_review_id' => (int) $case->environment_review_id,
]); ]);
if ($result->status === 'scope_busy') {
throw new InvalidArgumentException('Provider connection check is already running for another operation.');
}
$this->markQueuedOrCompleted( $this->markQueuedOrCompleted(
case: $case, case: $case,
step: $step, step: $step,
@ -192,7 +196,9 @@ private function completeRequiredReports(
type: OperationRunType::EntraAdminRolesScan->value, type: OperationRunType::EntraAdminRolesScan->value,
identityInputs: [ identityInputs: [
'managed_environment_id' => (int) $tenant->getKey(), 'managed_environment_id' => (int) $tenant->getKey(),
'trigger' => 'scan', 'review_publication_resolution_case_id' => (int) $case->getKey(),
'environment_review_id' => (int) $case->environment_review_id,
'trigger' => 'review_publication_resolution',
], ],
context: [ context: [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,

View File

@ -74,7 +74,7 @@ public function openOrResume(EnvironmentReview $review, User $actor, string $sou
]), ]),
])->save(); ])->save();
$case = $this->syncCase($case, $review, $readiness); $case = $this->syncCase($case, $review, $readiness, $actor);
$this->recordAudit( $this->recordAudit(
case: $case, case: $case,
@ -107,7 +107,7 @@ public function openOrResume(EnvironmentReview $review, User $actor, string $sou
], ],
]); ]);
$case = $this->syncCase($case, $review, $readiness); $case = $this->syncCase($case, $review, $readiness, $actor);
$this->recordAudit( $this->recordAudit(
case: $case, case: $case,
@ -133,7 +133,7 @@ public function refreshCase(ReviewPublicationResolutionCase $case, ?User $actor
} }
$previousStatus = (string) $case->status; $previousStatus = (string) $case->status;
$case = $this->syncCase($case, $review, $this->evaluator->evaluate($review)); $case = $this->syncCase($case, $review, $this->evaluator->evaluate($review), $actor);
if ($case->status === ReviewPublicationResolutionCaseStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionCaseStatus::Completed->value) { if ($case->status === ReviewPublicationResolutionCaseStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionCaseStatus::Completed->value) {
$this->recordAudit( $this->recordAudit(
@ -235,6 +235,7 @@ private function syncCase(
ReviewPublicationResolutionCase $case, ReviewPublicationResolutionCase $case,
EnvironmentReview $review, EnvironmentReview $review,
array $readiness, array $readiness,
?User $actor = null,
): ReviewPublicationResolutionCase { ): ReviewPublicationResolutionCase {
$plan = $this->planner->plan($review, $readiness, $case->exists ? $case : null); $plan = $this->planner->plan($review, $readiness, $case->exists ? $case : null);
@ -254,10 +255,37 @@ private function syncCase(
'metadata' => $stepPlan['metadata'], 'metadata' => $stepPlan['metadata'],
]); ]);
if (! in_array($step->status, [
ReviewPublicationResolutionStepStatus::Completed->value,
ReviewPublicationResolutionStepStatus::Failed->value,
ReviewPublicationResolutionStepStatus::Running->value,
], true)) {
$step->completed_at = null;
$step->failed_at = null;
$step->started_at = null;
}
if ($step->status !== ReviewPublicationResolutionStepStatus::Completed->value) {
$step->completed_at = null;
}
if ($step->status !== ReviewPublicationResolutionStepStatus::Failed->value) {
$step->failed_at = null;
}
if ($step->status === ReviewPublicationResolutionStepStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Completed->value) { if ($step->status === ReviewPublicationResolutionStepStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Completed->value) {
if ($step->completed_at === null) { if ($step->completed_at === null) {
$step->completed_at = now(); $step->completed_at = now();
} }
if ($step->exists && $previousStatus !== null) {
$this->recordAudit(
case: $case,
action: AuditActionId::ReviewPublicationResolutionStepCompleted,
actor: $actor,
metadata: $this->safeProofAuditMetadata($stepPlan),
);
}
} }
if ($step->status === ReviewPublicationResolutionStepStatus::Failed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Failed->value) { if ($step->status === ReviewPublicationResolutionStepStatus::Failed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Failed->value) {
@ -267,6 +295,9 @@ private function syncCase(
} }
if ($step->status === ReviewPublicationResolutionStepStatus::Running->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Running->value) { if ($step->status === ReviewPublicationResolutionStepStatus::Running->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Running->value) {
$step->completed_at = null;
$step->failed_at = null;
if ($step->started_at === null) { if ($step->started_at === null) {
$step->started_at = now(); $step->started_at = now();
} }
@ -311,4 +342,23 @@ private function caseSummary(array $readiness, ?string $currentStepKey): array
'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false), 'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false),
]; ];
} }
/**
* @param array<string, mixed> $stepPlan
* @return array<string, mixed>
*/
private function safeProofAuditMetadata(array $stepPlan): array
{
return [
'step_key' => (string) $stepPlan['step_key'],
'proof_type' => is_string($stepPlan['proof_type'] ?? null) ? $stepPlan['proof_type'] : null,
'proof_id' => is_numeric($stepPlan['proof_id'] ?? null) ? (int) $stepPlan['proof_id'] : null,
'proof_status' => is_string($stepPlan['proof_status'] ?? null) ? $stepPlan['proof_status'] : null,
'proof_currentness' => (string) data_get($stepPlan, 'metadata.proof_currentness', ''),
'proof_usability' => (string) data_get($stepPlan, 'metadata.proof_usability', ''),
'proof_visibility' => (string) data_get($stepPlan, 'metadata.proof_visibility', ''),
'proof_reason_code' => (string) data_get($stepPlan, 'metadata.proof_reason_code', ''),
'proof_evaluated_at' => data_get($stepPlan, 'metadata.proof_evaluated_at'),
];
}
} }

View File

@ -73,19 +73,24 @@ private function stepPlan(
?ReviewPublicationResolutionStep $existingStep, ?ReviewPublicationResolutionStep $existingStep,
): array { ): array {
$status = $this->baseStatus($stepKey, $readiness, $existingStep); $status = $this->baseStatus($stepKey, $readiness, $existingStep);
$proof = $this->proofResolver->proofFor($stepKey, $review); $proofEvaluation = $this->proofResolver->evaluationFor($stepKey, $review, $readiness, $existingStep);
$proof = $proofEvaluation->toStepPayload();
if ($this->requiresCurrentProof($stepKey)) {
if ($proofEvaluation->canCompleteStep()) {
$status = ReviewPublicationResolutionStepStatus::Completed;
} elseif ($this->shouldReopenForCurrentProof($status, $proofEvaluation)) {
$status = ReviewPublicationResolutionStepStatus::Pending;
}
}
if ($existingStep instanceof ReviewPublicationResolutionStep if ($existingStep instanceof ReviewPublicationResolutionStep
&& in_array($status, [ && in_array($status, [
ReviewPublicationResolutionStepStatus::Running, ReviewPublicationResolutionStepStatus::Running,
ReviewPublicationResolutionStepStatus::Failed, ReviewPublicationResolutionStepStatus::Failed,
], true)) { ], true)
$proof = [ && ($proof['proof_visibility'] ?? null) !== ResolutionProofVisibility::Hidden->value) {
'proof_type' => is_string($existingStep->proof_type) ? $existingStep->proof_type : $proof['proof_type'], $proof = $this->mergeExistingProofFallback($existingStep, $proof);
'proof_id' => is_numeric($existingStep->proof_id) ? (int) $existingStep->proof_id : $proof['proof_id'],
'proof_status' => is_string($existingStep->proof_status) ? $existingStep->proof_status : $proof['proof_status'],
'operation_run_id' => is_numeric($existingStep->operation_run_id) ? (int) $existingStep->operation_run_id : $proof['operation_run_id'],
];
} }
return [ return [
@ -97,10 +102,25 @@ private function stepPlan(
'proof_type' => $proof['proof_type'], 'proof_type' => $proof['proof_type'],
'proof_id' => $proof['proof_id'], 'proof_id' => $proof['proof_id'],
'proof_status' => $proof['proof_status'], 'proof_status' => $proof['proof_status'],
'summary' => $this->summary($stepKey, $readiness, $status), 'summary' => array_replace($this->summary($stepKey, $readiness, $status), [
'proof_label' => $this->proofLabel($proof),
'proof_state_description' => $this->proofStateDescription($proof),
'proof_reason_code' => $proof['proof_reason_code'],
'proof_currentness' => $proof['proof_currentness'],
'proof_usability' => $proof['proof_usability'],
'proof_visibility' => $proof['proof_visibility'],
'proof_summary' => $proof['proof_summary'],
]),
'metadata' => [ 'metadata' => [
'readiness_fingerprint' => (string) $readiness['fingerprint'], 'readiness_fingerprint' => (string) $readiness['fingerprint'],
'planned_at' => now()->toIso8601String(), 'planned_at' => now()->toIso8601String(),
'proof_currentness' => $proof['proof_currentness'],
'proof_usability' => $proof['proof_usability'],
'proof_visibility' => $proof['proof_visibility'],
'proof_reason_code' => $proof['proof_reason_code'],
'proof_evaluated_at' => $proof['proof_evaluated_at'],
'proof_timestamp' => $proof['proof_timestamp'],
'proof_summary' => $proof['proof_summary'],
], ],
]; ];
} }
@ -210,6 +230,111 @@ private function readinessStatus(
}; };
} }
private function requiresCurrentProof(ReviewPublicationResolutionStepKey $stepKey): bool
{
return in_array($stepKey, [
ReviewPublicationResolutionStepKey::CompleteRequiredReports,
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot,
ReviewPublicationResolutionStepKey::RefreshReviewComposition,
ReviewPublicationResolutionStepKey::GenerateReviewPack,
], true);
}
private function shouldReopenForCurrentProof(
ReviewPublicationResolutionStepStatus $status,
ResolutionProofEvaluation $proofEvaluation,
): bool {
return match ($status) {
ReviewPublicationResolutionStepStatus::Completed => true,
ReviewPublicationResolutionStepStatus::Running => ! $this->isCurrentRunningOperationProof($proofEvaluation),
ReviewPublicationResolutionStepStatus::Failed => ! $this->isCurrentTerminalOperationProof($proofEvaluation),
default => false,
};
}
private function isCurrentRunningOperationProof(ResolutionProofEvaluation $proofEvaluation): bool
{
return $proofEvaluation->status === ResolutionProofStatus::Running
&& $proofEvaluation->currentness === ResolutionProofCurrentness::Current
&& $proofEvaluation->usability === ResolutionProofUsability::InspectionOnly
&& $proofEvaluation->visibility === ResolutionProofVisibility::OperatorVisible
&& $proofEvaluation->reasonCode === 'proof.operation_running';
}
private function isCurrentTerminalOperationProof(ResolutionProofEvaluation $proofEvaluation): bool
{
return $proofEvaluation->status === ResolutionProofStatus::Failed
&& $proofEvaluation->currentness === ResolutionProofCurrentness::Current
&& $proofEvaluation->usability === ResolutionProofUsability::InspectionOnly
&& $proofEvaluation->visibility === ResolutionProofVisibility::OperatorVisible
&& $proofEvaluation->reasonCode === 'proof.operation_terminal_without_current_artifact';
}
/**
* @param array<string, mixed> $proof
* @return array<string, mixed>
*/
private function mergeExistingProofFallback(ReviewPublicationResolutionStep $existingStep, array $proof): array
{
if (! is_string($existingStep->proof_type) && ! is_numeric($existingStep->proof_id) && ! is_numeric($existingStep->operation_run_id)) {
return $proof;
}
return array_replace($proof, [
'proof_type' => is_string($existingStep->proof_type) ? $existingStep->proof_type : $proof['proof_type'],
'proof_id' => is_numeric($existingStep->proof_id) ? (int) $existingStep->proof_id : $proof['proof_id'],
'proof_status' => is_string($existingStep->proof_status) ? $existingStep->proof_status : $proof['proof_status'],
'operation_run_id' => is_numeric($existingStep->operation_run_id) ? (int) $existingStep->operation_run_id : $proof['operation_run_id'],
]);
}
/**
* @param array<string, mixed> $proof
*/
private function proofLabel(array $proof): string
{
$status = (string) ($proof['proof_status'] ?? '');
$currentness = (string) ($proof['proof_currentness'] ?? '');
$usability = (string) ($proof['proof_usability'] ?? '');
$visibility = (string) ($proof['proof_visibility'] ?? '');
$reasonCode = (string) ($proof['proof_reason_code'] ?? '');
return match (true) {
$visibility === ResolutionProofVisibility::Hidden->value,
$visibility === ResolutionProofVisibility::OperatorLimited->value => 'Not available with your permissions',
$status === ResolutionProofStatus::Missing->value => 'Proof missing',
$status === ResolutionProofStatus::Running->value => 'Operation running',
$status === ResolutionProofStatus::Failed->value => 'Action failed',
$currentness === ResolutionProofCurrentness::Stale->value => 'Outdated proof',
$currentness === ResolutionProofCurrentness::Superseded->value,
str_contains($reasonCode, 'supersede') => 'Superseded by newer result',
$usability === ResolutionProofUsability::Usable->value,
$usability === ResolutionProofUsability::UsableWithWarning->value => 'Current proof',
default => 'Proof cannot be verified',
};
}
/**
* @param array<string, mixed> $proof
*/
private function proofStateDescription(array $proof): string
{
$reasonCode = (string) ($proof['proof_reason_code'] ?? '');
return match (true) {
str_contains($reasonCode, 'supersede') => 'A newer current artifact is available, so the older operation result is diagnostics-only.',
str_contains($reasonCode, 'without_artifact') => 'The operation finished, but the expected artifact is still missing.',
str_contains($reasonCode, 'running') => 'The linked operation can be inspected, but it does not complete this step yet.',
str_contains($reasonCode, 'stale') => 'The linked proof no longer matches the current review inputs.',
str_contains($reasonCode, 'missing') => 'TenantPilot has not found current proof for this step.',
str_contains($reasonCode, 'type_mismatch') => 'The linked operation does not match this resolution step.',
str_contains($reasonCode, 'context_missing'),
str_contains($reasonCode, 'context_mismatch') => 'The linked operation does not match this review publication case.',
str_contains($reasonCode, 'scope_mismatch') => 'Proof is not available for this workspace or environment.',
default => 'TenantPilot evaluated proof against the current review-publication state.',
};
}
/** /**
* @param list<array<string, mixed>> $steps * @param list<array<string, mixed>> $steps
* @return list<array<string, mixed>> * @return list<array<string, mixed>>

View File

@ -159,6 +159,29 @@
<div class="text-xs leading-5 text-gray-500 dark:text-gray-400"> <div class="text-xs leading-5 text-gray-500 dark:text-gray-400">
Proof and operation links are supporting evidence only. They do not publish the review. Proof and operation links are supporting evidence only. They do not publish the review.
</div> </div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::badge color="gray">
{{ (string) ($step['proof_label'] ?? 'Proof cannot be verified') }}
</x-filament::badge>
@if (! empty($step['proof_currentness']))
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ str((string) $step['proof_currentness'])->replace('_', ' ')->title() }}
</span>
@endif
@if (! empty($step['proof_usability']))
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ str((string) $step['proof_usability'])->replace('_', ' ')->title() }}
</span>
@endif
</div>
@if (! empty($step['proof_state_description']))
<div class="text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ (string) $step['proof_state_description'] }}
</div>
@endif
</div> </div>
<div class="flex shrink-0 flex-wrap gap-2"> <div class="flex shrink-0 flex-wrap gap-2">
@if (! empty($step['proof_url'])) @if (! empty($step['proof_url']))

View File

@ -7,6 +7,7 @@
use App\Models\EnvironmentReview; use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment; use App\Models\ManagedEnvironment;
use App\Models\StoredReport;
use App\Models\User; use App\Models\User;
use App\Support\EnvironmentReviewStatus; use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
@ -164,10 +165,21 @@ function spec387BrowserPartialEvidence(ManagedEnvironment $environment): Evidenc
'source_record_id' => null, 'source_record_id' => null,
'source_fingerprint' => null, 'source_fingerprint' => null,
]); ]);
spec387BrowserDeleteStoredReport($environment, StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
spec387BrowserDeleteStoredReport($environment, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
return $snapshot->fresh('items'); return $snapshot->fresh('items');
} }
function spec387BrowserDeleteStoredReport(ManagedEnvironment $environment, string $reportType): void
{
StoredReport::query()
->where('workspace_id', (int) $environment->workspace_id)
->where('managed_environment_id', (int) $environment->getKey())
->where('report_type', $reportType)
->delete();
}
function spec387BrowserScreenshotName(string $name): string function spec387BrowserScreenshotName(string $name): string
{ {
return 'spec387-review-publication-resolution-'.$name; return 'spec387-review-publication-resolution-'.$name;

View File

@ -10,6 +10,7 @@
use App\Models\ManagedEnvironment; use App\Models\ManagedEnvironment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ReviewPublicationResolutionCase; use App\Models\ReviewPublicationResolutionCase;
use App\Models\StoredReport;
use App\Models\User; use App\Models\User;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
@ -149,6 +150,7 @@
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
[$operator] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'operator', workspaceRole: 'operator'); [$operator] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'operator', workspaceRole: 'operator');
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
spec386DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
@ -178,6 +180,7 @@
'source_record_id' => null, 'source_record_id' => null,
'source_fingerprint' => null, 'source_fingerprint' => null,
]); ]);
spec386DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items')); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot->fresh('items'));
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
@ -191,6 +194,7 @@
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Stale Proof']); $tenant = ManagedEnvironment::factory()->create(['name' => 'Spec386 Stale Proof']);
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0); $snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
spec386DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
$step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value); $step = $case?->steps->firstWhere('step_key', ReviewPublicationResolutionStepKey::CompleteRequiredReports->value);
@ -199,6 +203,10 @@
'status' => OperationRunStatus::Completed->value, 'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value, 'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(), 'completed_at' => now()->subMinute(),
'context' => [
'environment_review_id' => (int) $review->getKey(),
'review_publication_resolution_case_id' => (int) $case?->getKey(),
],
]); ]);
$step?->forceFill([ $step?->forceFill([
@ -210,11 +218,24 @@
])->save(); ])->save();
$snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete); $snapshot = restateEnvironmentReviewEvidenceSnapshot($snapshot, EvidenceCompletenessState::Complete);
$snapshot->items()->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles'])->update([ $permissionReport = spec386CreateReadyStoredReport($tenant, StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
$adminRolesReport = StoredReport::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
->latest('id')
->firstOrFail();
$snapshot->items()->where('dimension_key', 'permission_posture')->update([
'state' => EvidenceCompletenessState::Complete->value, 'state' => EvidenceCompletenessState::Complete->value,
'source_record_id' => (int) $permissionReport->getKey(),
'source_fingerprint' => (string) $permissionReport->fingerprint,
]);
$snapshot->items()->where('dimension_key', 'entra_admin_roles')->update([
'state' => EvidenceCompletenessState::Complete->value,
'source_record_id' => (int) $adminRolesReport->getKey(),
'source_fingerprint' => (string) $adminRolesReport->fingerprint,
]); ]);
app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview']), $owner); app(ReviewPublicationResolutionService::class)->refreshCase($case->fresh(['steps.operationRun', 'environmentReview.evidenceSnapshot.items']), $owner);
expect($step?->fresh()->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value); expect($step?->fresh()->status)->toBe(ReviewPublicationResolutionStepStatus::Completed->value);
}); });
@ -233,6 +254,7 @@
'source_record_id' => null, 'source_record_id' => null,
'source_fingerprint' => null, 'source_fingerprint' => null,
]); ]);
spec386DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
@ -353,3 +375,33 @@ public function generateFromReview(\App\Models\EnvironmentReview $review, User $
expect(ReviewPublicationResolutionCase::query()->forReview($review)->active()->count())->toBe(1); expect(ReviewPublicationResolutionCase::query()->forReview($review)->active()->count())->toBe(1);
}); });
function spec386DeleteStoredReport(ManagedEnvironment $tenant, string $reportType): void
{
StoredReport::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->where('report_type', $reportType)
->delete();
}
function spec386CreateReadyStoredReport(ManagedEnvironment $tenant, string $reportType): StoredReport
{
$factory = $reportType === StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES
? StoredReport::factory()->entraAdminRoles(['roles' => []])
: StoredReport::factory()->permissionPosture([
'required_count' => 0,
'granted_count' => 0,
'permissions' => [],
]);
return $factory->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'report_type' => $reportType,
'status' => StoredReport::STATUS_READY,
'generated_at' => now()->addMinute(),
'created_at' => now()->addMinute(),
'updated_at' => now()->addMinute(),
]);
}

View File

@ -10,8 +10,10 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\ReviewPublicationResolutionCase; use App\Models\ReviewPublicationResolutionCase;
use App\Models\StoredReport;
use App\Models\User; use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate; use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
use App\Services\ReviewPackService;
use App\Support\EnvironmentReviewStatus; use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -159,6 +161,10 @@
'type' => OperationRunType::EntraAdminRolesScan->value, 'type' => OperationRunType::EntraAdminRolesScan->value,
'status' => OperationRunStatus::Running->value, 'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'environment_review_id' => (int) $review->getKey(),
'review_publication_resolution_case_id' => (int) $case->getKey(),
],
]); ]);
spec387ForceCurrentStep($case, ReviewPublicationResolutionStepKey::CompleteRequiredReports, ReviewPublicationResolutionStepStatus::Running, $run); spec387ForceCurrentStep($case, ReviewPublicationResolutionStepKey::CompleteRequiredReports, ReviewPublicationResolutionStepStatus::Running, $run);
@ -239,12 +245,18 @@
Storage::disk('exports')->put('review-packs/spec387-customer-safe.zip', 'PK-test'); Storage::disk('exports')->put('review-packs/spec387-customer-safe.zip', 'PK-test');
$packOptions = [
'include_pii' => false,
'include_operations' => true,
];
$pack = ReviewPack::factory()->ready()->create([ $pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(), 'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'environment_review_id' => (int) $review->getKey(), 'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $review->evidence_snapshot_id, 'evidence_snapshot_id' => (int) $review->evidence_snapshot_id,
'initiated_by_user_id' => (int) $owner->getKey(), 'initiated_by_user_id' => (int) $owner->getKey(),
'fingerprint' => app(ReviewPackService::class)->computeFingerprintForReview($review, $packOptions),
'options' => $packOptions,
'file_path' => 'review-packs/spec387-customer-safe.zip', 'file_path' => 'review-packs/spec387-customer-safe.zip',
'file_disk' => 'exports', 'file_disk' => 'exports',
]); ]);
@ -279,6 +291,8 @@ function spec387BlockedReviewFixture(): array
'source_record_id' => null, 'source_record_id' => null,
'source_fingerprint' => null, 'source_fingerprint' => null,
]); ]);
spec387DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
spec387DeleteStoredReport($tenant, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot); $review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
$case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner); $case = app(ReviewPublicationResolutionService::class)->openOrResume($review, $owner);
@ -310,31 +324,50 @@ function spec387MakeReviewReadyForReturn(EnvironmentReview $review, User $owner)
])->save(); ])->save();
}); });
$permissionReport = spec387EnsureReadyStoredReport($review, StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
$adminRolesReport = spec387EnsureReadyStoredReport($review, StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
$review->evidenceSnapshot?->items() $review->evidenceSnapshot?->items()
->whereIn('dimension_key', ['permission_posture', 'entra_admin_roles']) ->where('dimension_key', 'permission_posture')
->update([ ->update([
'state' => EvidenceCompletenessState::Complete->value, 'state' => EvidenceCompletenessState::Complete->value,
'source_record_id' => '1', 'source_record_id' => (int) $permissionReport->getKey(),
'source_fingerprint' => 'spec387-ready-report', 'source_fingerprint' => (string) $permissionReport->fingerprint,
'updated_at' => now(),
]);
$review->evidenceSnapshot?->items()
->where('dimension_key', 'entra_admin_roles')
->update([
'state' => EvidenceCompletenessState::Complete->value,
'source_record_id' => (int) $adminRolesReport->getKey(),
'source_fingerprint' => (string) $adminRolesReport->fingerprint,
'updated_at' => now(), 'updated_at' => now(),
]); ]);
Storage::disk('exports')->put('review-packs/spec387-ready-return.zip', 'PK-test'); Storage::disk('exports')->put('review-packs/spec387-ready-return.zip', 'PK-test');
$review->forceFill([
'status' => EnvironmentReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
$packOptions = [
'include_pii' => false,
'include_operations' => true,
];
$pack = ReviewPack::factory()->ready()->create([ $pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $review->managed_environment_id, 'managed_environment_id' => (int) $review->managed_environment_id,
'workspace_id' => (int) $review->workspace_id, 'workspace_id' => (int) $review->workspace_id,
'environment_review_id' => (int) $review->getKey(), 'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $review->evidence_snapshot_id, 'evidence_snapshot_id' => (int) $review->evidence_snapshot_id,
'initiated_by_user_id' => (int) $owner->getKey(), 'initiated_by_user_id' => (int) $owner->getKey(),
'fingerprint' => app(ReviewPackService::class)->computeFingerprintForReview($review, $packOptions),
'options' => $packOptions,
'file_path' => 'review-packs/spec387-ready-return.zip', 'file_path' => 'review-packs/spec387-ready-return.zip',
'file_disk' => 'exports', 'file_disk' => 'exports',
]); ]);
$review->forceFill([ $review->forceFill([
'status' => EnvironmentReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
'current_export_review_pack_id' => (int) $pack->getKey(), 'current_export_review_pack_id' => (int) $pack->getKey(),
])->save(); ])->save();
@ -358,6 +391,11 @@ function spec387ForceCurrentStep(
'proof_type' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? 'operation_run' : $step->proof_type, 'proof_type' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? 'operation_run' : $step->proof_type,
'proof_id' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : $step->proof_id, 'proof_id' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : $step->proof_id,
'proof_status' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? (string) $operationRun->outcome : $step->proof_status, 'proof_status' => $step->step_key === $stepKey->value && $operationRun instanceof OperationRun ? (string) $operationRun->outcome : $step->proof_status,
'metadata' => $step->step_key === $stepKey->value
? array_replace(is_array($step->metadata) ? $step->metadata : [], [
'readiness_fingerprint' => (string) $case->readiness_fingerprint,
])
: $step->metadata,
])->save(); ])->save();
} }
@ -377,6 +415,48 @@ function spec387ForceCurrentStep(
return $case->fresh('steps.operationRun'); return $case->fresh('steps.operationRun');
} }
function spec387DeleteStoredReport(ManagedEnvironment $tenant, string $reportType): void
{
StoredReport::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->where('report_type', $reportType)
->delete();
}
function spec387EnsureReadyStoredReport(EnvironmentReview $review, string $reportType): StoredReport
{
$report = StoredReport::query()
->where('workspace_id', (int) $review->workspace_id)
->where('managed_environment_id', (int) $review->managed_environment_id)
->where('report_type', $reportType)
->where('status', StoredReport::STATUS_READY)
->latest('id')
->first();
if ($report instanceof StoredReport) {
return $report;
}
$factory = $reportType === StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES
? StoredReport::factory()->entraAdminRoles(['roles' => []])
: StoredReport::factory()->permissionPosture([
'required_count' => 0,
'granted_count' => 0,
'permissions' => [],
]);
return $factory->create([
'workspace_id' => (int) $review->workspace_id,
'managed_environment_id' => (int) $review->managed_environment_id,
'report_type' => $reportType,
'status' => StoredReport::STATUS_READY,
'generated_at' => now()->addMinute(),
'created_at' => now()->addMinute(),
'updated_at' => now()->addMinute(),
]);
}
function spec387EnvironmentReviewHeaderActions(Testable $component): array function spec387EnvironmentReviewHeaderActions(Testable $component): array
{ {
$instance = $component->instance(); $instance = $component->instance();

View File

@ -1,11 +1,13 @@
<?php <?php
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentVerificationReport;
use App\Jobs\ProviderConnectionHealthCheckJob; use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
@ -97,6 +99,104 @@
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
}); });
it('does not dedupe or link a resolution-scoped active check from the normal connection check action', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('is_default', true)
->firstOrFail();
$resolutionRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'trigger' => 'review_publication_resolution',
'review_publication_resolution_case_id' => 388001,
'environment_review_id' => 388002,
],
]);
Livewire::test(ListProviderConnections::class)
->callTableAction('check_connection', $connection);
expect(OperationRun::query()
->where('managed_environment_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1);
$notifications = session('filament.notifications', []);
$lastNotification = collect($notifications)->last();
$actions = collect(is_array($lastNotification) ? ($lastNotification['actions'] ?? []) : []);
$encodedActions = json_encode($actions->all(), JSON_THROW_ON_ERROR);
expect($lastNotification['title'] ?? null)->toBe('Scope busy')
->and($lastNotification['body'] ?? null)->toBe('Another provider-backed operation is already running for this scope. Retry after it finishes.')
->and($actions->pluck('name')->all())->not->toContain('view_run')
->and($encodedActions)->not->toContain(OperationRunLinks::view($resolutionRun, $tenant));
Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class);
});
it('does not refresh operation activity for a non-disclosable resolution-scoped busy check from the verification widget', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('is_default', true)
->firstOrFail();
$resolutionRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'trigger' => 'review_publication_resolution',
'review_publication_resolution_case_id' => 388201,
'environment_review_id' => 388202,
],
]);
Livewire::test(ManagedEnvironmentVerificationReport::class, ['record' => $tenant])
->call('startVerification')
->assertNotDispatched(OpsUxBrowserEvents::RunEnqueued);
$notifications = session('filament.notifications', []);
$lastNotification = collect($notifications)->last();
$actions = collect(is_array($lastNotification) ? ($lastNotification['actions'] ?? []) : []);
$encodedActions = json_encode($actions->all(), JSON_THROW_ON_ERROR);
expect(OperationRun::query()
->where('managed_environment_id', $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1)
->and($lastNotification['title'] ?? null)->toBe('Scope busy')
->and($lastNotification['body'] ?? null)->toBe('Another provider-backed operation is already running for this scope. Retry after it finishes.')
->and($actions->pluck('name')->all())->not->toContain('view_run')
->and($encodedActions)->not->toContain(OperationRunLinks::tenantlessView($resolutionRun));
Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class);
});
it('disables connection check action for readonly users', function (): void { it('disables connection check action for readonly users', function (): void {
Queue::fake(); Queue::fake();

View File

@ -4,13 +4,16 @@
use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob; use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob; use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun; use App\Models\AuditLog;
use App\Models\ProviderConnection;
use App\Models\ManagedEnvironment; use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentOnboardingSession; use App\Models\ManagedEnvironmentOnboardingSession;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
@ -77,6 +80,100 @@
Bus::assertNotDispatched(ProviderConnectionHealthCheckJob::class); Bus::assertNotDispatched(ProviderConnectionHealthCheckJob::class);
}); });
it('does not disclose a resolution-scoped busy verification run in onboarding state audit or notifications', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user);
$tenantGuid = '30303030-3030-3030-3030-303030303030';
$component = Livewire::actingAs($user)->test(ManagedEnvironmentOnboardingWizard::class);
$component->call('identifyManagedEnvironment', [
'entra_tenant_id' => $tenantGuid,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Provider start disclosure test',
]);
$tenant = ManagedEnvironment::query()->forTenant($tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$resolutionRun = OperationRun::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'running',
'outcome' => 'pending',
'run_identity_hash' => sha1('resolution-verification-busy-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'trigger' => 'review_publication_resolution',
'review_publication_resolution_case_id' => 388101,
'environment_review_id' => 388102,
],
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
$session = ManagedEnvironmentOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('managed_environment_id', (int) $tenant->getKey())
->firstOrFail();
expect(OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->count())->toBe(1)
->and($session->state['verification_operation_run_id'] ?? null)->toBeNull();
$notificationActions = collect(session('filament.notifications', []))
->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null)
? $notification['actions']
: []);
$encodedActions = json_encode($notificationActions->all(), JSON_THROW_ON_ERROR);
expect($notificationActions->pluck('name')->all())->not->toContain('view_run')
->and($encodedActions)->not->toContain(OperationRunLinks::tenantlessView((int) $resolutionRun->getKey()));
$auditLogs = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('action', [
AuditActionId::ManagedEnvironmentOnboardingVerificationStart->value,
AuditActionId::ManagedEnvironmentOnboardingVerificationPersisted->value,
])
->get();
expect($auditLogs)->not->toBeEmpty();
$auditLogs->each(function (AuditLog $auditLog): void {
expect($auditLog->operation_run_id)->toBeNull()
->and($auditLog->resource_type)->not->toBe('operation_run')
->and($auditLog->metadata['operation_run_id'] ?? null)->toBeNull();
});
Bus::assertNotDispatched(ProviderConnectionHealthCheckJob::class);
});
it('serializes onboarding bootstrap so only one selected provider-backed action starts at a time', function (): void { it('serializes onboarding bootstrap so only one selected provider-backed action starts at a time', function (): void {
Bus::fake(); Bus::fake();
@ -167,3 +264,116 @@
Bus::assertDispatchedTimes(ProviderInventorySyncJob::class, 1); Bus::assertDispatchedTimes(ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(ProviderComplianceSnapshotJob::class, 1); Bus::assertDispatchedTimes(ProviderComplianceSnapshotJob::class, 1);
}); });
it('does not disclose a resolution-scoped busy bootstrap run in onboarding state audit or notifications', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user);
$tenantGuid = '40404040-4040-4040-4040-404040404040';
$component = Livewire::actingAs($user)->test(ManagedEnvironmentOnboardingWizard::class);
$component->call('identifyManagedEnvironment', [
'entra_tenant_id' => $tenantGuid,
'environment' => 'prod',
'name' => 'Acme',
'primary_domain' => 'acme.example',
'notes' => 'Bootstrap disclosure test',
]);
$tenant = ManagedEnvironment::query()->forTenant($tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
spec283SeedRequirementRows($tenant, ['permissions.intune_configuration', 'permissions.intune_apps']);
$verificationRun = OperationRun::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('bootstrap-disclosure-verification-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = ManagedEnvironmentOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('managed_environment_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$resolutionRun = OperationRun::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory.sync',
'status' => 'running',
'outcome' => 'pending',
'run_identity_hash' => sha1('resolution-bootstrap-busy-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'trigger' => 'review_publication_resolution',
'review_publication_resolution_case_id' => 388201,
'environment_review_id' => 388202,
],
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startBootstrap', ['inventory.sync']);
$session->refresh();
expect(OperationRun::query()
->where('managed_environment_id', (int) $tenant->getKey())
->where('type', 'inventory.sync')
->count())->toBe(1)
->and($session->state['bootstrap_operation_runs'] ?? [])->toBe([]);
$notificationActions = collect(session('filament.notifications', []))
->flatMap(static fn (array $notification): array => is_array($notification['actions'] ?? null)
? $notification['actions']
: []);
$encodedActions = json_encode($notificationActions->all(), JSON_THROW_ON_ERROR);
expect($notificationActions->pluck('name')->all())->not->toContain('view_run')
->and($encodedActions)->not->toContain(OperationRunLinks::tenantlessView((int) $resolutionRun->getKey()));
$auditLog = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::ManagedEnvironmentOnboardingBootstrapStarted->value)
->latest('id')
->firstOrFail();
expect($auditLog->operation_run_id)->toBeNull()
->and($auditLog->metadata['operation_run_id'] ?? null)->toBeNull();
Bus::assertNotDispatched(ProviderInventorySyncJob::class);
});

View File

@ -1,10 +1,10 @@
<?php <?php
use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentPermission;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\ProviderCredential; use App\Models\ProviderCredential;
use App\Models\ManagedEnvironmentPermission;
use App\Models\ManagedEnvironment;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -174,6 +174,62 @@ function providerOperationStartGateSeedRequirementRows(ManagedEnvironment $tenan
expect(OperationRun::query()->where('managed_environment_id', $tenant->getKey())->count())->toBe(1); expect(OperationRun::query()->where('managed_environment_id', $tenant->getKey())->count())->toBe(1);
}); });
it('keeps provider scope busy when a newer same-type run belongs to another resolution context', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'managed_environment_id' => $tenant->getKey(),
'consent_status' => 'granted',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$blocking = OperationRun::factory()->create([
'managed_environment_id' => $tenant->getKey(),
'type' => 'inventory.sync',
'status' => 'running',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$otherResolutionRun = OperationRun::factory()->create([
'managed_environment_id' => $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'trigger' => 'review_publication_resolution',
'review_publication_resolution_case_id' => 123,
'environment_review_id' => 456,
],
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function () use (&$dispatched): void {
$dispatched++;
},
extraContext: [
'trigger' => 'review_publication_resolution',
'review_publication_resolution_case_id' => 789,
'environment_review_id' => 987,
],
);
expect($dispatched)->toBe(0);
expect($result->status)->toBe('scope_busy');
expect($result->run->getKey())->toBe($otherResolutionRun->getKey());
expect($result->canDiscloseRun)->toBeFalse();
expect(OperationRun::query()->where('managed_environment_id', $tenant->getKey())->count())->toBe(2);
expect($blocking->fresh()?->status)->toBe('running');
expect($otherResolutionRun->fresh()?->status)->toBe('running');
});
it('returns blocked and stores reason metadata when no default connection exists', function (): void { it('returns blocked and stores reason metadata when no default connection exists', function (): void {
$tenant = ManagedEnvironment::factory()->create(); $tenant = ManagedEnvironment::factory()->create();

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\EnvironmentReview;
use App\Support\ReviewPublicationResolution\ResolutionProofCurrentness;
use App\Support\ReviewPublicationResolution\ResolutionProofEvaluation;
use App\Support\ReviewPublicationResolution\ResolutionProofReference;
use App\Support\ReviewPublicationResolution\ResolutionProofStatus;
use App\Support\ReviewPublicationResolution\ResolutionProofUsability;
use App\Support\ReviewPublicationResolution\ResolutionProofVisibility;
use App\Support\ReviewPublicationResolution\ReviewPublicationResolutionStepKey;
it('fails missing proof closed with safe summary only', function (): void {
$review = new EnvironmentReview(['id' => 123]);
$payload = ResolutionProofEvaluation::missing(
ReviewPublicationResolutionStepKey::CompleteRequiredReports,
$review,
)->toStepPayload();
expect($payload['proof_type'])->toBeNull()
->and($payload['proof_id'])->toBeNull()
->and($payload['proof_status'])->toBe(ResolutionProofStatus::Missing->value)
->and($payload['proof_currentness'])->toBe(ResolutionProofCurrentness::Unknown->value)
->and($payload['proof_usability'])->toBe(ResolutionProofUsability::NotUsable->value)
->and($payload['proof_visibility'])->toBe(ResolutionProofVisibility::OperatorVisible->value);
});
it('sanitizes unsafe proof summaries before persistence or audit', function (): void {
$evaluation = new ResolutionProofEvaluation(
actionKey: ReviewPublicationResolutionStepKey::CompleteRequiredReports,
subjectType: EnvironmentReview::class,
subjectId: 321,
status: ResolutionProofStatus::Available,
currentness: ResolutionProofCurrentness::Current,
usability: ResolutionProofUsability::Usable,
visibility: ResolutionProofVisibility::OperatorVisible,
reasonCode: 'proof.required_reports_current',
reference: new ResolutionProofReference('stored_report', 55, 'ready', now()),
evaluatedAt: now(),
safeSummary: [
'label' => 'Current proof',
'message' => 'ClientException: Graph response body says Access token expired.',
'raw_graph_response' => ['access_token' => 'abc'],
'exception_message' => 'secret-token=abc123 rawGraphPayload {"access_token":"xyz"}',
'nested' => [
'safe_reason' => 'Report was evaluated.',
'error' => 'Authorization: Bearer abc.def.ghi',
'payload' => ['full_report' => true],
],
],
);
$payload = $evaluation->toStepPayload();
$encoded = json_encode($payload['proof_summary'], JSON_THROW_ON_ERROR);
expect($payload['proof_summary'])->toHaveKey('label')
->and($payload['proof_summary'])->toHaveKey('nested')
->and($encoded)->not->toContain('raw_graph_response', 'access_token', 'Access token', 'ClientException', 'Authorization', 'Bearer', 'secret-token', 'rawGraphPayload', 'full_report');
});
it('only lets current usable operator-visible proof complete a step', function (
ResolutionProofCurrentness $currentness,
ResolutionProofUsability $usability,
ResolutionProofVisibility $visibility,
bool $canComplete,
): void {
$evaluation = new ResolutionProofEvaluation(
actionKey: ReviewPublicationResolutionStepKey::GenerateReviewPack,
subjectType: EnvironmentReview::class,
subjectId: 123,
status: ResolutionProofStatus::Available,
currentness: $currentness,
usability: $usability,
visibility: $visibility,
reasonCode: 'proof.test',
);
expect($evaluation->canCompleteStep())->toBe($canComplete);
})->with([
'current usable visible' => [
ResolutionProofCurrentness::Current,
ResolutionProofUsability::Usable,
ResolutionProofVisibility::OperatorVisible,
true,
],
'unknown usable visible' => [
ResolutionProofCurrentness::Unknown,
ResolutionProofUsability::Usable,
ResolutionProofVisibility::OperatorVisible,
false,
],
'current inspection visible' => [
ResolutionProofCurrentness::Current,
ResolutionProofUsability::InspectionOnly,
ResolutionProofVisibility::OperatorVisible,
false,
],
'current usable hidden' => [
ResolutionProofCurrentness::Current,
ResolutionProofUsability::Usable,
ResolutionProofVisibility::Hidden,
false,
],
'current usable operator-limited' => [
ResolutionProofCurrentness::Current,
ResolutionProofUsability::Usable,
ResolutionProofVisibility::OperatorLimited,
false,
],
]);
it('requires available proof status before completing a step', function (
ResolutionProofStatus $status,
bool $canComplete,
): void {
$evaluation = new ResolutionProofEvaluation(
actionKey: ReviewPublicationResolutionStepKey::GenerateReviewPack,
subjectType: EnvironmentReview::class,
subjectId: 123,
status: $status,
currentness: ResolutionProofCurrentness::Current,
usability: ResolutionProofUsability::Usable,
visibility: ResolutionProofVisibility::OperatorVisible,
reasonCode: 'proof.test',
);
expect($evaluation->canCompleteStep())->toBe($canComplete);
})->with([
'available proof completes' => [
ResolutionProofStatus::Available,
true,
],
'running proof does not complete' => [
ResolutionProofStatus::Running,
false,
],
'succeeded operation remains inspection-only' => [
ResolutionProofStatus::Succeeded,
false,
],
'failed proof does not complete' => [
ResolutionProofStatus::Failed,
false,
],
]);

View File

@ -8,7 +8,7 @@ # UI-101 Review Publication Resolution
| Archetype | Reviews | | Archetype | Reviews |
| Design depth | Strategic Surface | | Design depth | Strategic Surface |
| Repo truth | browser-verified route; feature-tested | | Repo truth | browser-verified route; feature-tested |
| Screenshot | Spec 386 baseline: [desktop](../../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/01-resolution-page-desktop.png), [mobile](../../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/02-resolution-page-mobile.png). Spec 387 hardening: [detail CTA](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/01-review-detail-blocked-cta.png), [decision desktop](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/02-resolution-decision-desktop.png), [confirmation modal](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/03-confirmation-modal.png), [proof expanded](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/04-technical-proof-expanded.png), [mobile](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/05-resolution-decision-mobile.png), [customer boundary](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/06-customer-no-leakage.png), [readonly](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/07-readonly-inspection.png) | | Screenshot | Spec 386 baseline: [desktop](../../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/01-resolution-page-desktop.png), [mobile](../../../specs/386-review-publication-resolution-workflow-v1/artifacts/screenshots/02-resolution-page-mobile.png). Spec 387 hardening: [detail CTA](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/01-review-detail-blocked-cta.png), [decision desktop](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/02-resolution-decision-desktop.png), [confirmation modal](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/03-confirmation-modal.png), [proof expanded](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/04-technical-proof-expanded.png), [mobile](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/05-resolution-decision-mobile.png), [customer boundary](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/06-customer-no-leakage.png), [readonly](../../../specs/387-review-publication-resolution-decision-ux-v1/artifacts/screenshots/07-readonly-inspection.png). Spec 388 proof-state evidence: [artifact note](../../../specs/388-resolution-proof-currentness-contract-v1/artifacts/screenshots/proof-state-evidence.md) |
| Browser status | Browser smoke passed for blocked-review CTA handoff, decision-first resolution page rendering, compact preparation progress, action-specific confirmation copy, technical disclosure, readonly inspection, customer boundary non-leakage, and narrow viewport readability. | | Browser status | Browser smoke passed for blocked-review CTA handoff, decision-first resolution page rendering, compact preparation progress, action-specific confirmation copy, technical disclosure, readonly inspection, customer boundary non-leakage, and narrow viewport readability. |
## First Five Seconds ## First Five Seconds
@ -28,6 +28,8 @@ ## Information Inventory
Default content shows publication blocked state, required reports, compact preparation progress, the next safe action, what happens after the action, and the return-to-review action. Running and failed operation states use normalized operator copy. Case status, proof links, operation links, and implementation terms such as report-backed evidence are technical details behind disclosure or normalized out of operator copy. Default content shows publication blocked state, required reports, compact preparation progress, the next safe action, what happens after the action, and the return-to-review action. Running and failed operation states use normalized operator copy. Case status, proof links, operation links, and implementation terms such as report-backed evidence are technical details behind disclosure or normalized out of operator copy.
Spec 388 adds normalized proof labels inside the existing collapsed technical disclosure: current proof, outdated proof, superseded by newer result, operation running, action failed, proof missing, not available with your permissions, and proof cannot be verified. These labels do not add a new page archetype, route, global search surface, navigation item, or competing primary action.
## Dangerous Actions ## Dangerous Actions
`Cancel resolution` is destructive to the local resolution case only, is demoted into the grouped More action, and requires confirmation plus `ENVIRONMENT_REVIEW_MANAGE`. Step execution is high-impact, uses action-specific confirmation heading/body/submit copy, hides duplicate start actions while a linked operation is running, and delegates to the owning source action: provider verification or Entra scan for required reports, evidence snapshot generation for evidence, review refresh for composition, review-pack generation for export proof, and a non-publishing return-to-review completion step. `Cancel resolution` is destructive to the local resolution case only, is demoted into the grouped More action, and requires confirmation plus `ENVIRONMENT_REVIEW_MANAGE`. Step execution is high-impact, uses action-specific confirmation heading/body/submit copy, hides duplicate start actions while a linked operation is running, and delegates to the owning source action: provider verification or Entra scan for required reports, evidence snapshot generation for evidence, review refresh for composition, review-pack generation for export proof, and a non-publishing return-to-review completion step.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 238 KiB

View File

@ -0,0 +1,25 @@
# Spec 388 Proof-State Evidence
Spec 388 changes proof-state labels inside the existing Review Publication Resolution technical disclosure. It does not add a route, navigation entry, panel provider, primary action, or customer-facing proof surface.
## Browser Smoke Decision
- No dedicated `tests/Browser/Spec388ReviewPublicationProofCurrentnessSmokeTest.php` was added.
- The existing page-family smoke `tests/Browser/Spec387ReviewPublicationResolutionDecisionUxTest.php` was reused for real-browser coverage of the resolution page shell, decision-first layout, technical disclosure family, and readonly inspection mode.
- Proof-state permutations are covered by deterministic Livewire/feature tests in `tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`.
## Covered States
- Current proof: Livewire assertion sees `Current proof` on the resolution page.
- Superseded failed proof: Livewire assertion sees `Superseded by newer result` after a newer `StoredReport` supersedes an old failed operation.
- Stale evidence proof: feature assertion keeps `collect_evidence_snapshot` actionable with `proof.evidence_stale`.
- Stale review-pack proof: feature assertion keeps `generate_review_pack` actionable with `proof.review_pack_stale`.
- Successful operation without artifact: feature assertion keeps the proof `inspection_only`.
- Customer boundary: customer workspace regression asserts no proof reason codes, step keys, OperationRun internals, or `proof_currentness` leak.
- Readonly/browser boundary: existing Spec 387 browser smoke passed readonly inspection copy on the same resolution page family.
## Validation Commands
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReviewPublicationResolution tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec387ReviewPublicationResolutionDecisionUxTest.php`

View File

@ -0,0 +1,64 @@
# Specification Quality Checklist: Spec 388 - Resolution Proof & Currentness Contract v1
**Purpose**: Validate specification completeness and preparation readiness before implementation
**Created**: 2026-06-19
**Feature**: `specs/388-resolution-proof-currentness-contract-v1/spec.md`
## Candidate Selection and Guardrails
- [x] CHK001 The candidate source is the direct user-provided Spec 388 draft attachment.
- [x] CHK002 The active auto-prep queue in `docs/product/spec-candidates.md` is acknowledged as empty/no-safe-automatic-target, and this spec is treated as manual user-provided promotion.
- [x] CHK003 No existing `specs/388-*` package or `*388*` branch existed before Spec Kit scaffold.
- [x] CHK004 Completed or implemented related specs 385, 386, and 387 are treated as dependency context only and are not rewritten.
- [x] CHK005 Close alternatives are deferred instead of hidden inside the primary scope.
- [x] CHK006 Candidate Selection Gate passes with scope narrowed to Review Publication Resolution proof/currentness V1.
## Scope and Constitution Fit
- [x] CHK007 The spec includes the mandatory Spec Candidate Check with approval class, score, red flags, and decision.
- [x] CHK008 The spec includes proportionality review for new DTO/value objects and proof state families.
- [x] CHK009 The plan keeps source truth in existing OperationRun, StoredReport, EvidenceSnapshot, EnvironmentReview, ReviewPack, and ReviewPublicationResolution records.
- [x] CHK010 No new proof persistence, migration, global registry, workflow engine, broad adapter package, Resource, route family, panel provider, or global search surface is approved by default.
- [x] CHK011 Restore, Provider Onboarding, Governance Inbox, Report Delivery, Cross-Tenant Promotion, and AI proof adapters are explicit follow-up candidates only.
- [x] CHK012 Existing workspace/managed-environment isolation and deny-as-not-found expectations are carried into proof evaluation.
## Requirements Quality
- [x] CHK013 Problem statement, business value, users, user stories, functional requirements, non-functional requirements, edge cases, out-of-scope boundaries, success criteria, assumptions, and open questions are present.
- [x] CHK014 Requirements define behavior for current, stale, superseded, missing, running, failed, inaccessible, unknown, usable, not usable, and inspection-only proof.
- [x] CHK015 Requirements state that successful OperationRun alone cannot complete artifact-backed steps without current artifact proof.
- [x] CHK016 Requirements state that newer current artifact proof supersedes older failed/running proof where appropriate.
- [x] CHK017 Requirements state that safe summaries exclude raw provider payloads, Graph responses, tokens, secrets, full report/evidence content, and raw exception messages.
- [x] CHK018 No `[NEEDS CLARIFICATION]` markers remain.
## UI, Filament, and Disclosure
- [x] CHK019 UI Surface Impact is explicit and limited to existing proof/state presentation plus customer no-leakage regression.
- [x] CHK020 UI/Productization Coverage reuses UI-101, UI-040, and UI-006 context without inventing a new route taxonomy.
- [x] CHK021 Technical proof remains collapsed/secondary by default.
- [x] CHK022 Customer-facing surfaces are explicitly forbidden from showing internal proof mechanics.
- [x] CHK023 Filament v5 / Livewire v4 compliance is stated.
- [x] CHK024 Laravel 12 panel provider location is stated as `apps/platform/bootstrap/providers.php`, with no provider change planned.
- [x] CHK025 No globally searchable Resource is added.
- [x] CHK026 No new destructive action is approved; existing high-impact actions remain confirmation-, authorization-, audit-, and test-gated.
- [x] CHK027 Asset strategy is stated as no new registered Filament assets expected.
## Plan and Task Readiness
- [x] CHK028 `spec.md`, `plan.md`, `tasks.md`, and this checklist exist.
- [x] CHK029 The plan identifies likely affected repo surfaces and avoids contradicting existing architecture.
- [x] CHK030 Tasks are ordered, small, verifiable, and include tests before implementation.
- [x] CHK031 Tasks include unit, feature, RBAC/scope, customer no-leakage, and optional browser/screenshot coverage.
- [x] CHK032 Validation commands use Sail-first paths and include focused Spec 388, Spec 386/387 regression, optional browser, Pint, and `git diff --check`.
- [x] CHK033 No full-suite success is implied by the preparation artifacts.
## Spec Readiness Gate
- [x] CHK034 Preparation artifacts are consistent on scope: Review Publication Resolution proof/currentness only.
- [x] CHK035 No task expands beyond `spec.md` or `plan.md`.
- [x] CHK036 Open questions are non-blocking.
- [x] CHK037 Spec Readiness Gate passes for a later implementation loop.
## Notes
- This checklist validates preparation artifacts only. It does not claim implementation, tests, browser smoke, or runtime behavior has been completed.

View File

@ -0,0 +1,272 @@
# Implementation Plan: Spec 388 - Resolution Proof & Currentness Contract v1
**Branch**: `388-resolution-proof-currentness-contract-v1` | **Date**: 2026-06-19 | **Spec**: `specs/388-resolution-proof-currentness-contract-v1/spec.md`
**Input**: Feature specification from `/specs/388-resolution-proof-currentness-contract-v1/spec.md`
## Summary
Harden the existing Spec 386/387 Review Publication Resolution workflow with a bounded proof/currentness contract. Replace or extend the current shallow proof resolver output so step planning and proof disclosure can distinguish current, stale, superseded, missing, running, failed, inaccessible, and usable proof. Keep source truth in existing domain objects, avoid new persistence by default, and do not create a generic proof registry or broad adapter framework.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52.0
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1
**Storage**: PostgreSQL via Sail; no schema migration approved by default
**Testing**: Pest 4 unit, feature, Filament/Livewire, optional focused browser smoke
**Validation Lanes**: fast-feedback + confidence; browser if rendered proof states materially change; PostgreSQL only if schema changes after spec update
**Target Platform**: Laravel monolith under `apps/platform`
**Project Type**: web application / Filament admin panel
**Performance Goals**: deterministic local DB evaluation; no Graph/provider calls during UI render; avoid N+1 proof lookup
**Constraints**: no generic workflow engine, no global proof adapter registry, no new proof source-of-truth table, no auto-publish, no customer proof explorer, no new panel/provider/route family
**Scale/Scope**: one existing subject-owned Review Publication Resolution workflow and related proof display
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed state/proof presentation on existing surfaces.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
- `App\Filament\Resources\EnvironmentReviewResource\Pages\ResolveReviewPublication`
- `apps/platform/resources/views/filament/resources/environment-review-resource/pages/resolve-review-publication.blade.php`
- existing Environment Review detail only if it consumes proof summary changes
- existing Customer Review Workspace negative no-leakage tests only
- **No-impact class, if applicable**: N/A.
- **Native vs custom classification summary**: existing Filament page + existing Blade disclosure + native Filament badges/actions.
- **Shared-family relevance**: proof/status messaging, OperationRun links, artifact links, readiness labels, customer-safe disclosure.
- **State layers in scope**: page, proof disclosure, current step state.
- **Audience modes in scope**: operator-MSP, manager, readonly inspector, support-platform; customer/read-only only for no-leakage regression.
- **Decision/diagnostic/raw hierarchy plan**: decision-first proof label and next action; diagnostics in collapsed technical proof; raw/support payloads absent.
- **Raw/support gating plan**: no raw provider/report/evidence content; safe reason code and safe summary only.
- **One-primary-action / duplicate-truth control**: proof state informs the one current step action; technical proof links stay secondary.
- **Handling modes by drift class or surface**: review-mandatory for proof-state display and no-leakage; report-only for UI audit files unless rendered structure materially changes.
- **Repository-signal treatment**: UI-101 and Spec 387 screenshot evidence are context; update only if visible state/copy structure changes.
- **Special surface test profiles**: workflow-detail surface with proof-state smoke; standard-native-filament relief for unchanged action placement.
- **Required tests or manual smoke**: proof state mapping, stale/superseded display, readonly limited proof, customer no-leakage.
- **Exception path and spread control**: no generic proof UI framework; local/bounded mapper only.
- **Active feature PR close-out entry**: Proof Currentness / No Generic Registry / Smoke Coverage.
- **UI/Productization coverage decision**: update `docs/ui-ux-enterprise-audit/page-reports/ui-101-review-publication-resolution.md` only when visible structure/copy changes; otherwise record no-new-route/no-archetype rationale.
- **Coverage artifacts to update**: likely none or UI-101 only; route inventory/design matrix should not change.
- **No-impact rationale**: N/A.
- **Navigation / Filament provider-panel handling**: no provider registration or panel path changes; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
- **Screenshot or page-report need**: yes only for representative changed proof states; Feature/Livewire evidence may substitute for hard-to-fixture states with an artifact note.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes, bounded to one workflow.
- **Systems touched**:
- `App\Support\ReviewPublicationResolution\ReviewPublicationProofResolver`
- `ReviewPublicationReadinessEvaluator`
- `ReviewPublicationStepPlanner`
- `ReviewPublicationResolutionService`
- `ResolveReviewPublication`
- `ReviewPublicationResolutionCase` / `ReviewPublicationResolutionStep`
- existing OperationRun link/actionability/reconciliation classes as context only
- `EvidenceSnapshot`, `StoredReport`, `EnvironmentReview`, `ReviewPack`
- **Shared abstractions reused**:
- existing review-publication resolver/evaluator/planner/service
- existing readiness fingerprint from Spec 386
- existing OperationRun UX/link helpers
- existing policies, `UiEnforcement`, and Filament components
- **New abstraction introduced? why?**: yes, bounded proof evaluation DTO/value-object and enum/value set are expected because current arrays cannot encode currentness/usability/visibility safely.
- **Why the existing abstraction was sufficient or insufficient**: existing workflow ownership is sufficient; existing proof array fields are insufficient to prevent stale, superseded, inaccessible, or diagnostics-only proof from driving completion.
- **Bounded deviation / spread control**: keep new classes under existing `App\Support\ReviewPublicationResolution` or a clearly review-publication-owned child namespace. Do not introduce global `ProofRegistry`, `GlobalProofAdapterRegistry`, `WorkflowProofEngine`, or broad `Support/ResolutionProof` package without updating spec/plan/tasks first.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes for classification/display only; no new start/completion behavior.
- **Central contract reused**: existing OperationRun service lifecycle, OperationRun links, Spec 386 step execution behavior, and terminal notification path.
- **Delegated UX behaviors**: queued toast, run link, artifact link, run-enqueued event, dedupe messaging, terminal notification, and safe URL resolution remain delegated to existing paths.
- **Surface-owned behavior kept local**: deciding whether an existing run is current, superseded, failed, running, diagnostics-only, or not enough without artifact proof.
- **Queued DB-notification policy**: unchanged.
- **Terminal notification path**: unchanged.
- **Exception path**: none.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes, platform proof vocabulary over provider-backed artifacts.
- **Provider-owned seams**: raw Microsoft/Graph identifiers, provider payloads, provider-specific report internals, credential/permission mechanics.
- **Platform-core seams**: proof, artifact, operation, currentness, usability, visibility, safe summary, readiness fingerprint.
- **Neutral platform terms / contracts preserved**: proof, currentness, artifact, operation, subject, action, workspace, managed environment, safe summary.
- **Retained provider-specific semantics and why**: required report keys such as permission posture and Entra admin roles remain existing review requirements but stay out of customer-safe proof mechanics.
- **Bounded extraction or follow-up path**: follow-up-spec only when Restore, Provider, Governance Inbox, or Report Delivery has a real proof/currentness consumer.
## Constitution Check
- Inventory-first: no inventory truth changes.
- Read/write separation: no external write path is added; existing step actions keep Spec 386/387 confirmation, authorization, audit, and tests.
- Graph contract path: no Graph calls added; proof evaluation consumes existing DB-backed artifacts.
- Deterministic capabilities: proof visibility uses existing policies/capabilities; no raw capability strings unless existing pattern requires.
- RBAC-UX: workspace/environment access remains deny-as-not-found; missing capability remains safe limited/forbidden state.
- Workspace isolation: proof candidates must resolve through workspace scope before use/display.
- Tenant isolation: managed-environment proof must match environment-scoped subject/action.
- Run observability: OperationRun remains execution truth; no new operation type or lifecycle transition.
- OperationRun start UX: no local queued toast/link/event/start-state composition.
- Ops-UX lifecycle: no direct `OperationRun.status` / `OperationRun.outcome` mutation outside existing services.
- Data minimization: no raw provider payload, raw report content, full evidence JSON, token, secret, or raw exception in proof summary/audit/UI.
- Test governance: Unit/Feature/Filament coverage first; browser only for representative UI state smoke.
- Proportionality: new proof DTO/enums are justified by false-blocker/false-confidence risk in current workflow; no new persistence by default.
- No premature abstraction: no registry or broad adapter framework; review-publication-owned classes only.
- Persisted truth: existing domain objects and resolution steps remain truth; proof evaluation is derived.
- Behavioral state: new proof states affect step completion, actionability, visibility, audit metadata, or operator next action.
- UI semantics: proof labels are direct mappings from normalized proof result to existing UI; no cross-domain presentation framework.
- Shared pattern first: existing resolver/evaluator/planner/page and OperationRun helpers are extended.
- Provider boundary: provider-specific details stay internal/diagnostic and are sanitized.
- V1 explicitness / few layers: small explicit value objects/helpers; no generalized platform package.
- Badge semantics: existing Filament/shared badge semantics; no ad-hoc independent color system.
- Filament-native UI: existing Filament page and Blade disclosure remain; no new custom UI system.
- UI/Productization coverage: existing UI-101 coverage reused/updated proportionally.
- Filament v5 / Livewire v4: implementation must remain Livewire 4.1.4 compatible and avoid Livewire v3 APIs.
- Panel provider registration: no panel provider changes; Laravel 12 providers remain in `apps/platform/bootstrap/providers.php`.
- Global search: no Resource is added; no proof evaluation global search surface is introduced.
- Destructive/high-impact actions: no new action approved. Existing step/cancel actions remain `->action(...)`, `->requiresConfirmation()`, authorized, audited, and tested.
- Asset strategy: no registered Filament assets expected; no `filament:assets` deploy change unless implementation unexpectedly registers assets and updates this plan first.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for pure proof evaluation and fingerprint helpers; Feature for planner/service/currentness/RBAC/customer leakage; Filament/Livewire for proof-state display and action state; Browser for representative visible proof-state smoke if UI changes.
- **Affected validation lanes**: fast-feedback, confidence, optional browser.
- **Why this lane mix is the narrowest sufficient proof**: the risk is deterministic evaluation and safe workflow display over existing DB-backed records, not new provider execution or broad surface discovery.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReviewPublicationResolution tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec388ReviewPublicationProofCurrentnessSmokeTest.php` if a browser file is added
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `git diff --check`
- **Fixture / helper / factory / seed / context cost risks**: reuse existing Spec 386/387 fixtures and `apps/platform/tests/Pest.php`; avoid global setup defaults or broad provider context.
- **Expensive defaults or shared helper growth introduced?**: no by default.
- **Heavy-family additions, promotions, or visibility changes**: none planned.
- **Surface-class relief / special coverage rule**: workflow-detail surface; browser optional for representative proof states; no full UI audit/browser matrix.
- **Closing validation and reviewer handoff**: verify proof states affect behavior, no generic framework/persistence, no customer leakage, no raw payloads, and no local OperationRun lifecycle rewrites.
- **Budget / baseline / trend follow-up**: none expected; document-in-feature if browser fixture scope grows materially.
- **Review-stop questions**: Did stale proof fail closed? Did newer current artifact supersede old failure? Did successful run require artifact proof? Did visibility enforce RBAC? Did customer output stay clean?
- **Escalation path**: document-in-feature for bounded fixture gaps; follow-up-spec for cross-domain proof consumers.
- **Active feature PR close-out entry**: Proof Currentness / No Generic Registry / Smoke Coverage.
- **Why no dedicated follow-up spec is needed**: this is the dedicated review-publication proof-currentness hardening slice; broader adapters are explicitly deferred.
## Project Structure
### Documentation (this feature)
```text
specs/388-resolution-proof-currentness-contract-v1/
+-- checklists/
| +-- requirements.md
+-- plan.md
+-- spec.md
+-- tasks.md
```
### Source Code (repository root)
```text
apps/platform/app/Support/ReviewPublicationResolution/
+-- ReviewPublicationProofResolver.php
+-- ReviewPublicationReadinessEvaluator.php
+-- ReviewPublicationStepPlanner.php
+-- ReviewPublicationResolutionService.php
+-- ReviewPublicationResolutionStepAuthorizer.php
+-- ReviewPublicationResolutionStepKey.php
+-- ReviewPublicationResolutionStepStatus.php
+-- ResolutionProofEvaluation.php
+-- ResolutionProofReference.php
+-- ResolutionProofStatus.php
+-- ResolutionProofCurrentness.php
+-- ResolutionProofUsability.php
+-- ResolutionProofVisibility.php
apps/platform/app/Models/
+-- ReviewPublicationResolutionCase.php
+-- ReviewPublicationResolutionStep.php
+-- EnvironmentReview.php
+-- EvidenceSnapshot.php
+-- StoredReport.php
+-- ReviewPack.php
+-- OperationRun.php
apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/
+-- ResolveReviewPublication.php
+-- ViewEnvironmentReview.php
apps/platform/resources/views/filament/resources/environment-review-resource/pages/
+-- resolve-review-publication.blade.php
apps/platform/tests/Unit/Support/ReviewPublicationResolution/
+-- ResolutionProofEvaluationTest.php
+-- ReviewPublicationProofResolverTest.php
apps/platform/tests/Feature/EnvironmentReview/
+-- Spec386ReviewPublicationResolutionWorkflowTest.php
+-- Spec387ReviewPublicationResolutionDecisionUxTest.php
+-- Spec388ReviewPublicationProofCurrentnessTest.php
apps/platform/tests/Browser/
+-- Spec388ReviewPublicationProofCurrentnessSmokeTest.php (only if browser coverage is added)
docs/ui-ux-enterprise-audit/page-reports/
+-- ui-101-review-publication-resolution.md (only if visible structure/copy materially changes)
```
**Structure Decision**: Use existing Laravel/Filament app structure and existing `App\Support\ReviewPublicationResolution` ownership. Do not create a new base package, registry, global adapter system, Resource, navigation entry, panel provider, or standalone readiness fingerprint abstraction. Fingerprint work extends the existing readiness evaluator/helper behavior from Spec 386 unless spec/plan/tasks are updated first.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| New proof state/value set | Current workflow needs distinct current/stale/superseded/visibility/usability behavior to avoid false completion/blockers | Existing `proof_status` string cannot govern currentness, actor visibility, or artifact-backed completion safely |
| New DTO/value object | Step planner/page/audit need one safe shape rather than ad hoc arrays | Adding labels to current arrays would keep logic duplicated and untestable |
## Proportionality Review
- **Current operator problem**: stale failed runs, stale artifacts, inaccessible proof, and successful runs without artifacts can mislead resolution workflow decisions.
- **Existing structure is insufficient because**: `ReviewPublicationProofResolver::proofFor()` returns only `proof_type`, `proof_id`, `proof_status`, and `operation_run_id`; it cannot represent currentness, supersession, usability, or actor-safe visibility.
- **Narrowest correct implementation**: evaluate only Review Publication Resolution step proof and use existing readiness fingerprint/artifact records.
- **Ownership cost created**: maintain enum/value mapping, safe-summary rules, resolver tests, and UI/audit mapping.
- **Alternative intentionally rejected**: local UI-only wording changes; they would not prevent incorrect step completion or false blockers.
- **Release truth**: current-release trust hardening for an implemented workflow.
## Domain / Model Implications
- Existing models remain source truth.
- Proof evaluation is derived; do not add a table.
- Existing `review_publication_resolution_steps` proof fields may continue storing safe references. If implementation proves `proof_currentness`, `proof_usability`, or `proof_evaluated_at` must be persisted, update spec/plan/tasks first and add a reversible PostgreSQL-safe migration.
- Prefer safe timestamps/fingerprints already present on EnvironmentReview, EvidenceSnapshot, StoredReport, ReviewPack, and readiness payloads.
## UI / Filament Implications
- Existing `ResolveReviewPublication` stays the decision surface.
- Technical proof remains collapsed/secondary by default.
- Customer-facing surfaces display only safe availability states.
- No new Resource, global search, navigation, panel provider, or assets.
- Livewire v4.0+ compliance is mandatory; no Livewire v3 references or APIs.
## Audit / Observability Implications
- Proof evaluation itself should not audit on every render.
- Audit proof metadata only when a proof state is persisted, linked, superseded, rejected for step completion, or used for step completion through existing case/step transitions.
- Audit metadata must use normalized fields and safe summaries only.
- Add or update focused test assertions for proof-driven transition audit metadata when safe proof fields are attached, including proof status/currentness/usability/visibility/reason fields and exclusion of raw payloads or unsafe details.
- OperationRun remains execution truth; this spec does not change operation lifecycle transitions.
## Data / Migration Implications
- Default: no migration.
- If a migration becomes necessary, it must be reversible, PostgreSQL-safe, and limited to existing review-publication resolution tables.
- No raw payload columns, no generic polymorphic proof table, and no cross-domain proof persistence.
## Implementation Phases
1. Confirm repo truth and existing proof/currentness inputs in Spec 386/387 implementation.
2. Add failing tests for proof evaluation states and step-planner outcomes.
3. Introduce bounded proof evaluation DTO/enums or equivalent values under existing review-publication ownership.
4. Update proof resolver and step planner/service to consume currentness/usability/visibility.
5. Update proof disclosure UI only where needed.
6. Add RBAC/customer non-leakage tests and representative browser evidence if visible proof states change.
7. Run focused validation and record close-out.
## Risk Controls
- Stop and update spec/plan/tasks before adding persistence, a generic namespace, a registry, or cross-domain adapter behavior.
- Treat unknown currentness as not usable.
- Treat cross-workspace/environment proof as not accessible/not usable.
- Require artifact proof for artifact-backed steps even when runs succeed.
- Keep relationship-backed proof resolution bounded through explicit queries or eager-loading; do not add per-step unbounded query behavior.
- Keep raw/support detail absent from customer-safe and default operator UI.
- Use existing policies/capabilities, not UI visibility alone.

View File

@ -0,0 +1,445 @@
# Feature Specification: Spec 388 - Resolution Proof & Currentness Contract v1
**Feature Branch**: `388-resolution-proof-currentness-contract-v1`
**Created**: 2026-06-19
**Status**: Draft / Ready for implementation planning
**Input**: User-provided draft candidate "Spec 388 - Resolution Proof & Currentness Contract v1" from `/Users/ahmeddarrazi/.codex/attachments/cc062aad-6a0b-4321-9b06-b7886c0c6e2a/pasted-text.txt`.
## Repo-Truth Adjustment
The user supplied a complete numbered draft for Spec 388 after Specs 386 and 387. Repo truth confirms:
- `docs/product/spec-candidates.md` still states that no safe automatic next-best-prep target remains in the active queue.
- This candidate is therefore treated as a direct manual promotion, not as an auto-selected backlog item.
- `specs/386-review-publication-resolution-workflow-v1/` already introduced the review-publication resolution case, steps, readiness fingerprint, proof references, OperationRun links, audit, route, and workflow page.
- `specs/387-review-publication-resolution-decision-ux-v1/` already hardened the existing workflow UI and contains completed task markers and screenshot/browser evidence.
- Current code already has `App\Support\ReviewPublicationResolution\ReviewPublicationProofResolver`, `ReviewPublicationReadinessEvaluator`, `ReviewPublicationStepPlanner`, `ReviewPublicationResolutionService`, `ReviewPublicationResolutionStepAuthorizer`, and the `ReviewPublicationResolutionCase` / `ReviewPublicationResolutionStep` models.
The supplied draft proposed a possibly reusable `apps/platform/app/Support/ResolutionProof/` package and generic proof contract. This preparation narrows the approved V1:
- V1 MUST wire proof/currentness semantics into the existing Review Publication Resolution path first.
- V1 SHOULD extend or replace the existing primitive array-based review-publication proof resolver with typed, bounded proof evaluation objects only where this directly prevents stale, superseded, inaccessible, or misleading proof from driving the current workflow.
- V1 MUST NOT introduce a global proof adapter registry, workflow engine, generic resolver framework, or broad cross-domain proof package unless the spec/plan/tasks are updated first with a new proportionality defense and concrete repo evidence that a narrower review-publication implementation is insufficient.
- Restore, Provider Onboarding, Evidence/Baseline Readiness outside the current review path, Report Delivery, Governance Inbox, Cross-Tenant Promotion, and AI governance proof are follow-up candidates only.
## Candidate Selection Gate
- **Selected candidate**: Spec 388 - Resolution Proof & Currentness Contract v1.
- **Source**: Direct user-provided candidate attachment with target path `specs/388-resolution-proof-currentness-contract-v1/`.
- **Why selected**: The active automatic queue is empty, but the user provided an explicit next numbered candidate. The candidate addresses a real trust gap left after Specs 386 and 387: old failed or stale proof can still influence readiness/resolution decisions unless proof currentness is normalized.
- **Roadmap relationship**: Supports R2 Evidence & Exception Workflows, customer-safe review consumption, Governance-of-Record auditability, OperationRun-backed proof, operator workflow compression, and the roadmap principle that current proof must not overstate readiness.
- **Close alternatives deferred**:
- Management Report PDF runtime validation remains tied to Specs 378-380 and staging/Dokploy renderer validation.
- Governance artifact lifecycle retention runtime remains manual promotion only.
- Provider readiness onboarding productization remains optional manual promotion only.
- Cross-domain indicator runtime follow-through remains a broader guardrail lane.
- Restore proof hardening, Provider onboarding proof, Governance Inbox intake, and cross-tenant promotion proof are follow-up specs only.
- **Completed-spec guardrail result**:
- `specs/385-evidence-review-readiness/` has implementation close-out and is completed dependency context only.
- `specs/386-review-publication-resolution-workflow-v1/` contains completed task markers, validation/smoke signals, and existing runtime implementation; it is context only and is not rewritten by this preparation.
- `specs/387-review-publication-resolution-decision-ux-v1/` contains completed task markers and screenshot/browser evidence; it is context only and is not rewritten by this preparation.
- Specs 350, 351, 367, and 381-384 are related guidance, OperationRun, and baseline context only.
- **Smallest viable implementation slice**: Normalize currentness/status/usability/visibility evaluation for Review Publication Resolution proof candidates already used by Spec 386: StoredReport-backed required reports, EvidenceSnapshot, EnvironmentReview/review composition, ReviewPack/current export, and relevant OperationRun references.
- **Gate result**: PASS. The candidate is directly supplied, unprepared, not completed, roadmap-aligned, bounded to one active workflow, and explicitly defers broader adapters.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Review Publication Resolution has persistent steps and proof references, but the current resolver is too shallow to decide whether proof is still current, stale, superseded, accessible, or usable for the current readiness fingerprint.
- **Today's failure**: An old failed OperationRun can keep a step looking failed after newer artifact proof exists; an old successful run can look sufficient when the expected artifact is stale or missing; an EvidenceSnapshot, StoredReport, or ReviewPack can be shown as useful proof even when inputs have changed.
- **User-visible improvement**: Operators see an honest "current proof", "outdated proof", "superseded by newer result", "operation running", "action failed", "proof missing", or "not available with your permissions" state, and stale proof no longer blocks or completes a step incorrectly.
- **Smallest enterprise-capable version**: Add a bounded proof evaluation contract inside the existing Review Publication Resolution flow, update step planning and proof display to consume it, and add focused tests for current, stale, superseded, missing, running, failed, inaccessible, and safe-summary cases.
- **Explicit non-goals**: No generic workflow engine, no global proof adapter registry, no broad cross-domain proof service, no full Restore adapter, no full Provider onboarding adapter, no Governance Inbox intake, no customer-facing proof explorer, no raw OperationRun log viewer, no raw evidence/report payload viewer, no automatic backfill of old proof records, and no auto-publish.
- **Permanent complexity imported**: A small DTO/value-object family, proof status/currentness/usability/visibility enums or equivalent constants, fingerprint comparison helpers, updated review-publication proof resolver behavior, mapping tests, and selected UI/proof disclosure tests. No new source-of-truth table is approved by default.
- **Why now**: Specs 386 and 387 made the resolution workflow product-real. The next risk is trust: the workflow must not let old runs or stale artifacts create false confidence or false blockers as operators repeat the workflow.
- **Why not local**: A local label change cannot safely answer whether a proof reference is current for the present review, action, readiness fingerprint, actor, workspace, and environment. The decision affects step completion, blocked/running/failed display, audit metadata, and customer non-leakage.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New status axes, new DTOs, possible resolver layer, and future-facing wording. Defense: the scope is narrowed to one existing workflow; each state changes operator action or step completion; no registry/framework/persistence is approved; future adapters are explicitly deferred.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve as a bounded Core Enterprise trust-hardening slice over existing Review Publication Resolution proof.
## Problem Statement
TenantPilot increasingly uses readiness-driven review surfaces. A blocked Environment Review can now open a guided Review Publication Resolution case, but the workflow must decide whether proof objects are still valid for the current subject and action.
The workflow must answer:
- Is this proof for the same workspace and managed environment?
- Is it for the same Environment Review and resolution step action?
- Does it match the current readiness fingerprint or has the input state changed?
- Is a newer successful artifact available that supersedes an older failed run?
- Is a running OperationRun still relevant, or has current artifact proof replaced it?
- Is a successful OperationRun enough, or is the expected StoredReport, EvidenceSnapshot, EnvironmentReview output, or ReviewPack still missing?
- Can the current actor see the proof, or should the page show a safe limited state?
- Can this proof complete the step, or is it diagnostics only?
Without an explicit contract, each service or UI surface can drift. The result is false confidence, false blockers, and inconsistent customer/operator disclosure.
## Business / Product Value
- Prevents stale proof from completing or blocking review-publication steps incorrectly.
- Makes the resolution workflow more trustworthy during repeated report/evidence/review/export cycles.
- Keeps OperationRun as execution truth while preserving artifact truth in StoredReports, EvidenceSnapshots, EnvironmentReviews, and ReviewPacks.
- Improves supportability by exposing safe normalized proof reasons without raw payloads or exception messages.
- Creates a narrow contract future proof-related specs can study without committing this V1 to a broad platform framework.
## Primary Users / Operators
- MSP or workspace operator preparing an Environment Review for publication.
- Workspace manager verifying that blocked review publication is now ready or still needs action.
- Support/platform operator diagnosing why a resolution step remains blocked, failed, or waiting.
- Read-only users who may inspect safe state but must not execute steps or see unauthorized technical proof.
- Customer-facing review consumers who must not see internal proof mechanics.
## Spec Scope Fields *(mandatory)*
- **Scope**: managed-environment-scoped Review Publication Resolution proof inside established workspace boundaries.
- **Primary Routes**:
- existing Environment Review detail route(s);
- existing Review Publication Resolution page from Spec 386;
- existing Evidence Snapshot, Stored Report, Review Pack, OperationRun, and Environment Review destinations as proof or diagnostics links only;
- Customer Review Workspace only for negative leakage regression.
- **Data Ownership**:
- `OperationRun` remains execution truth.
- `StoredReport` remains report truth.
- `EvidenceSnapshot` remains evidence truth.
- `EnvironmentReview` remains review-output truth.
- `ReviewPack` remains export/package truth.
- `ReviewPublicationResolutionCase` and `ReviewPublicationResolutionStep` remain workflow state and proof-reference holders.
- New proof evaluation objects are derived contract output, not independent persisted truth.
- **RBAC**:
- Workspace membership and managed-environment entitlement are required before proof is resolved or displayed.
- Viewing a resolution page does not grant permission to view all proof detail or execute step actions.
- Proof details must be hidden or summarized when the actor lacks operation/evidence/report/review-pack capability.
- Non-member or non-entitled access remains deny-as-not-found; entitled users missing a capability receive safe limited/forbidden states according to existing policies.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: N/A. This spec does not add canonical collection filtering or revive retired `/admin/t` routes.
- **Explicit entitlement checks preventing cross-tenant leakage**: proof candidates must be resolved through workspace and managed-environment scoped queries/policies before they can become usable, linked, or visible.
## UI Surface Impact *(mandatory - UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [ ] No UI surface impact
- [x] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [x] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
Clarification: this spec changes state presentation and proof disclosure on an existing operator workflow page. Customer-facing scope is negative leakage regression only unless implementation discovers an existing overclaim and updates the spec before changing customer copy.
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
- **Route/page/surface**:
- Review Publication Resolution page (`ResolveReviewPublication`);
- technical proof and operation history disclosure inside that page;
- Environment Review blocked/readiness state only where it consumes proof summary;
- Customer Review Workspace leakage boundary checks only.
- **Current or new page archetype**: existing UI-101 Review Publication Resolution strategic workflow surface; existing UI-040 Environment Review detail; existing UI-006 Customer Review Workspace for negative no-leakage checks.
- **Design depth**: Strategic Surface for UI-101; Domain Pattern Surface for proof disclosure states; customer-safe regression only for UI-006.
- **Repo-truth level**: repo-verified through Specs 386 and 387.
- **Existing pattern reused**: existing Spec 386 page, native Filament actions/sections/badges, existing collapsed technical proof disclosure, existing OperationRun links/helpers, existing policy/UiEnforcement paths.
- **New pattern required**: no new route/archetype. A local or bounded proof-state presenter is allowed only if it maps normalized proof states to existing UI affordances and does not become a generic UI framework.
- **Screenshot required**: yes for proof states that materially change rendering, under `specs/388-resolution-proof-currentness-contract-v1/artifacts/screenshots/`; screenshot substitutions may be documented for states that are fully covered by Feature/Livewire tests but hard to produce in browser fixtures.
- **Page audit required**: update UI-101 only if visible structure/copy materially changes; otherwise record a no-new-route/no-new-archetype implementation note.
- **Customer-safe review required**: yes, negative leakage checks only.
- **Dangerous-action review required**: no new dangerous action is approved. Existing step actions remain governed by Spec 386/387 confirmation, authorization, audit, and tests.
- **Coverage files updated or explicitly not needed**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [x] `docs/ui-ux-enterprise-audit/page-reports/...`
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [ ] `N/A - no reachable UI surface impact`
- **No-impact rationale when applicable**: N/A.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes, but V1 scope is one existing workflow.
- **Interaction class(es)**: status messaging, proof disclosure, OperationRun links, artifact links, readiness labels, customer-safe disclosure, audit metadata.
- **Systems touched**:
- `App\Support\ReviewPublicationResolution\ReviewPublicationProofResolver`
- `ReviewPublicationReadinessEvaluator`
- `ReviewPublicationStepPlanner`
- `ReviewPublicationResolutionService`
- `ResolveReviewPublication`
- existing OperationRun reconciliation/actionability helpers as read-only context
- existing EvidenceSnapshot, StoredReport, ReviewPack, and EnvironmentReview services as artifact truth providers
- **Existing pattern(s) to extend**: existing review-publication proof resolver and readiness fingerprint; existing OperationRun link/actionability behavior; existing collapsed technical proof/history UI.
- **Shared contract / presenter / builder / renderer to reuse**: existing `ReviewPublicationProofResolver`, `ReviewPublicationReadinessEvaluator`, `OperationRunLinks`/operation UX helpers where applicable, existing Filament badge/status rendering.
- **Why the existing shared path is sufficient or insufficient**: existing paths are sufficient for workflow ownership and UI placement, but insufficient because proof is currently only a shallow reference/status array rather than a normalized currentness/usability/visibility result.
- **Allowed deviation and why**: a bounded typed proof evaluation result is allowed because it prevents false step completion/blocking. A global registry, generic adapter system, or cross-domain proof framework is not allowed in V1.
- **Consistency impact**: step completion, current-step state, technical proof display, audit metadata, and customer-safe non-leakage must agree about the same proof evaluation.
- **Review focus**: stale/superseded proof must not dominate current proof; successful runs must not replace artifact proof; missing/inaccessible proof must not leak existence across workspace/environment boundaries.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: yes for proof classification and display; no new start/completion UX is approved.
- **Shared OperationRun UX contract/layer reused**: existing Spec 386/387 OperationRun links, start behavior, terminal notification path, and operation lifecycle service remain authoritative.
- **Delegated start/completion UX behaviors**: queued toast, run link, artifact link, browser event, dedupe messaging, terminal notification, and safe URL resolution stay delegated to existing OperationRun UX/lifecycle paths.
- **Local surface-owned behavior that remains**: classification of whether a run is current proof, superseded diagnostics, running inspection-only state, failed/not-usable state, and safe proof summary display.
- **Queued DB-notification policy**: unchanged.
- **Terminal notification path**: unchanged.
- **Exception required?**: none.
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: yes, platform-core proof vocabulary touches provider-backed report/evidence artifacts but does not change provider contracts.
- **Boundary classification**: platform-core for proof status/currentness/usability/visibility vocabulary; provider-owned for raw Microsoft/Graph/provider identifiers and payload details.
- **Seams affected**: readiness fingerprint inputs, report/evidence/review-pack artifact proof, OperationRun proof, safe summary metadata, proof labels.
- **Neutral platform terms preserved or introduced**: proof, currentness, usability, visibility, artifact, operation, readiness fingerprint, safe summary, subject, action.
- **Provider-specific semantics retained and why**: required report keys such as permission posture or Entra admin roles remain domain-specific report requirements where existing review readiness already uses them. They must not become raw provider payload or customer-facing debug language.
- **Why this does not deepen provider coupling accidentally**: proof evaluation consumes existing artifact records and safe identifiers; it does not add Graph calls, provider-specific payload readers, or provider-specific shared platform truth.
- **Follow-up path**: follow-up spec only for provider onboarding proof if current provider readiness surfaces show a concrete gap.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Review Publication Resolution proof disclosure | yes | Existing Filament page + existing Blade disclosure | status messaging, proof links, OperationRun links | page, proof detail, step state | no | no new route or navigation |
| Environment Review blocked/readiness state | yes, only if proof summary changes visible state | Existing Filament detail | review readiness status | detail | no | update only where current proof affects display |
| Customer Review Workspace leakage boundary | no positive UI feature | Existing customer-safe surface | customer disclosure | negative regression only | no | no internal proof mechanics may appear |
## 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 |
|---|---|---|---|---|---|---|---|
| Review Publication Resolution page | Primary Decision Surface | Operator decides whether to take the current preparation action, wait, inspect failure, or return to review | current proof state, next action, blocked/running/failed/outdated state | proof type/id, operation link, evaluated at, safe reason code | Primary because it owns blocked publication resolution | existing Spec 386 workflow | avoids cross-page interpretation of stale runs and artifacts |
| Environment Review detail | Secondary Context | Operator decides whether to start or resume resolution | publication blocked/ready state and one CTA | proof detail remains in resolution page | Secondary because it starts the flow but should not become proof explorer | review publication handoff | keeps proof detail out of review summary |
| Customer Review Workspace | Customer-safe consumption | Customer/auditor sees review availability only | safe availability state only | no internal proof detail | Not primary for resolution | customer-safe review consumption | prevents internal mechanics from creating confusion or leakage |
## 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 |
|---|---|---|---|---|---|---|---|
| Review Publication Resolution page | operator-MSP, manager, readonly inspector, support-platform | proof label, currentness/usability, next action or wait/fix message | operation/artifact references, safe reason code, evaluated at | raw provider payloads and raw report/evidence contents remain absent | execute current step, wait, inspect failure, or return to review | raw payloads, raw exception messages, unauthorized proof links | proof status is stated once; technical details explain but do not restate |
| Customer Review Workspace | customer-read-only, operator-MSP | preparing/available/unavailable style state only | none for customer mode | none | review/download only where already allowed | OperationRun, proof fingerprint, reason code, case/step details | no internal proof terms in customer output |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Review Publication Resolution page | Workflow / Detail | Primary Decision workflow detail | resolve current blocker, wait, inspect failed proof, or return to review | existing page route | N/A | technical proof disclosure | existing cancel behavior only | existing Environment Review collection | existing resolution page | workspace and managed environment | Review publication resolution | current proof state and one next action | none |
| Environment Review detail | Detail / Handoff | Secondary context detail | resume resolution | existing detail route | N/A | proof detail on resolution page | existing review actions unchanged | existing Environment Review collection | existing detail route | workspace and managed environment | Environment review | publication readiness and CTA | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Review Publication Resolution page | MSP/workspace operator | Decide whether current proof supports the step or another action is needed | workflow detail | Is this blocker resolved with current proof, or what must I do now? | proof label, currentness/usability, next action, no-auto-publish assurance | proof ID/type, operation link, safe reason, evaluated timestamp | execution outcome, artifact availability, currentness, usability, visibility | TenantPilot workflow plus source-owned step operations | current step action, wait/inspect, return to review | existing cancel and existing step actions only |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no. Proof evaluation is derived from existing domain truth and resolution step references.
- **New persisted entity/table/artifact?**: no by default. Existing `review_publication_resolution_*` records may store proof references/metadata already introduced by Spec 386. Any migration requires updating this spec/plan/tasks before implementation.
- **New abstraction?**: yes, a bounded proof evaluation DTO/value-object and resolver behavior are expected.
- **New enum/state/reason family?**: yes, proof status/currentness/usability/visibility states or equivalent enum families are expected.
- **New cross-domain UI framework/taxonomy?**: no. UI mapping remains local to Review Publication Resolution and existing surfaces.
- **Current operator problem**: operators can be misled by stale failed runs, stale artifacts, inaccessible proof, or successful runs without required artifacts.
- **Existing structure is insufficient because**: the current resolver returns only `proof_type`, `proof_id`, `proof_status`, and `operation_run_id`, which cannot distinguish current/stale/superseded/not accessible/inspection-only/usable proof.
- **Narrowest correct implementation**: evaluate only proof needed by the existing review-publication steps and reuse current readiness fingerprint/artifact records. Defer all non-review adapters.
- **Ownership cost**: maintain enum/DTO mappings, resolver tests, step-planner behavior, and UI label mapping for proof states.
- **Alternative intentionally rejected**: local string labels on existing proof arrays. This was rejected because it cannot safely govern step completion, actor visibility, and stale/superseded behavior.
- **Release truth**: current-release trust hardening for an already implemented workflow, not speculative future-release platform preparation.
### Compatibility posture
This feature assumes the current pre-production posture. Backward compatibility shims, dual readers, historical proof backfill, and legacy aliases are out of scope unless the spec is updated with a concrete current-release need.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit for proof evaluation and fingerprint comparison; Feature for step planner/service integration, RBAC/scope, audit metadata, and customer leakage; Filament/Livewire for proof display/action states; Browser for representative proof-state rendering where useful.
- **Validation lane(s)**: fast-feedback and confidence; browser for focused visual smoke; PostgreSQL only if implementation updates schema/constraints.
- **Why this classification and these lanes are sufficient**: the risk is deterministic proof classification and safe display over existing DB-backed workflow state, not new provider execution.
- **New or expanded test families**: new focused Spec 388 proof/currentness unit and feature families; optional focused browser file or extension of Spec 387 browser smoke.
- **Fixture / helper cost impact**: reuse Spec 386/387 helpers and existing `apps/platform/tests/Pest.php` review/evidence/report fixtures; do not add broad default workspace/provider setup.
- **Heavy-family visibility / justification**: none planned.
- **Special surface test profile**: workflow-detail surface with proof-state smoke; customer-safe negative leakage path.
- **Standard-native relief or required special coverage**: ordinary Feature/Filament coverage for most states; screenshot/browser smoke for representative current/stale/superseded/failed states only where fixtures make it safe.
- **Reviewer handoff**: verify no global registry/framework, no new persistence by default, no raw payload leakage, no Step completion from stale proof, no customer internal proof exposure, and no local OperationRun lifecycle rewrites.
- **Budget / baseline / trend impact**: no material trend impact expected; document-in-feature if browser fixture scope grows.
- **Escalation needed**: document-in-feature for contained proof-state exceptions; follow-up-spec for recurring cross-domain proof needs outside review publication.
- **Active feature PR close-out entry**: Proof Currentness / No Generic Registry / Smoke Coverage.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReviewPublicationResolution tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec388ReviewPublicationProofCurrentnessSmokeTest.php` if browser coverage is implemented as a separate file
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Current Artifact Proof Supersedes Old Failed Runs (Priority: P1)
As an operator resolving blocked review publication, I need a newer successful artifact to supersede an older failed OperationRun so I am not blocked by obsolete failure state.
**Why this priority**: This is the highest false-blocker risk in the existing workflow.
**Independent Test**: Create or simulate an old failed report/evidence/export run, then create newer current artifact proof for the same step and verify the step is completed or actionable based on the artifact, not failed because of the old run.
**Acceptance Scenarios**:
1. **Given** a failed report-generation OperationRun and a newer current StoredReport required by the step, **When** the resolution case is refreshed, **Then** the failed run is marked/displayed as superseded or diagnostics-only and the StoredReport becomes current usable proof.
2. **Given** a failed review-pack OperationRun and a newer ready ReviewPack generated from the current review output, **When** the step planner evaluates export readiness, **Then** the old run does not keep the step failed.
---
### User Story 2 - Stale or Missing Artifacts Cannot Complete Steps (Priority: P1)
As an operator, I need the workflow to reject stale or missing artifacts so a step does not look complete when the review input state changed.
**Why this priority**: This prevents false confidence and unsafe publication readiness.
**Independent Test**: Create stale StoredReport/EvidenceSnapshot/ReviewPack conditions by changing relevant input timestamps or fingerprints, then verify proof currentness is stale and usability is not usable.
**Acceptance Scenarios**:
1. **Given** an EvidenceSnapshot exists but required reports changed after it was collected, **When** proof is evaluated for evidence, **Then** currentness is stale and the collect-evidence step remains actionable.
2. **Given** a ReviewPack exists but the EnvironmentReview changed after pack generation, **When** export proof is evaluated, **Then** currentness is stale and the prepare-export step remains actionable.
3. **Given** a successful OperationRun exists but the expected artifact is missing, **When** the step is evaluated, **Then** the run is diagnostics-only or not usable and the step is not completed.
---
### User Story 3 - Proof Visibility Respects RBAC and Customer Boundaries (Priority: P1)
As a readonly or customer-safe user, I need internal proof details hidden or summarized so TenantPilot does not leak operation, evidence, report, or fingerprint internals.
**Why this priority**: Proof trust cannot compromise workspace/tenant isolation or customer-safe disclosure.
**Independent Test**: Evaluate the same proof as an authorized operator, readonly actor, unauthorized workspace actor, and customer-safe viewer; verify visibility and links differ appropriately.
**Acceptance Scenarios**:
1. **Given** proof belongs to another workspace or managed environment, **When** it is evaluated for the current actor, **Then** it is not usable and not visible, preferably deny-as-not-found according to existing patterns.
2. **Given** a readonly actor can inspect the resolution page but lacks operation/evidence/report detail capability, **When** proof is displayed, **Then** the page shows safe limited proof state and no executable step action.
3. **Given** a customer-facing review surface is rendered, **When** internal proof exists, **Then** no OperationRun IDs, proof fingerprints, resolution step keys, raw reason codes, raw provider payloads, or report/evidence internals appear.
---
### User Story 4 - Operators Can Inspect Safe Proof Reasons (Priority: P2)
As a support or operator user with permission, I need a safe proof summary that explains why proof is current, stale, superseded, running, failed, missing, inaccessible, or unknown without exposing raw payloads.
**Why this priority**: Safe reason codes reduce support effort and make the workflow reviewable.
**Independent Test**: Produce each proof state and verify the normalized reason code, evaluated timestamp, proof/artifact type, and safe summary appear only in authorized technical disclosure.
**Acceptance Scenarios**:
1. **Given** proof currentness cannot be determined safely, **When** proof is evaluated, **Then** usability is not usable and the operator sees "Proof cannot be verified" or equivalent safe copy.
2. **Given** proof is running, **When** the page renders, **Then** the current action is not duplicated and running proof is inspection-only.
3. **Given** proof is failed, **When** the page renders, **Then** raw exception messages are not shown and the normalized reason code is safe.
## Functional Requirements
- **FR-388-001**: The system MUST produce a normalized proof evaluation for each Review Publication Resolution step that includes proof type, proof id, subject type/id, action key, status, currentness, usability, visibility, optional operation run id, normalized reason code, evaluated timestamp, proof timestamps, and safe summary.
- **FR-388-002**: The proof evaluation MUST distinguish these V1 status outcomes: missing, available, running, succeeded, failed, cancelled, unavailable, not accessible, and unknown. Renaming, narrowing, or merging these outcomes requires updating this spec, plan, and tasks before implementation continues.
- **FR-388-003**: The proof evaluation MUST distinguish at least these currentness outcomes: current, stale, superseded, not applicable, and unknown.
- **FR-388-004**: The proof evaluation MUST distinguish at least these usability outcomes: usable, usable with warning, not usable, and inspection only.
- **FR-388-005**: The proof evaluation MUST distinguish at least these visibility outcomes: operator visible, operator limited, customer safe summary only, and hidden.
- **FR-388-006**: Proof from another workspace MUST never be usable or visible for the current subject.
- **FR-388-007**: Proof from another managed environment MUST never be usable for environment-scoped resolution steps.
- **FR-388-008**: A successful OperationRun MUST NOT complete an artifact-backed step unless the expected artifact exists and is current.
- **FR-388-009**: A failed OperationRun MUST become superseded or inspection-only when newer successful current artifact proof exists for the same step requirement.
- **FR-388-010**: A running OperationRun MUST be current only when it belongs to the same scope, subject, action, and readiness fingerprint or source-owned operation context, and no newer completed proof supersedes it.
- **FR-388-011**: StoredReport proof for required reports MUST be usable only when it matches the required report key/dimension, workspace, managed environment, successful/evaluated state, and current readiness inputs.
- **FR-388-012**: Zero-result StoredReports MUST be usable proof when the report was successfully evaluated and matches the current requirement.
- **FR-388-013**: EvidenceSnapshot proof MUST be usable only when required dimensions are complete or explicitly accepted by product rules and the snapshot is current relative to required report changes.
- **FR-388-014**: EnvironmentReview/review-output proof MUST be usable only when review composition is current relative to evidence/report inputs and publication blockers are resolved.
- **FR-388-015**: ReviewPack proof MUST be usable only when generated from the current EnvironmentReview output and matching the intended disclosure/export profile.
- **FR-388-016**: Safe summaries MUST NOT contain raw provider payloads, Graph responses, tokens, secrets, full report content, full evidence content, raw exception messages, or customer-unsafe internal reason families.
- **FR-388-017**: Step planning MUST consume proof currentness/usability so stale, superseded, inaccessible, unknown, or diagnostics-only proof cannot complete a step.
- **FR-388-018**: Technical proof disclosure MUST show authorized operators safe currentness/usability/reason information without making technical proof more prominent than the next operator decision.
- **FR-388-019**: Customer-facing surfaces MUST use only customer-safe availability/preparing/unavailable style summary and MUST NOT display internal proof mechanics.
- **FR-388-020**: Audit metadata for proof-driven step transitions MUST include safe proof fields when existing audit patterns support it without noisy per-render audit events.
- **FR-388-021**: The implementation MUST keep proof evaluation derived from existing domain objects and MUST NOT create an independent proof source of truth by default.
- **FR-388-022**: Any schema migration, new persisted proof fields, or generic proof namespace requires updating this spec, plan, and tasks before implementation continues.
## Non-Functional Requirements
- **NFR-388-001 - Data minimization**: Proof evaluation and audit metadata must include safe identifiers and normalized reason codes only.
- **NFR-388-002 - No render-time provider calls**: Proof evaluation must not call Microsoft Graph or any provider during UI render.
- **NFR-388-003 - Deterministic evaluation**: The same subject, action, actor capability, and readiness fingerprint must produce stable proof currentness outcomes until source domain truth changes.
- **NFR-388-004 - Performance**: Refreshing a resolution case should not add unbounded queries; relationship-backed proof should be eager-loaded or queried explicitly.
- **NFR-388-005 - Pre-production lean posture**: Do not add compatibility readers, aliases, historical backfills, or dual semantics for old proof data unless current release truth requires it.
- **NFR-388-006 - Filament/Livewire version safety**: Any UI work must remain Filament v5 / Livewire v4.0+ compliant and avoid Livewire v3 or Filament v3/v4 APIs.
## Key Entities / Concepts
- **Proof Evaluation**: Derived result that describes whether a proof object can support a specific subject/action/readiness decision.
- **Proof Reference**: Existing pointer on a resolution step or derived candidate pointer to an artifact/operation.
- **Readiness Fingerprint**: Existing Spec 386 hash describing meaningful readiness inputs for a review-publication action.
- **Proof Status**: The proof object's own execution/artifact state.
- **Proof Currentness**: Whether proof applies to the current subject/action/readiness state.
- **Proof Usability**: Whether proof can complete or support the step.
- **Proof Visibility**: Whether the current actor/surface may show the proof or only a safe summary.
- **Safe Summary**: Sanitized proof explanation with no raw provider/report/evidence/secrets.
## Edge Cases
- Newer current artifact proof supersedes an older failed OperationRun.
- Successful OperationRun exists but expected artifact is missing.
- OperationRun is still running but a newer completed artifact exists.
- StoredReport has zero rows but was successfully evaluated.
- EvidenceSnapshot exists but required reports changed after collection.
- ReviewPack exists but EnvironmentReview was refreshed after pack generation.
- Proof belongs to another workspace or managed environment.
- Actor can view resolution summary but lacks operation/evidence/report detail capability.
- Customer-facing surface renders while internal proof exists.
- Currentness cannot be safely determined.
## Out of Scope
- Generic workflow engine or generic action-resolution case model.
- Global proof adapter registry.
- Broad `Support/ResolutionProof` platform package unless spec is updated first.
- Restore proof/currentness adapter.
- Provider onboarding proof/currentness adapter.
- Evidence/Baseline readiness proof outside the current Review Publication Resolution path.
- Report Delivery adapter.
- Governance Inbox proof intake.
- Cross-Tenant Promotion resolution proof.
- AI governance proof contract.
- Customer-facing proof explorer.
- Raw OperationRun log viewer.
- Raw EvidenceSnapshot, StoredReport, ReviewPack, Graph, or provider payload viewer.
- Automatic backfill of old proof records.
- New navigation, Resource, collection route, or global search surface for proof evaluations.
## Success Criteria
- **SC-388-001**: In focused tests, every review-publication step uses normalized proof currentness/usability instead of raw proof status alone.
- **SC-388-002**: A newer current artifact supersedes an older failed run for at least required reports and review pack proof cases.
- **SC-388-003**: A successful OperationRun without expected artifact proof cannot complete an artifact-backed step.
- **SC-388-004**: Stale EvidenceSnapshot and stale ReviewPack proof keep their steps actionable instead of completed.
- **SC-388-005**: Unauthorized or cross-scope proof is not usable and does not expose proof existence outside existing entitlement rules.
- **SC-388-006**: Customer-facing output contains no internal proof mechanics or unsafe identifiers.
- **SC-388-007**: No new Graph/provider calls occur during proof evaluation or UI rendering.
- **SC-388-008**: The implementation remains bounded to Review Publication Resolution unless this spec is explicitly updated before implementation.
## Assumptions
- Specs 386 and 387 are the runtime baseline and should not be rewritten as part of Spec 388.
- Existing review/evidence/report/pack records expose enough timestamps, fingerprints, statuses, and relationships to derive V1 currentness without a migration.
- Existing policies and capability helpers can determine proof visibility; if they cannot, the spec must be updated before adding new capability families.
- Existing audit infrastructure can record safe proof metadata on step transitions without per-render audit noise.
## Open Questions
- None blocking implementation planning. If implementation proves current schema cannot store or derive needed currentness, update this spec/plan/tasks before adding migrations or persisted proof state.
## Follow-up Spec Candidates
- Restore execution proof/currentness hardening.
- Provider onboarding readiness proof and setup-currentness productization.
- Governance Inbox proof intake and assigned resolution work items.
- Cross-domain proof/currentness contract after at least two additional real consumers prove shared variance.
- Report Delivery proof/currentness for management PDF/export delivery after runtime validation.

View File

@ -0,0 +1,156 @@
# Tasks: Spec 388 - Resolution Proof & Currentness Contract v1
**Input**: Design documents from `/specs/388-resolution-proof-currentness-contract-v1/`
**Prerequisites**: `spec.md`, `plan.md`, `checklists/requirements.md`
**Tests**: Required. This feature changes runtime proof classification, step completion behavior, RBAC-sensitive proof visibility, and existing Filament proof-state display.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for proof-currentness behavior.
- [x] New or changed tests stay in focused Unit, Feature, Filament/Livewire, and optional Browser families.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
- [x] Planned validation commands cover the change without pulling unrelated lane cost.
- [x] The workflow-detail surface test profile and optional browser smoke need are explicit.
- [x] Any browser fixture gap is documented in the active spec artifacts instead of widened through product-only setup code.
## Phase 1: Preparation and Repo Truth
**Purpose**: Confirm Spec 388 starts from current Spec 386/387 runtime truth and avoids duplicate or generic framework work.
- [x] T001 Confirm current branch/status and re-read `specs/388-resolution-proof-currentness-contract-v1/spec.md`, `plan.md`, and `tasks.md`.
- [x] T002 Re-read `specs/386-review-publication-resolution-workflow-v1/` and `specs/387-review-publication-resolution-decision-ux-v1/` as completed implementation context only; do not rewrite those packages.
- [x] T003 Inspect existing proof resolver behavior in `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationProofResolver.php`.
- [x] T004 Inspect current readiness fingerprint inputs in `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationReadinessEvaluator.php`.
- [x] T005 Inspect step completion/current-step behavior in `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationStepPlanner.php`.
- [x] T006 Inspect case sync/audit behavior in `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionService.php`.
- [x] T007 Inspect current proof fields/casts in `apps/platform/app/Models/ReviewPublicationResolutionStep.php`.
- [x] T008 Inspect current UI proof disclosure in `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ResolveReviewPublication.php` and `apps/platform/resources/views/filament/resources/environment-review-resource/pages/resolve-review-publication.blade.php`.
- [x] T009 Confirm no migration, new proof table, generic registry, new route, navigation, global-search Resource, panel provider, provider call, or OperationRun lifecycle change is required; update spec/plan before implementation if this is false.
- [x] T010 Confirm Filament v5 / Livewire v4.0+ compliance and panel provider registration remains `apps/platform/bootstrap/providers.php`.
## Phase 2: Tests First - Proof Evaluation Contract
**Purpose**: Prove normalized proof behavior before changing resolver implementation.
- [x] T011 [P] Add unit tests under `apps/platform/tests/Unit/Support/ReviewPublicationResolution/` for missing proof returning missing/currentness unknown or not applicable, not usable, and safe summary only.
- [x] T012 [P] Add unit tests proving running OperationRun proof is current only for matching workspace, managed environment, subject, action, and fingerprint, and remains inspection-only.
- [x] T013 [P] Add unit tests proving failed current OperationRun proof is not usable or inspection-only.
- [x] T014 [P] Add unit tests proving failed OperationRun proof becomes superseded/inspection-only when a newer current artifact exists.
- [x] T015 [P] Add unit tests proving successful OperationRun without expected artifact proof cannot complete artifact-backed steps.
- [x] T016 [P] Add unit tests proving unknown currentness fails closed as not usable.
- [x] T017 [P] Add unit tests proving safe summaries exclude raw provider payloads, raw Graph responses, secrets/tokens, full report/evidence content, and raw exception messages.
## Phase 3: Tests First - Artifact Currentness
**Purpose**: Prove artifact-backed proof rules for current review publication steps.
- [x] T018 [P] Add feature/unit coverage proving current StoredReport proof matches required report key/dimension, workspace, managed environment, successful/evaluated state, and current readiness inputs.
- [x] T019 [P] Add coverage proving zero-result evaluated StoredReport is usable proof.
- [x] T020 [P] Add coverage proving stale StoredReport or changed required report input does not complete `complete_required_reports`.
- [x] T021 [P] Add coverage proving EvidenceSnapshot becomes stale when required reports changed after collection.
- [x] T022 [P] Add coverage proving EnvironmentReview/review-output proof is not usable when review composition is older than evidence/report inputs or readiness blockers remain.
- [x] T023 [P] Add coverage proving ReviewPack proof becomes stale when EnvironmentReview output changes after pack generation.
- [x] T024 [P] Add coverage proving ReviewPack proof is usable only when ready/current and matches current output/export profile.
## Phase 4: Tests First - Planner, RBAC, and Customer Boundary
**Purpose**: Prove proof currentness affects workflow behavior and disclosure safely.
- [x] T025 Add `apps/platform/tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php` covering newer StoredReport superseding old failed report run.
- [x] T026 Add feature coverage proving stale EvidenceSnapshot keeps collect-evidence step actionable.
- [x] T027 Add feature coverage proving stale ReviewPack keeps prepare-export step actionable.
- [x] T028 Add feature coverage proving successful OperationRun alone does not complete artifact-backed steps.
- [x] T029 Add feature coverage proving cross-workspace proof is not usable/visible and follows deny-as-not-found where existing policies require it.
- [x] T030 Add feature coverage proving cross-environment proof is not usable for environment-scoped resolution actions.
- [x] T031 Add readonly actor coverage proving limited proof display and no executable step action.
- [x] T032 Add customer-facing regression coverage proving no OperationRun ID/link, proof fingerprint, resolution case/step key, raw reason code, or internal proof state leaks.
## Phase 5: Implementation - Bounded Proof Contract
**Purpose**: Introduce the narrow derived proof contract under existing review-publication ownership.
- [x] T033 Create bounded proof evaluation value objects/enums under `apps/platform/app/Support/ReviewPublicationResolution/` or a review-publication-owned child namespace only.
- [x] T034 Implement proof status values exactly for missing, available, running, succeeded, failed, cancelled, unavailable, not accessible, and unknown; update spec/plan/tasks before implementation if any value is renamed, narrowed, or merged.
- [x] T035 Implement proof currentness values for current, stale, superseded, not applicable, and unknown.
- [x] T036 Implement proof usability values for usable, usable with warning, not usable, and inspection only.
- [x] T037 Implement proof visibility values for operator visible, operator limited, customer safe summary only, and hidden.
- [x] T038 Implement safe summary sanitization helpers that reject raw payloads, raw exceptions, full report/evidence content, tokens, and secrets.
- [x] T039 Ensure new classes are derived helpers only and do not create a new persisted proof source of truth.
## Phase 6: Implementation - Resolver and Fingerprint Behavior
**Purpose**: Replace shallow proof references with currentness-aware evaluations.
- [x] T040 Update `ReviewPublicationProofResolver` to return normalized proof evaluations for each `ReviewPublicationResolutionStepKey`.
- [x] T041 Update required report proof evaluation to resolve current StoredReport-backed evidence dimensions without treating missing/stale reports as completed proof.
- [x] T042 Update EvidenceSnapshot proof evaluation to compare snapshot state against current required report/evidence inputs.
- [x] T043 Update EnvironmentReview/review-output proof evaluation to require current composition and resolved publication blockers.
- [x] T044 Update ReviewPack proof evaluation to require current review output and ready/exportable pack state.
- [x] T045 Update OperationRun proof classification so running/failed/succeeded/cancelled runs are never treated as artifact proof unless the operation type itself is the required proof.
- [x] T046 Update fingerprint comparison helpers to use safe components only: workspace, managed environment, subject, action, blocker keys, artifact ids/timestamps, and relevant statuses.
- [x] T047 Ensure no provider/Graph calls occur during proof evaluation or UI render.
- [x] T048 Add or update bounded-query coverage/review for relationship-backed proof resolution so refreshing a resolution case uses explicit queries or eager-loaded relationships and does not introduce per-step unbounded query behavior.
## Phase 7: Implementation - Step Planning, Audit, and UI Disclosure
**Purpose**: Make workflow behavior consume proof evaluation without broad UI rewrites.
- [x] T049 Update `ReviewPublicationStepPlanner` so stale, superseded, inaccessible, unknown, not usable, or inspection-only proof cannot complete a step.
- [x] T050 Update planner/service behavior so newer current artifact proof supersedes older failed/running runs for the same step requirement.
- [x] T051 Update `ReviewPublicationResolutionService` audit metadata on proof-driven step transitions to include safe proof fields where existing audit patterns support it.
- [x] T052 Add or update audit assertions proving proof-driven transition metadata includes only safe proof fields and excludes raw provider payloads, Graph responses, full report/evidence content, raw exception messages, tokens, and secrets.
- [x] T053 Update `ResolveReviewPublication` and its Blade view only as needed to show proof labels such as current proof, operation running, action failed, outdated proof, superseded by newer result, proof missing, not available with your permissions, and proof cannot be verified.
- [x] T054 Keep technical proof collapsed/secondary by default and ensure no proof state creates an extra competing primary action.
- [x] T055 Keep customer-facing surfaces free of internal proof detail; update only negative leakage tests unless existing copy overclaims.
- [x] T056 Update UI-101 page report only if rendered structure/copy materially changes; otherwise record no-new-route/no-new-archetype rationale in implementation close-out.
## Phase 8: Browser / Screenshot Evidence
**Purpose**: Capture representative proof-state rendering if UI changes are visible.
- [x] T057 Decide whether changed proof states require a new `apps/platform/tests/Browser/Spec388ReviewPublicationProofCurrentnessSmokeTest.php` or can be covered by existing Spec 387 browser smoke plus Feature/Livewire proof.
- [x] T058 Capture or document current proof completed state under `specs/388-resolution-proof-currentness-contract-v1/artifacts/screenshots/`.
- [x] T059 Capture or document running operation proof state.
- [x] T060 Capture or document failed operation proof state.
- [x] T061 Capture or document superseded failed proof state.
- [x] T062 Capture or document stale/outdated proof disclosure.
- [x] T063 Capture or document readonly limited proof state.
- [x] T064 Capture or document customer workspace no-proof-leakage state.
## Phase 9: Validation
**Purpose**: Prove bounded trust hardening and no scope expansion.
- [x] T065 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReviewPublicationResolution tests/Feature/EnvironmentReview/Spec388ReviewPublicationProofCurrentnessTest.php`.
- [x] T066 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReview/Spec386ReviewPublicationResolutionWorkflowTest.php tests/Feature/EnvironmentReview/Spec387ReviewPublicationResolutionDecisionUxTest.php`.
- [x] T067 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec388ReviewPublicationProofCurrentnessSmokeTest.php` if a browser file is added.
- [x] T068 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T069 Run `git diff --check`.
- [x] T070 Record implementation close-out with Livewire v4 compliance, provider registration location, global search status, destructive/high-impact action handling, asset strategy, tests run, browser smoke result or documented substitution, deployment impact, and explicit no-generic-registry confirmation.
## Explicit Non-Goals
- [x] NT001 Do not modify completed Specs 385, 386, or 387 except as read-only context.
- [x] NT002 Do not create a global proof adapter registry, workflow engine, broad `Support/ResolutionProof` package, or generic process manager.
- [x] NT003 Do not add migrations, new persisted proof fields, or new proof tables unless spec/plan/tasks are updated first.
- [x] NT004 Do not add Restore, Provider Onboarding, Governance Inbox, Report Delivery, Cross-Tenant Promotion, or AI proof adapters.
- [x] NT005 Do not add top-level navigation, a Resource, collection route, or global search for proof evaluations.
- [x] NT006 Do not auto-publish reviews or move Publish onto the resolution page.
- [x] NT007 Do not expose raw provider payloads, Graph responses, tokens, secrets, full report/evidence content, raw exception messages, proof fingerprints, or internal reason codes to customer-facing surfaces.
- [x] NT008 Do not register new Filament assets unless spec/plan are updated first.
## Dependencies
- Phase 1 must complete before implementation.
- Phases 2-4 should be written before Phases 5-7 implementation.
- Phase 5 proof values are a prerequisite for resolver/planner work.
- Phase 6 resolver behavior, including bounded-query proof evaluation, is a prerequisite for Phase 7 planner/UI behavior.
- Phase 8 browser evidence depends on visible UI changes from Phase 7.
- Phase 9 runs after all implementation and artifact notes are complete.
## Parallel Execution Notes
- T011-T017 can run in parallel with T018-T024 if test fixtures do not overlap.
- T029-T032 can run in parallel with artifact currentness tests once helper fixtures exist.
- T034-T038 can run in parallel after T033 creates the owning namespace/files.
- T058-T064 can be captured/documented in parallel after browser fixture decision T057.