Some checks failed
Main Confidence / confidence (push) Failing after 48s
## Summary - implement the finding outcome taxonomy end-to-end with canonical resolve, close, reopen, and verification semantics - align finding UI, filters, audit metadata, review summaries, and export/read-model consumers to the shared outcome semantics - add focused Pest coverage and complete the spec artifacts for feature 231 ## Details - manual resolve is limited to the canonical `remediated` outcome - close and reopen flows now use bounded canonical reasons - trusted system clear and reopen distinguish verified-clear from verification-failed and recurrence paths - duplicate lifecycle backfill now closes findings canonically as `duplicate` - accepted-risk recording now uses the canonical `accepted_risk` reason - finding detail and list surfaces now expose terminal outcome and verification summaries - review, snapshot, and review-pack consumers now propagate the same outcome buckets ## Filament / Platform Contract - Livewire v4.0+ compatibility remains intact - provider registration is unchanged and remains in `bootstrap/providers.php` - no new globally searchable resource was introduced; `FindingResource` still has a View page and `TenantReviewResource` remains globally searchable false - lifecycle mutations still run through confirmed Filament actions with capability enforcement - no new asset family was added; the existing `filament:assets` deploy step is unchanged ## Verification - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Models/FindingResolvedTest.php tests/Unit/Findings/FindingWorkflowServiceTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php` - browser smoke: `/admin/findings/my-work` -> finding detail resolve flow -> queue regression check passed ## Notes - this commit also includes the existing `.github/agents/copilot-instructions.md` workspace change that was already present in the worktree when all changes were committed Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #267
204 lines
7.3 KiB
PHP
204 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Findings;
|
|
|
|
use App\Models\Finding;
|
|
|
|
final class FindingOutcomeSemantics
|
|
{
|
|
public const string VERIFICATION_PENDING = 'pending_verification';
|
|
|
|
public const string VERIFICATION_VERIFIED = 'verified_cleared';
|
|
|
|
public const string VERIFICATION_NOT_APPLICABLE = 'not_applicable';
|
|
|
|
public const string OUTCOME_RESOLVED_PENDING_VERIFICATION = 'resolved_pending_verification';
|
|
|
|
public const string OUTCOME_VERIFIED_CLEARED = 'verified_cleared';
|
|
|
|
public const string OUTCOME_CLOSED_FALSE_POSITIVE = 'closed_false_positive';
|
|
|
|
public const string OUTCOME_CLOSED_DUPLICATE = 'closed_duplicate';
|
|
|
|
public const string OUTCOME_CLOSED_NO_LONGER_APPLICABLE = 'closed_no_longer_applicable';
|
|
|
|
public const string OUTCOME_RISK_ACCEPTED = 'risk_accepted';
|
|
|
|
/**
|
|
* @return array{
|
|
* terminal_outcome_key: ?string,
|
|
* label: ?string,
|
|
* verification_state: string,
|
|
* verification_label: ?string,
|
|
* report_bucket: ?string
|
|
* }
|
|
*/
|
|
public function describe(Finding $finding): array
|
|
{
|
|
$terminalOutcomeKey = $this->terminalOutcomeKey($finding);
|
|
$verificationState = $this->verificationState($finding);
|
|
|
|
return [
|
|
'terminal_outcome_key' => $terminalOutcomeKey,
|
|
'label' => $terminalOutcomeKey !== null ? $this->terminalOutcomeLabel($terminalOutcomeKey) : null,
|
|
'verification_state' => $verificationState,
|
|
'verification_label' => $verificationState !== self::VERIFICATION_NOT_APPLICABLE
|
|
? $this->verificationStateLabel($verificationState)
|
|
: null,
|
|
'report_bucket' => $terminalOutcomeKey !== null ? $this->reportBucket($terminalOutcomeKey) : null,
|
|
];
|
|
}
|
|
|
|
public function terminalOutcomeKey(Finding $finding): ?string
|
|
{
|
|
return match ((string) $finding->status) {
|
|
Finding::STATUS_RESOLVED => $this->resolvedTerminalOutcomeKey((string) ($finding->resolved_reason ?? '')),
|
|
Finding::STATUS_CLOSED => $this->closedTerminalOutcomeKey((string) ($finding->closed_reason ?? '')),
|
|
Finding::STATUS_RISK_ACCEPTED => self::OUTCOME_RISK_ACCEPTED,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
public function verificationState(Finding $finding): string
|
|
{
|
|
if ((string) $finding->status !== Finding::STATUS_RESOLVED) {
|
|
return self::VERIFICATION_NOT_APPLICABLE;
|
|
}
|
|
|
|
$reason = (string) ($finding->resolved_reason ?? '');
|
|
|
|
if (Finding::isSystemResolveReason($reason)) {
|
|
return self::VERIFICATION_VERIFIED;
|
|
}
|
|
|
|
if (Finding::isManualResolveReason($reason)) {
|
|
return self::VERIFICATION_PENDING;
|
|
}
|
|
|
|
return self::VERIFICATION_NOT_APPLICABLE;
|
|
}
|
|
|
|
public function systemReopenReasonFor(Finding $finding): string
|
|
{
|
|
return $this->verificationState($finding) === self::VERIFICATION_PENDING
|
|
? Finding::REOPEN_REASON_VERIFICATION_FAILED
|
|
: Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function terminalOutcomeOptions(): array
|
|
{
|
|
return [
|
|
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => $this->terminalOutcomeLabel(self::OUTCOME_RESOLVED_PENDING_VERIFICATION),
|
|
self::OUTCOME_VERIFIED_CLEARED => $this->terminalOutcomeLabel(self::OUTCOME_VERIFIED_CLEARED),
|
|
self::OUTCOME_CLOSED_FALSE_POSITIVE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_FALSE_POSITIVE),
|
|
self::OUTCOME_CLOSED_DUPLICATE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_DUPLICATE),
|
|
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE),
|
|
self::OUTCOME_RISK_ACCEPTED => $this->terminalOutcomeLabel(self::OUTCOME_RISK_ACCEPTED),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function verificationStateOptions(): array
|
|
{
|
|
return [
|
|
self::VERIFICATION_PENDING => $this->verificationStateLabel(self::VERIFICATION_PENDING),
|
|
self::VERIFICATION_VERIFIED => $this->verificationStateLabel(self::VERIFICATION_VERIFIED),
|
|
self::VERIFICATION_NOT_APPLICABLE => $this->verificationStateLabel(self::VERIFICATION_NOT_APPLICABLE),
|
|
];
|
|
}
|
|
|
|
public function terminalOutcomeLabel(string $terminalOutcomeKey): string
|
|
{
|
|
return match ($terminalOutcomeKey) {
|
|
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification',
|
|
self::OUTCOME_VERIFIED_CLEARED => 'Verified cleared',
|
|
self::OUTCOME_CLOSED_FALSE_POSITIVE => 'Closed as false positive',
|
|
self::OUTCOME_CLOSED_DUPLICATE => 'Closed as duplicate',
|
|
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'Closed as no longer applicable',
|
|
self::OUTCOME_RISK_ACCEPTED => 'Risk accepted',
|
|
default => 'Unknown outcome',
|
|
};
|
|
}
|
|
|
|
public function verificationStateLabel(string $verificationState): string
|
|
{
|
|
return match ($verificationState) {
|
|
self::VERIFICATION_PENDING => 'Pending verification',
|
|
self::VERIFICATION_VERIFIED => 'Verified cleared',
|
|
default => 'Not applicable',
|
|
};
|
|
}
|
|
|
|
public function reportBucket(string $terminalOutcomeKey): string
|
|
{
|
|
return match ($terminalOutcomeKey) {
|
|
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'remediation_pending_verification',
|
|
self::OUTCOME_VERIFIED_CLEARED => 'remediation_verified',
|
|
self::OUTCOME_RISK_ACCEPTED => 'accepted_risk',
|
|
default => 'administrative_closure',
|
|
};
|
|
}
|
|
|
|
public function compactOutcomeSummary(array $counts): ?string
|
|
{
|
|
$parts = [];
|
|
|
|
foreach ($this->orderedOutcomeKeys() as $outcomeKey) {
|
|
$count = (int) ($counts[$outcomeKey] ?? 0);
|
|
|
|
if ($count < 1) {
|
|
continue;
|
|
}
|
|
|
|
$parts[] = sprintf('%d %s', $count, strtolower($this->terminalOutcomeLabel($outcomeKey)));
|
|
}
|
|
|
|
return $parts === [] ? null : implode(', ', $parts);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public function orderedOutcomeKeys(): array
|
|
{
|
|
return [
|
|
self::OUTCOME_RESOLVED_PENDING_VERIFICATION,
|
|
self::OUTCOME_VERIFIED_CLEARED,
|
|
self::OUTCOME_CLOSED_FALSE_POSITIVE,
|
|
self::OUTCOME_CLOSED_DUPLICATE,
|
|
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
|
|
self::OUTCOME_RISK_ACCEPTED,
|
|
];
|
|
}
|
|
|
|
private function resolvedTerminalOutcomeKey(string $reason): ?string
|
|
{
|
|
if (Finding::isSystemResolveReason($reason)) {
|
|
return self::OUTCOME_VERIFIED_CLEARED;
|
|
}
|
|
|
|
if (Finding::isManualResolveReason($reason)) {
|
|
return self::OUTCOME_RESOLVED_PENDING_VERIFICATION;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function closedTerminalOutcomeKey(string $reason): ?string
|
|
{
|
|
return match ($reason) {
|
|
Finding::CLOSE_REASON_FALSE_POSITIVE => self::OUTCOME_CLOSED_FALSE_POSITIVE,
|
|
Finding::CLOSE_REASON_DUPLICATE => self::OUTCOME_CLOSED_DUPLICATE,
|
|
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
|
|
default => null,
|
|
};
|
|
}
|
|
}
|