feat: implement finding outcome taxonomy (#267)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
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
This commit is contained in:
parent
76334cb096
commit
421261a517
5
.github/agents/copilot-instructions.md
vendored
5
.github/agents/copilot-instructions.md
vendored
@ -238,6 +238,8 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
|
||||
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
|
||||
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
|
||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy)
|
||||
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -272,10 +274,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger`
|
||||
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`)
|
||||
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives
|
||||
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
|
||||
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
@ -352,7 +353,14 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
|
||||
|
||||
private function reviewOutcomeDescription(TenantReview $record): ?string
|
||||
{
|
||||
return $this->reviewOutcome($record)->primaryReason;
|
||||
$primaryReason = $this->reviewOutcome($record)->primaryReason;
|
||||
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
|
||||
|
||||
if ($findingOutcomeSummary === null) {
|
||||
return $primaryReason;
|
||||
}
|
||||
|
||||
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
||||
}
|
||||
|
||||
private function reviewOutcomeNextStep(TenantReview $record): string
|
||||
@ -373,4 +381,16 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr
|
||||
SurfaceCompressionContext::reviewRegister(),
|
||||
);
|
||||
}
|
||||
|
||||
private function findingOutcomeSummary(TenantReview $record): ?string
|
||||
{
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
||||
|
||||
if (! is_array($outcomeCounts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
@ -156,6 +157,14 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||
TextEntry::make('finding_terminal_outcome')
|
||||
->label('Terminal outcome')
|
||||
->state(fn (Finding $record): ?string => static::terminalOutcomeLabel($record))
|
||||
->visible(fn (Finding $record): bool => static::terminalOutcomeLabel($record) !== null),
|
||||
TextEntry::make('finding_verification_state')
|
||||
->label('Verification')
|
||||
->state(fn (Finding $record): ?string => static::verificationStateLabel($record))
|
||||
->visible(fn (Finding $record): bool => static::verificationStateLabel($record) !== null),
|
||||
TextEntry::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||
@ -292,9 +301,15 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'),
|
||||
TextEntry::make('resolved_reason')
|
||||
->label('Resolved reason')
|
||||
->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'),
|
||||
TextEntry::make('closed_reason')
|
||||
->label('Closed/risk reason')
|
||||
->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('closed_by_user_id')
|
||||
->label('Closed by')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||
@ -726,7 +741,7 @@ public static function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||
->description(fn (Finding $record): string => static::statusDescription($record)),
|
||||
Tables\Columns\TextColumn::make('governance_validity')
|
||||
->label('Governance')
|
||||
->badge()
|
||||
@ -820,6 +835,14 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::findingStatuses())
|
||||
->label('Status'),
|
||||
Tables\Filters\SelectFilter::make('terminal_outcome')
|
||||
->label('Terminal outcome')
|
||||
->options(FilterOptionCatalog::findingTerminalOutcomes())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyTerminalOutcomeFilter($query, $data['value'] ?? null)),
|
||||
Tables\Filters\SelectFilter::make('verification_state')
|
||||
->label('Verification')
|
||||
->options(FilterOptionCatalog::findingVerificationStates())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyVerificationStateFilter($query, $data['value'] ?? null)),
|
||||
Tables\Filters\SelectFilter::make('workflow_family')
|
||||
->label('Workflow family')
|
||||
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
||||
@ -1092,16 +1115,20 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('resolve_selected')
|
||||
->label('Resolve selected')
|
||||
->label(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription)
|
||||
->form([
|
||||
Textarea::make('resolved_reason')
|
||||
->label('Resolution reason')
|
||||
->rows(3)
|
||||
Select::make('resolved_reason')
|
||||
->label('Resolution outcome')
|
||||
->options(static::resolveReasonOptions())
|
||||
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -1145,7 +1172,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
}
|
||||
|
||||
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.';
|
||||
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's')." pending verification.";
|
||||
if ($skippedCount > 0) {
|
||||
$body .= " Skipped {$skippedCount}.";
|
||||
}
|
||||
@ -1167,18 +1194,20 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('close_selected')
|
||||
->label('Close selected')
|
||||
->label(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
Select::make('closed_reason')
|
||||
->label('Close reason')
|
||||
->rows(3)
|
||||
->options(static::closeReasonOptions())
|
||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -1448,24 +1477,30 @@ public static function assignAction(): Actions\Action
|
||||
|
||||
public static function resolveAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('resolve_finding');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('resolve')
|
||||
->label('Resolve')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('resolved_reason')
|
||||
->label('Resolution reason')
|
||||
->rows(3)
|
||||
Select::make('resolved_reason')
|
||||
->label('Resolution outcome')
|
||||
->options(static::resolveReasonOptions())
|
||||
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding resolved',
|
||||
successTitle: $rule->successTitle,
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
|
||||
$finding,
|
||||
$tenant,
|
||||
@ -1495,11 +1530,13 @@ public static function closeAction(): Actions\Action
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
Select::make('closed_reason')
|
||||
->label('Close reason')
|
||||
->rows(3)
|
||||
->options(static::closeReasonOptions())
|
||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
@ -1694,12 +1731,17 @@ public static function reopenAction(): Actions\Action
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||
->fillForm([
|
||||
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
])
|
||||
->form([
|
||||
Textarea::make('reopen_reason')
|
||||
Select::make('reopen_reason')
|
||||
->label('Reopen reason')
|
||||
->rows(3)
|
||||
->options(static::reopenReasonOptions())
|
||||
->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
@ -2138,6 +2180,150 @@ private static function governanceValidityState(Finding $finding): ?string
|
||||
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
||||
}
|
||||
|
||||
private static function findingOutcomeSemantics(): FindingOutcomeSemantics
|
||||
{
|
||||
return app(FindingOutcomeSemantics::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* terminal_outcome_key: ?string,
|
||||
* label: ?string,
|
||||
* verification_state: string,
|
||||
* verification_label: ?string,
|
||||
* report_bucket: ?string
|
||||
* }
|
||||
*/
|
||||
private static function findingOutcome(Finding $finding): array
|
||||
{
|
||||
return static::findingOutcomeSemantics()->describe($finding);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function resolveReasonOptions(): array
|
||||
{
|
||||
return [
|
||||
Finding::RESOLVE_REASON_REMEDIATED => 'Remediated',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function closeReasonOptions(): array
|
||||
{
|
||||
return [
|
||||
Finding::CLOSE_REASON_FALSE_POSITIVE => 'False positive',
|
||||
Finding::CLOSE_REASON_DUPLICATE => 'Duplicate',
|
||||
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => 'No longer applicable',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function reopenReasonOptions(): array
|
||||
{
|
||||
return [
|
||||
Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION => 'Recurred after resolution',
|
||||
Finding::REOPEN_REASON_VERIFICATION_FAILED => 'Verification failed',
|
||||
Finding::REOPEN_REASON_MANUAL_REASSESSMENT => 'Manual reassessment',
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveReasonLabel(?string $reason): ?string
|
||||
{
|
||||
return static::resolveReasonOptions()[$reason] ?? match ($reason) {
|
||||
Finding::RESOLVE_REASON_NO_LONGER_DRIFTING => 'No longer drifting',
|
||||
Finding::RESOLVE_REASON_PERMISSION_GRANTED => 'Permission granted',
|
||||
Finding::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY => 'Permission removed from registry',
|
||||
Finding::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED => 'Role assignment removed',
|
||||
Finding::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD => 'GA count within threshold',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function closeReasonLabel(?string $reason): ?string
|
||||
{
|
||||
return static::closeReasonOptions()[$reason] ?? match ($reason) {
|
||||
Finding::CLOSE_REASON_ACCEPTED_RISK => 'Accepted risk',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function reopenReasonLabel(?string $reason): ?string
|
||||
{
|
||||
return static::reopenReasonOptions()[$reason] ?? null;
|
||||
}
|
||||
|
||||
private static function terminalOutcomeLabel(Finding $finding): ?string
|
||||
{
|
||||
return static::findingOutcome($finding)['label'] ?? null;
|
||||
}
|
||||
|
||||
private static function verificationStateLabel(Finding $finding): ?string
|
||||
{
|
||||
return static::findingOutcome($finding)['verification_label'] ?? null;
|
||||
}
|
||||
|
||||
private static function statusDescription(Finding $finding): string
|
||||
{
|
||||
return static::terminalOutcomeLabel($finding) ?? static::primaryNarrative($finding);
|
||||
}
|
||||
|
||||
private static function applyTerminalOutcomeFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => $query
|
||||
->where('status', Finding::STATUS_CLOSED)
|
||||
->where('closed_reason', Finding::CLOSE_REASON_FALSE_POSITIVE),
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => $query
|
||||
->where('status', Finding::STATUS_CLOSED)
|
||||
->where('closed_reason', Finding::CLOSE_REASON_DUPLICATE),
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $query
|
||||
->where('status', Finding::STATUS_CLOSED)
|
||||
->where('closed_reason', Finding::CLOSE_REASON_NO_LONGER_APPLICABLE),
|
||||
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
|
||||
private static function applyVerificationStateFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
FindingOutcomeSemantics::VERIFICATION_PENDING => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::VERIFICATION_VERIFIED => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::VERIFICATION_NOT_APPLICABLE => $query->where(function (Builder $verificationQuery): void {
|
||||
$verificationQuery
|
||||
->where('status', '!=', Finding::STATUS_RESOLVED)
|
||||
->orWhereNull('resolved_reason')
|
||||
->orWhereNotIn('resolved_reason', Finding::resolveReasonKeys());
|
||||
}),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
|
||||
private static function primaryNarrative(Finding $finding): string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -540,12 +541,19 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
$truthEnvelope = static::truthEnvelope($record);
|
||||
$reasonPresenter = app(ReasonPresenter::class);
|
||||
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
||||
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||
|
||||
if ($findingOutcomeSummary !== null) {
|
||||
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
|
||||
}
|
||||
|
||||
return [
|
||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||
'highlights' => $highlights,
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
@ -554,6 +562,8 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -655,4 +665,18 @@ private static function compressedOutcome(TenantReview $record, bool $fresh = fa
|
||||
SurfaceCompressionContext::tenantReview(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
*/
|
||||
private static function findingOutcomeSummary(array $summary): ?string
|
||||
{
|
||||
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
||||
|
||||
if (! is_array($outcomeCounts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -345,9 +345,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $backfillStartedAt,
|
||||
'resolved_reason' => 'consolidated_duplicate',
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
|
||||
@ -325,9 +325,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $backfillStartedAt,
|
||||
'resolved_reason' => 'consolidated_duplicate',
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
|
||||
@ -47,6 +47,32 @@ class Finding extends Model
|
||||
|
||||
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
|
||||
|
||||
public const string RESOLVE_REASON_REMEDIATED = 'remediated';
|
||||
|
||||
public const string RESOLVE_REASON_NO_LONGER_DRIFTING = 'no_longer_drifting';
|
||||
|
||||
public const string RESOLVE_REASON_PERMISSION_GRANTED = 'permission_granted';
|
||||
|
||||
public const string RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY = 'permission_removed_from_registry';
|
||||
|
||||
public const string RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED = 'role_assignment_removed';
|
||||
|
||||
public const string RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD = 'ga_count_within_threshold';
|
||||
|
||||
public const string CLOSE_REASON_FALSE_POSITIVE = 'false_positive';
|
||||
|
||||
public const string CLOSE_REASON_DUPLICATE = 'duplicate';
|
||||
|
||||
public const string CLOSE_REASON_NO_LONGER_APPLICABLE = 'no_longer_applicable';
|
||||
|
||||
public const string CLOSE_REASON_ACCEPTED_RISK = 'accepted_risk';
|
||||
|
||||
public const string REOPEN_REASON_RECURRED_AFTER_RESOLUTION = 'recurred_after_resolution';
|
||||
|
||||
public const string REOPEN_REASON_VERIFICATION_FAILED = 'verification_failed';
|
||||
|
||||
public const string REOPEN_REASON_MANUAL_REASSESSMENT = 'manual_reassessment';
|
||||
|
||||
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
|
||||
|
||||
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
|
||||
@ -160,6 +186,113 @@ public static function highSeverityValues(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function manualResolveReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::RESOLVE_REASON_REMEDIATED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function systemResolveReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
self::RESOLVE_REASON_PERMISSION_GRANTED,
|
||||
self::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY,
|
||||
self::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED,
|
||||
self::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function resolveReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
...self::manualResolveReasonKeys(),
|
||||
...self::systemResolveReasonKeys(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function closeReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::CLOSE_REASON_FALSE_POSITIVE,
|
||||
self::CLOSE_REASON_DUPLICATE,
|
||||
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
||||
self::CLOSE_REASON_ACCEPTED_RISK,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function manualCloseReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::CLOSE_REASON_FALSE_POSITIVE,
|
||||
self::CLOSE_REASON_DUPLICATE,
|
||||
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function reopenReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::REOPEN_REASON_RECURRED_AFTER_RESOLUTION,
|
||||
self::REOPEN_REASON_VERIFICATION_FAILED,
|
||||
self::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isResolveReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::resolveReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isManualResolveReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::manualResolveReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isSystemResolveReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::systemResolveReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isCloseReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::closeReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isManualCloseReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::manualCloseReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isRiskAcceptedReason(?string $reason): bool
|
||||
{
|
||||
return $reason === self::CLOSE_REASON_ACCEPTED_RISK;
|
||||
}
|
||||
|
||||
public static function isReopenReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::reopenReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function canonicalizeStatus(?string $status): ?string
|
||||
{
|
||||
if ($status === self::STATUS_ACKNOWLEDGED) {
|
||||
|
||||
@ -213,6 +213,12 @@ public function buildSnapshotPayload(Tenant $tenant): array
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
], $items),
|
||||
'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null)
|
||||
? $findingsSummary['outcome_counts']
|
||||
: [],
|
||||
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
|
||||
? $findingsSummary['report_bucket_counts']
|
||||
: [],
|
||||
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
||||
? $findingsSummary['risk_acceptance']
|
||||
: [
|
||||
|
||||
@ -8,12 +8,14 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class FindingsSummarySource implements EvidenceSourceProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
public function key(): string
|
||||
@ -33,6 +35,7 @@ public function collect(Tenant $tenant): array
|
||||
$entries = $findings->map(function (Finding $finding): array {
|
||||
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
|
||||
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
|
||||
$outcome = $this->findingOutcomeSemantics->describe($finding);
|
||||
|
||||
return [
|
||||
'id' => (int) $finding->getKey(),
|
||||
@ -43,10 +46,42 @@ public function collect(Tenant $tenant): array
|
||||
'description' => $finding->description,
|
||||
'created_at' => $finding->created_at?->toIso8601String(),
|
||||
'updated_at' => $finding->updated_at?->toIso8601String(),
|
||||
'verification_state' => $outcome['verification_state'],
|
||||
'report_bucket' => $outcome['report_bucket'],
|
||||
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
|
||||
'terminal_outcome_label' => $outcome['label'],
|
||||
'terminal_outcome' => $outcome['terminal_outcome_key'] !== null ? [
|
||||
'key' => $outcome['terminal_outcome_key'],
|
||||
'label' => $outcome['label'],
|
||||
'verification_state' => $outcome['verification_state'],
|
||||
'report_bucket' => $outcome['report_bucket'],
|
||||
'governance_state' => $governanceState,
|
||||
] : null,
|
||||
'governance_state' => $governanceState,
|
||||
'governance_warning' => $governanceWarning,
|
||||
];
|
||||
});
|
||||
$outcomeCounts = array_fill_keys($this->findingOutcomeSemantics->orderedOutcomeKeys(), 0);
|
||||
$reportBucketCounts = [
|
||||
'remediation_pending_verification' => 0,
|
||||
'remediation_verified' => 0,
|
||||
'administrative_closure' => 0,
|
||||
'accepted_risk' => 0,
|
||||
];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$terminalOutcomeKey = $entry['terminal_outcome_key'] ?? null;
|
||||
$reportBucket = $entry['report_bucket'] ?? null;
|
||||
|
||||
if (is_string($terminalOutcomeKey) && array_key_exists($terminalOutcomeKey, $outcomeCounts)) {
|
||||
$outcomeCounts[$terminalOutcomeKey]++;
|
||||
}
|
||||
|
||||
if (is_string($reportBucket) && array_key_exists($reportBucket, $reportBucketCounts)) {
|
||||
$reportBucketCounts[$reportBucket]++;
|
||||
}
|
||||
}
|
||||
|
||||
$riskAcceptedEntries = $entries->filter(
|
||||
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
|
||||
);
|
||||
@ -78,6 +113,8 @@ public function collect(Tenant $tenant): array
|
||||
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
|
||||
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
|
||||
],
|
||||
'outcome_counts' => $outcomeCounts,
|
||||
'report_bucket_counts' => $reportBucketCounts,
|
||||
'entries' => $entries->all(),
|
||||
];
|
||||
|
||||
|
||||
@ -857,7 +857,7 @@ private function evidenceSummary(array $references): array
|
||||
|
||||
private function findingRiskAcceptedReason(string $approvalReason): string
|
||||
{
|
||||
return mb_substr($approvalReason, 0, 255);
|
||||
return Finding::CLOSE_REASON_ACCEPTED_RISK;
|
||||
}
|
||||
|
||||
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
|
||||
|
||||
@ -7,11 +7,16 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class FindingRiskGovernanceResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
public function resolveWorkflowFamily(Finding $finding): string
|
||||
{
|
||||
return match (Finding::canonicalizeStatus((string) $finding->status)) {
|
||||
@ -218,11 +223,7 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc
|
||||
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
|
||||
? 'Accepted risk remains visible because current governance is still valid.'
|
||||
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
|
||||
'historical' => match ((string) $finding->status) {
|
||||
Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||
default => 'This finding is historical workflow context.',
|
||||
},
|
||||
'historical' => $this->historicalPrimaryNarrative($finding),
|
||||
default => match ($finding->responsibilityState()) {
|
||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
|
||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
|
||||
@ -253,8 +254,14 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
|
||||
};
|
||||
}
|
||||
|
||||
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) {
|
||||
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
|
||||
if ((string) $finding->status === Finding::STATUS_RESOLVED) {
|
||||
return $this->findingOutcomeSemantics->verificationState($finding) === FindingOutcomeSemantics::VERIFICATION_PENDING
|
||||
? 'Wait for later trusted evidence to confirm the issue is actually clear, or reopen the finding if verification still fails.'
|
||||
: 'Keep the finding closed unless later trusted evidence shows the issue has returned.';
|
||||
}
|
||||
|
||||
if ((string) $finding->status === Finding::STATUS_CLOSED) {
|
||||
return 'Review the administrative closure context and reopen the finding if the tenant reality no longer matches that decision.';
|
||||
}
|
||||
|
||||
return match ($finding->responsibilityState()) {
|
||||
@ -340,23 +347,33 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
||||
|
||||
private function resolvedHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
$reason = (string) ($finding->resolved_reason ?? '');
|
||||
|
||||
return match ($reason) {
|
||||
'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.',
|
||||
'permission_granted',
|
||||
'permission_removed_from_registry',
|
||||
'role_assignment_removed',
|
||||
'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.',
|
||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'This finding was resolved manually and is still waiting for trusted evidence to confirm the issue is actually gone.',
|
||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Trusted evidence later confirmed the triggering condition was no longer present at the last observed check.',
|
||||
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
|
||||
};
|
||||
}
|
||||
|
||||
private function closedHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
return match ((string) ($finding->closed_reason ?? '')) {
|
||||
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => 'This finding was closed as a false positive, which is an administrative closure rather than proof of remediation.',
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => 'This finding was closed as a duplicate, which is an administrative closure rather than proof of remediation.',
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding was closed as no longer applicable, which is an administrative closure rather than proof of remediation.',
|
||||
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
|
||||
};
|
||||
}
|
||||
|
||||
private function historicalPrimaryNarrative(Finding $finding): string
|
||||
{
|
||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification means an operator declared the remediation complete, but trusted verification has not confirmed it yet.',
|
||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Verified cleared means trusted evidence later confirmed the issue was no longer present.',
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE,
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE,
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding is closed for an administrative reason and should not be read as a remediation outcome.',
|
||||
default => 'This finding is historical workflow context.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -28,6 +29,7 @@ public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly FindingNotificationService $findingNotificationService,
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -273,7 +275,7 @@ public function resolve(Finding $finding, Tenant $tenant, User $actor, string $r
|
||||
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason', Finding::manualResolveReasonKeys());
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -299,7 +301,7 @@ public function close(Finding $finding, Tenant $tenant, User $actor, string $rea
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]);
|
||||
|
||||
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||
$reason = $this->validatedReason($reason, 'closed_reason', Finding::manualCloseReasonKeys());
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -342,7 +344,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
|
||||
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||
$reason = $this->validatedReason($reason, 'closed_reason', [Finding::CLOSE_REASON_ACCEPTED_RISK]);
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -376,7 +378,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor, string $re
|
||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'reopen_reason');
|
||||
$reason = $this->validatedReason($reason, 'reopen_reason', Finding::reopenReasonKeys());
|
||||
$now = CarbonImmutable::now();
|
||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
|
||||
@ -418,11 +420,11 @@ public function resolveBySystem(
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
if (! $finding->hasOpenStatus()) {
|
||||
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||
if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) {
|
||||
throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys());
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
@ -456,6 +458,7 @@ public function reopenBySystem(
|
||||
CarbonImmutable $reopenedAt,
|
||||
?int $operationRunId = null,
|
||||
?callable $mutate = null,
|
||||
?string $reason = null,
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
@ -463,6 +466,11 @@ public function reopenBySystem(
|
||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason(
|
||||
$reason ?? $this->findingOutcomeSemantics->systemReopenReasonFor($finding),
|
||||
'reopen_reason',
|
||||
Finding::reopenReasonKeys(),
|
||||
);
|
||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
||||
|
||||
@ -474,6 +482,7 @@ public function reopenBySystem(
|
||||
context: [
|
||||
'metadata' => [
|
||||
'reopened_at' => $reopenedAt->toIso8601String(),
|
||||
'reopened_reason' => $reason,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $dueAt->toIso8601String(),
|
||||
'system_origin' => true,
|
||||
@ -574,7 +583,10 @@ private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $
|
||||
}
|
||||
}
|
||||
|
||||
private function validatedReason(string $reason, string $field): string
|
||||
/**
|
||||
* @param array<int, string> $allowedReasons
|
||||
*/
|
||||
private function validatedReason(string $reason, string $field, array $allowedReasons): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
@ -586,6 +598,14 @@ private function validatedReason(string $reason, string $field): string
|
||||
throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field));
|
||||
}
|
||||
|
||||
if (! in_array($reason, $allowedReasons, true)) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'%s must be one of: %s.',
|
||||
$field,
|
||||
implode(', ', $allowedReasons),
|
||||
));
|
||||
}
|
||||
|
||||
return $reason;
|
||||
}
|
||||
|
||||
@ -637,12 +657,17 @@ private function mutateAndAudit(
|
||||
$record->save();
|
||||
|
||||
$after = $this->auditSnapshot($record);
|
||||
$outcome = $this->findingOutcomeSemantics->describe($record);
|
||||
$auditMetadata = array_merge($metadata, [
|
||||
'finding_id' => (int) $record->getKey(),
|
||||
'before_status' => $before['status'] ?? null,
|
||||
'after_status' => $after['status'] ?? null,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
|
||||
'terminal_outcome_label' => $outcome['label'],
|
||||
'verification_state' => $outcome['verification_state'],
|
||||
'report_bucket' => $outcome['report_bucket'],
|
||||
'_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
|
||||
]);
|
||||
|
||||
@ -713,6 +738,7 @@ private function dedupeKey(
|
||||
'owner_user_id' => $metadata['owner_user_id'] ?? null,
|
||||
'resolved_reason' => $metadata['resolved_reason'] ?? null,
|
||||
'closed_reason' => $metadata['closed_reason'] ?? null,
|
||||
'reopened_reason' => $metadata['reopened_reason'] ?? null,
|
||||
];
|
||||
|
||||
$encoded = json_encode($payload);
|
||||
|
||||
@ -83,6 +83,12 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [
|
||||
'finding_outcomes' => is_array($snapshot->summary['finding_outcomes'] ?? null)
|
||||
? $snapshot->summary['finding_outcomes']
|
||||
: [],
|
||||
'finding_report_buckets' => is_array($snapshot->summary['finding_report_buckets'] ?? null)
|
||||
? $snapshot->summary['finding_report_buckets']
|
||||
: [],
|
||||
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
|
||||
? $snapshot->summary['risk_acceptance']
|
||||
: [],
|
||||
@ -168,6 +174,12 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
||||
? $review->summary['finding_outcomes']
|
||||
: [],
|
||||
'finding_report_buckets' => is_array($review->summary['finding_report_buckets'] ?? null)
|
||||
? $review->summary['finding_report_buckets']
|
||||
: [],
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
|
||||
@ -59,6 +59,12 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
||||
'publish_blockers' => $blockers,
|
||||
'has_ready_export' => false,
|
||||
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
|
||||
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
|
||||
? data_get($sections, '0.summary_payload.finding_outcomes')
|
||||
: [],
|
||||
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
|
||||
? data_get($sections, '0.summary_payload.finding_report_buckets')
|
||||
: [],
|
||||
'report_count' => 2,
|
||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||
|
||||
@ -6,12 +6,17 @@
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class TenantReviewSectionFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
@ -47,6 +52,8 @@ private function executiveSummarySection(
|
||||
$rolesSummary = $this->summary($rolesItem);
|
||||
$baselineSummary = $this->summary($baselineItem);
|
||||
$operationsSummary = $this->summary($operationsItem);
|
||||
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
|
||||
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
|
||||
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
||||
|
||||
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
||||
@ -55,9 +62,11 @@ private function executiveSummarySection(
|
||||
$postureScore = $permissionSummary['posture_score'] ?? null;
|
||||
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
|
||||
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
|
||||
$outcomeSummary = $this->findingOutcomeSemantics->compactOutcomeSummary($findingOutcomes);
|
||||
|
||||
$highlights = array_values(array_filter([
|
||||
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
|
||||
$outcomeSummary !== null ? 'Terminal outcomes: '.$outcomeSummary.'.' : null,
|
||||
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
||||
sprintf('%d baseline drift findings remain open.', $driftCount),
|
||||
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
||||
@ -81,6 +90,8 @@ private function executiveSummarySection(
|
||||
'summary_payload' => [
|
||||
'finding_count' => $findingCount,
|
||||
'open_risk_count' => $openCount,
|
||||
'finding_outcomes' => $findingOutcomes,
|
||||
'finding_report_buckets' => $findingReportBuckets,
|
||||
'posture_score' => $postureScore,
|
||||
'baseline_drift_count' => $driftCount,
|
||||
'failed_operation_count' => $operationFailures,
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\RestoreRunStatus;
|
||||
@ -142,6 +143,22 @@ public static function findingWorkflowFamilies(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function findingTerminalOutcomes(): array
|
||||
{
|
||||
return app(FindingOutcomeSemantics::class)->terminalOutcomeOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function findingVerificationStates(): array
|
||||
{
|
||||
return app(FindingOutcomeSemantics::class)->verificationStateOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
203
apps/platform/app/Support/Findings/FindingOutcomeSemantics.php
Normal file
203
apps/platform/app/Support/Findings/FindingOutcomeSemantics.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -70,7 +70,7 @@ public static function families(): array
|
||||
'canonicalObject' => 'finding',
|
||||
'panels' => ['tenant'],
|
||||
'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'],
|
||||
'defaultActionOrder' => ['close_finding', 'reopen_finding'],
|
||||
'defaultActionOrder' => ['resolve_finding', 'close_finding', 'reopen_finding'],
|
||||
'supportsDocumentedDeviation' => false,
|
||||
'defaultMutationScopeSource' => 'finding lifecycle',
|
||||
],
|
||||
@ -260,6 +260,20 @@ public static function rules(): array
|
||||
serviceOwner: 'OperationRunTriageService',
|
||||
surfaceKeys: ['system_view_run'],
|
||||
),
|
||||
'resolve_finding' => new GovernanceActionRule(
|
||||
actionKey: 'resolve_finding',
|
||||
familyKey: 'finding_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Resolve',
|
||||
modalHeading: 'Resolve finding',
|
||||
modalDescription: 'Resolve this finding for the current tenant. TenantPilot records a canonical remediation outcome and keeps the finding in a pending-verification state until trusted evidence later confirms it is actually clear.',
|
||||
successTitle: 'Finding resolved pending verification',
|
||||
auditVerb: 'resolve finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
|
||||
),
|
||||
'close_finding' => new GovernanceActionRule(
|
||||
actionKey: 'close_finding',
|
||||
familyKey: 'finding_lifecycle',
|
||||
@ -268,7 +282,7 @@ public static function rules(): array
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Close',
|
||||
modalHeading: 'Close finding',
|
||||
modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.',
|
||||
modalDescription: 'Close this finding for the current tenant. TenantPilot records a canonical administrative closure reason such as false positive, duplicate, or no longer applicable.',
|
||||
successTitle: 'Finding closed',
|
||||
auditVerb: 'close finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
@ -282,7 +296,7 @@ public static function rules(): array
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Reopen',
|
||||
modalHeading: 'Reopen finding',
|
||||
modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.',
|
||||
modalDescription: 'Reopen this terminal finding for the current tenant. TenantPilot records a canonical reopen reason and recalculates due attention.',
|
||||
successTitle: 'Finding reopened',
|
||||
auditVerb: 'reopen finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
@ -489,6 +503,17 @@ public static function surfaceBindings(): array
|
||||
'uiFieldKey' => 'reason',
|
||||
'auditChannel' => 'system_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
|
||||
'actionName' => 'resolve',
|
||||
'familyKey' => 'finding_lifecycle',
|
||||
'statePredicate' => 'finding has open status',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'tenant_findings.resolve',
|
||||
'uiFieldKey' => 'resolved_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
|
||||
|
||||
@ -120,7 +120,16 @@ public function resolved(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'resolved_reason' => 'permission_granted',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifiedCleared(string $reason = Finding::RESOLVE_REASON_NO_LONGER_DRIFTING): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'resolved_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -176,7 +185,7 @@ public function closed(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_at' => now(),
|
||||
'closed_reason' => 'duplicate',
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -188,7 +197,7 @@ public function riskAccepted(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_at' => now(),
|
||||
'closed_reason' => 'accepted_risk',
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -1100,7 +1100,7 @@
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subMinute(),
|
||||
'resolved_reason' => 'manually_resolved',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$firstRun->update(['completed_at' => now()->subMinute()]);
|
||||
|
||||
@ -63,3 +63,52 @@
|
||||
->assertSee('Baseline compare')
|
||||
->assertSee('Operation #'.$run->getKey());
|
||||
});
|
||||
|
||||
it('shows canonical manual terminal outcome and verification labels on finding detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Terminal outcome')
|
||||
->assertSee('Resolved pending verification')
|
||||
->assertSee('Verification')
|
||||
->assertSee('Pending verification')
|
||||
->assertSee('Resolved reason')
|
||||
->assertSee('Remediated');
|
||||
});
|
||||
|
||||
it('shows verified clear and administrative closure labels on finding detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$verifiedFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
'resolved_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$closedFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $verifiedFinding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Verified cleared')
|
||||
->assertSee('No longer drifting');
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $closedFinding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Closed as duplicate')
|
||||
->assertSee('Duplicate');
|
||||
});
|
||||
|
||||
@ -20,9 +20,9 @@
|
||||
|
||||
$service->triage($finding, $tenant, $user);
|
||||
$service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey());
|
||||
$service->resolve($finding->refresh(), $tenant, $user, 'patched');
|
||||
$service->reopen($finding->refresh(), $tenant, $user, 'The issue recurred after validation.');
|
||||
$service->close($finding->refresh(), $tenant, $user, 'duplicate');
|
||||
$service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$service->reopen($finding->refresh(), $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
$service->close($finding->refresh(), $tenant, $user, Finding::CLOSE_REASON_DUPLICATE);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -37,14 +37,14 @@
|
||||
->and($closedAudit->targetDisplayLabel())->toContain('finding')
|
||||
->and(data_get($closedAudit->metadata, 'before_status'))->toBe(Finding::STATUS_REOPENED)
|
||||
->and(data_get($closedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_CLOSED)
|
||||
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate')
|
||||
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull()
|
||||
->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull();
|
||||
|
||||
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
|
||||
|
||||
expect($reopenedAudit)->not->toBeNull()
|
||||
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.');
|
||||
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
});
|
||||
|
||||
it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void {
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -24,7 +25,7 @@
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
$service->triage($finding, $tenant, $user);
|
||||
$service->resolve($finding->refresh(), $tenant, $user, 'fixed');
|
||||
$service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -40,7 +41,9 @@
|
||||
->and(data_get($audit->metadata, 'finding_id'))->toBe((int) $finding->getKey())
|
||||
->and(data_get($audit->metadata, 'before_status'))->toBe(Finding::STATUS_TRIAGED)
|
||||
->and(data_get($audit->metadata, 'after_status'))->toBe(Finding::STATUS_RESOLVED)
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe('fixed')
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and(data_get($audit->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->and(data_get($audit->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->and(data_get($audit->metadata, 'before'))->toBeArray()
|
||||
->and(data_get($audit->metadata, 'after'))->toBeArray()
|
||||
->and(data_get($audit->metadata, 'evidence_jsonb'))->toBeNull()
|
||||
|
||||
@ -120,7 +120,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit->actorSnapshot()->type)->toBe(AuditActorType::System)
|
||||
->and(data_get($audit->metadata, 'system_origin'))->toBeTrue()
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe('no_longer_drifting');
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING);
|
||||
});
|
||||
|
||||
it('writes system-origin audit rows for permission posture auto-resolve and recurrence reopen', function (): void {
|
||||
@ -185,7 +185,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
|
||||
|
||||
expect($resolvedAudit)->not->toBeNull()
|
||||
->and($resolvedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
|
||||
->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe('permission_granted')
|
||||
->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED)
|
||||
->and($reopenedAudit)->not->toBeNull()
|
||||
->and($reopenedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
|
||||
->and(data_get($reopenedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_REOPENED);
|
||||
@ -299,7 +299,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-03-18T09:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$run2 = OperationRun::factory()->create([
|
||||
|
||||
@ -91,7 +91,7 @@
|
||||
'subject_external_id' => 'policy-dupe',
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'recurrence_key' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'first_seen_at' => null,
|
||||
@ -126,9 +126,11 @@
|
||||
->and($open->status)->toBe(Finding::STATUS_NEW);
|
||||
|
||||
expect($duplicate->recurrence_key)->toBeNull()
|
||||
->and($duplicate->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($duplicate->resolved_reason)->toBe('consolidated_duplicate')
|
||||
->and($duplicate->resolved_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
|
||||
->and($duplicate->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($duplicate->resolved_reason)->toBeNull()
|
||||
->and($duplicate->resolved_at)->toBeNull()
|
||||
->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
|
||||
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
@ -88,7 +88,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
|
||||
$component
|
||||
->callTableBulkAction('resolve_selected', $resolveFindings, data: [
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
@ -96,7 +96,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_reason)->toBe('fixed')
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($finding->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -114,7 +114,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
|
||||
$component
|
||||
->callTableBulkAction('close_selected', $closeFindings, data: [
|
||||
'closed_reason' => 'not applicable',
|
||||
'closed_reason' => Finding::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
@ -122,7 +122,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($finding->closed_reason)->toBe('not applicable')
|
||||
->and($finding->closed_reason)->toBe(Finding::CLOSE_REASON_NO_LONGER_APPLICABLE)
|
||||
->and($finding->closed_at)->not->toBeNull()
|
||||
->and($finding->closed_by_user_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\Evidence\Sources\FindingsSummarySource;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedFindingOutcomeMatrix(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'pending_verification' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
]),
|
||||
'verified_cleared' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
]),
|
||||
'closed_duplicate' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]),
|
||||
'risk_accepted' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function materializeFindingOutcomeSnapshot(\App\Models\Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
$payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'dimension_key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
'source_kind' => $item['source_kind'],
|
||||
'source_record_type' => $item['source_record_type'],
|
||||
'source_record_id' => $item['source_record_id'],
|
||||
'source_fingerprint' => $item['source_fingerprint'],
|
||||
'measured_at' => $item['measured_at'],
|
||||
'freshness_at' => $item['freshness_at'],
|
||||
'summary_payload' => $item['summary_payload'],
|
||||
'sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot->load('items');
|
||||
}
|
||||
|
||||
it('summarizes canonical terminal outcomes and report buckets from findings evidence', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$findings = seedFindingOutcomeMatrix($tenant);
|
||||
|
||||
$summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'] ?? [];
|
||||
|
||||
expect(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.remediation_pending_verification'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.remediation_verified'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.administrative_closure'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.accepted_risk'))->toBe(1);
|
||||
|
||||
$pendingEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['pending_verification']->getKey());
|
||||
$verifiedEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['verified_cleared']->getKey());
|
||||
|
||||
expect(data_get($pendingEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->and(data_get($pendingEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->and(data_get($verifiedEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
|
||||
->and(data_get($verifiedEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED);
|
||||
});
|
||||
|
||||
it('propagates finding outcome summaries into evidence snapshots tenant reviews and review packs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
seedFindingOutcomeMatrix($tenant);
|
||||
|
||||
$snapshot = materializeFindingOutcomeSnapshot($tenant);
|
||||
|
||||
expect(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1)
|
||||
->and(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1)
|
||||
->and(data_get($snapshot->summary, 'finding_report_buckets.accepted_risk'))->toBe(1);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
|
||||
expect(data_get($review->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
|
||||
->and(data_get($review->summary, 'finding_report_buckets.administrative_closure'))->toBe(1);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Terminal outcomes:')
|
||||
->assertSee('resolved pending verification')
|
||||
->assertSee('verified cleared')
|
||||
->assertSee('closed as duplicate')
|
||||
->assertSee('risk accepted');
|
||||
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertCanSeeTableRecords([$review])
|
||||
->assertSee('Terminal outcomes:')
|
||||
->assertSee('resolved pending verification');
|
||||
|
||||
$pack = app(ReviewPackService::class)->generateFromReview($review, $user, [
|
||||
'include_pii' => false,
|
||||
'include_operations' => false,
|
||||
]);
|
||||
|
||||
expect(data_get($pack->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
|
||||
->and(data_get($pack->summary, 'finding_report_buckets.accepted_risk'))->toBe(1);
|
||||
});
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@ -213,7 +214,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -259,6 +260,11 @@ function baselineCompareRbacDriftItem(
|
||||
->and($finding->sla_days)->toBe($expectedSlaDays2)
|
||||
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt2->toIso8601String())
|
||||
->and((int) $finding->current_operation_run_id)->toBe((int) $run2->getKey());
|
||||
|
||||
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($reopenedAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_VERIFICATION_FAILED);
|
||||
});
|
||||
|
||||
it('keeps closed baseline compare drift findings terminal on recurrence but updates seen tracking', function (): void {
|
||||
@ -308,7 +314,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'closed_reason' => 'accepted',
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -396,7 +402,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-26T00:00:00Z'),
|
||||
'resolved_reason' => 'manual',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -485,7 +491,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -523,4 +529,9 @@ function baselineCompareRbacDriftItem(
|
||||
->and($finding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($finding->times_seen)->toBe(2)
|
||||
->and((string) data_get($finding->evidence_jsonb, 'rbac_role_definition.diff_fingerprint'))->toBe('rbac-diff-b');
|
||||
|
||||
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($reopenedAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION);
|
||||
});
|
||||
|
||||
@ -7,8 +7,10 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\Sources\FindingsSummarySource;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -171,3 +173,24 @@
|
||||
expect(app(FindingRiskGovernanceResolver::class)->resolveFindingState($finding->fresh('findingException')))
|
||||
->toBe('ungoverned');
|
||||
});
|
||||
|
||||
it('keeps accepted risk in a separate reporting bucket from administrative closures', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]);
|
||||
|
||||
Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]);
|
||||
|
||||
$summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'] ?? [];
|
||||
|
||||
expect(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.accepted_risk'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.administrative_closure'))->toBe(1);
|
||||
});
|
||||
|
||||
@ -100,7 +100,7 @@ function invokeConcurrencyBaselineCompareUpsertFindings(
|
||||
->firstOrFail();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T10:00:00Z'));
|
||||
app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'human-won');
|
||||
app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
|
||||
$run2 = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -156,7 +156,7 @@ function invokeConcurrencyBaselineCompareUpsertFindings(
|
||||
->firstOrFail();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T09:00:00Z'));
|
||||
app(FindingWorkflowService::class)->close($finding, $tenant, $user, 'accepted');
|
||||
app(FindingWorkflowService::class)->close($finding, $tenant, $user, Finding::CLOSE_REASON_DUPLICATE);
|
||||
|
||||
$run2 = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
|
||||
@ -47,19 +47,19 @@
|
||||
|
||||
$component
|
||||
->callTableAction('resolve', $finding, [
|
||||
'resolved_reason' => 'patched',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$finding->refresh();
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_reason)->toBe('patched')
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($finding->resolved_at)->not->toBeNull();
|
||||
|
||||
$component
|
||||
->filterTable('open', false)
|
||||
->callTableAction('reopen', $finding, [
|
||||
'reopen_reason' => 'The issue recurred in a later scan.',
|
||||
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
$component
|
||||
->callTableAction('close', $closeFinding, [
|
||||
'closed_reason' => 'duplicate ticket',
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
expect($closeFinding->refresh()->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($closeFinding->closed_reason)->toBe('duplicate ticket');
|
||||
->and($closeFinding->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE);
|
||||
|
||||
$exception = FindingException::query()
|
||||
->where('finding_id', (int) $exceptionFinding->getKey())
|
||||
|
||||
@ -6,9 +6,15 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('enforces the canonical transition matrix for service-driven status changes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -25,17 +31,24 @@
|
||||
expect($inProgressFinding->status)->toBe(Finding::STATUS_IN_PROGRESS)
|
||||
->and($this->latestFindingAudit($inProgressFinding, AuditActionId::FindingInProgress))->not->toBeNull();
|
||||
|
||||
$resolvedFinding = $service->resolve($inProgressFinding, $tenant, $user, 'patched');
|
||||
$resolvedFinding = $service->resolve($inProgressFinding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$resolvedAudit = $this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved);
|
||||
|
||||
expect($resolvedFinding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($resolvedFinding->resolved_reason)->toBe('patched')
|
||||
->and($this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved))->not->toBeNull();
|
||||
->and($resolvedFinding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($resolvedAudit)->not->toBeNull()
|
||||
->and(data_get($resolvedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->and(data_get($resolvedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->and(data_get($resolvedAudit?->metadata, 'report_bucket'))->toBe('remediation_pending_verification');
|
||||
|
||||
$reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, 'The issue recurred after remediation.');
|
||||
$reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
$reopenedAudit = $this->latestFindingAudit($reopenedFinding, AuditActionId::FindingReopened);
|
||||
|
||||
expect($reopenedFinding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($reopenedFinding->reopened_at)->not->toBeNull()
|
||||
->and($this->latestFindingAudit($reopenedFinding, AuditActionId::FindingReopened))->not->toBeNull();
|
||||
->and($reopenedAudit)->not->toBeNull()
|
||||
->and(data_get($reopenedAudit?->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT)
|
||||
->and(data_get($reopenedAudit?->metadata, 'terminal_outcome_key'))->toBeNull();
|
||||
|
||||
expect(fn () => $service->startProgress($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user))
|
||||
->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.');
|
||||
@ -118,14 +131,118 @@
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');
|
||||
});
|
||||
|
||||
it('enforces canonical manual reason keys for terminal workflow mutations', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
expect(fn () => $service->resolve($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'patched'))
|
||||
->toThrow(\InvalidArgumentException::class, 'resolved_reason must be one of: remediated.');
|
||||
|
||||
expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'not applicable'))
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: false_positive, duplicate, no_longer_applicable.');
|
||||
|
||||
expect(fn () => $service->reopen($this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED), $tenant, $user, 'please re-open'))
|
||||
->toThrow(\InvalidArgumentException::class, 'reopen_reason must be one of: recurred_after_resolution, verification_failed, manual_reassessment.');
|
||||
|
||||
expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'accepted'))
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: accepted_risk.');
|
||||
});
|
||||
|
||||
it('records canonical close and risk-accept outcome metadata', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
$closed = $service->close(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::CLOSE_REASON_DUPLICATE,
|
||||
);
|
||||
$closedAudit = $this->latestFindingAudit($closed, AuditActionId::FindingClosed);
|
||||
|
||||
expect($closed->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($closed->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and(data_get($closedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE)
|
||||
->and(data_get($closedAudit?->metadata, 'report_bucket'))->toBe('administrative_closure');
|
||||
|
||||
$riskAccepted = $service->riskAccept(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
);
|
||||
$riskAudit = $this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted);
|
||||
|
||||
expect($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
|
||||
->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK)
|
||||
->and(data_get($riskAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED)
|
||||
->and(data_get($riskAudit?->metadata, 'report_bucket'))->toBe('accepted_risk');
|
||||
});
|
||||
|
||||
it('distinguishes verified clear from manual resolution in system transitions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
CarbonImmutable::setTestNow('2026-04-23T10:00:00Z');
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
$manualResolved = $service->resolve(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::RESOLVE_REASON_REMEDIATED,
|
||||
);
|
||||
|
||||
$verified = $service->resolveBySystem(
|
||||
finding: $manualResolved,
|
||||
tenant: $tenant,
|
||||
reason: Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
resolvedAt: CarbonImmutable::parse('2026-04-23T11:00:00Z'),
|
||||
);
|
||||
$verifiedAudit = $this->latestFindingAudit($verified, AuditActionId::FindingResolved);
|
||||
|
||||
expect($verified->resolved_reason)->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING)
|
||||
->and(data_get($verifiedAudit?->metadata, 'system_origin'))->toBeTrue()
|
||||
->and(data_get($verifiedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
|
||||
->and(data_get($verifiedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED)
|
||||
->and(data_get($verifiedAudit?->metadata, 'report_bucket'))->toBe('remediation_verified');
|
||||
|
||||
$verificationFailed = $service->reopenBySystem(
|
||||
finding: $service->resolve(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::RESOLVE_REASON_REMEDIATED,
|
||||
),
|
||||
tenant: $tenant,
|
||||
reopenedAt: CarbonImmutable::parse('2026-04-23T12:00:00Z'),
|
||||
);
|
||||
$verificationFailedAudit = $this->latestFindingAudit($verificationFailed, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($verificationFailedAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_VERIFICATION_FAILED);
|
||||
|
||||
$recurred = $service->reopenBySystem(
|
||||
finding: $verified,
|
||||
tenant: $tenant,
|
||||
reopenedAt: CarbonImmutable::parse('2026-04-23T13:00:00Z'),
|
||||
);
|
||||
$recurredAudit = $this->latestFindingAudit($recurred, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($recurredAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION);
|
||||
});
|
||||
|
||||
it('returns 403 for in-scope members without the required workflow capability', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);
|
||||
|
||||
expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $readonly, 'patched'))
|
||||
expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $readonly, Finding::RESOLVE_REASON_REMEDIATED))
|
||||
->toThrow(AuthorizationException::class);
|
||||
|
||||
expect(app(FindingWorkflowService::class)->resolve($finding, $tenant, $owner, 'patched')->status)
|
||||
expect(app(FindingWorkflowService::class)->resolve($finding, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED)->status)
|
||||
->toBe(Finding::STATUS_RESOLVED);
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -21,7 +22,7 @@
|
||||
$resolvedFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
]);
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $newFinding->getKey()])
|
||||
@ -38,8 +39,10 @@
|
||||
->assertActionVisible('reopen')
|
||||
->mountAction('reopen')
|
||||
->assertActionMounted('reopen')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reopen_reason']);
|
||||
->assertFormFieldExists('reopen_reason', function (Select $field): bool {
|
||||
return $field->getLabel() === 'Reopen reason'
|
||||
&& array_keys($field->getOptions()) === Finding::reopenReasonKeys();
|
||||
});
|
||||
});
|
||||
|
||||
it('executes workflow actions from view header and supports assignment to tenant members only', function (): void {
|
||||
@ -65,7 +68,7 @@
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->callAction('resolve', [
|
||||
'resolved_reason' => 'handled in queue',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
@ -77,12 +80,13 @@
|
||||
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
||||
->mountAction('reopen')
|
||||
->assertActionMounted('reopen')
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reopen_reason']);
|
||||
->assertFormFieldExists('reopen_reason', function (Select $field): bool {
|
||||
return array_keys($field->getOptions()) === Finding::reopenReasonKeys();
|
||||
});
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
||||
->callAction('reopen', [
|
||||
'reopen_reason' => 'The finding recurred after remediation.',
|
||||
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->callAction('assign', [
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -92,7 +93,7 @@ function findingFilterIndicatorLabels($component): array
|
||||
$historical = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subDay(),
|
||||
'resolved_reason' => 'no_longer_drifting',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
@ -109,6 +110,59 @@ function findingFilterIndicatorLabels($component): array
|
||||
->assertCanNotSeeTableRecords([$active, $healthyAccepted, $historical]);
|
||||
});
|
||||
|
||||
it('filters findings by canonical terminal outcome and verification state', function (): void {
|
||||
[, $tenant] = actingAsFindingsManagerForFilters();
|
||||
|
||||
$pendingVerification = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
]);
|
||||
|
||||
$verifiedCleared = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
]);
|
||||
|
||||
$closedDuplicate = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]);
|
||||
|
||||
$riskAccepted = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]);
|
||||
|
||||
$openFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->assertCanSeeTableRecords([$pendingVerification])
|
||||
->assertCanNotSeeTableRecords([$verifiedCleared, $closedDuplicate, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
|
||||
->assertCanSeeTableRecords([$verifiedCleared])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $closedDuplicate, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE)
|
||||
->assertCanSeeTableRecords([$closedDuplicate])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $verifiedCleared, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED)
|
||||
->assertCanSeeTableRecords([$riskAccepted])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $verifiedCleared, $closedDuplicate, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('verification_state', FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->assertCanSeeTableRecords([$pendingVerification])
|
||||
->assertCanNotSeeTableRecords([$verifiedCleared, $closedDuplicate, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('verification_state', FindingOutcomeSemantics::VERIFICATION_VERIFIED)
|
||||
->assertCanSeeTableRecords([$verifiedCleared])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $closedDuplicate, $riskAccepted, $openFinding]);
|
||||
});
|
||||
|
||||
it('filters findings by high severity quick filter', function (): void {
|
||||
[, $tenant] = actingAsFindingsManagerForFilters();
|
||||
|
||||
|
||||
@ -12,11 +12,11 @@
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
|
||||
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'permission_granted');
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED);
|
||||
|
||||
$fresh = Finding::query()->find($finding->getKey());
|
||||
expect($fresh->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
@ -27,7 +27,7 @@
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->resolved()->create();
|
||||
|
||||
$finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The finding recurred after a later scan.');
|
||||
$finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($finding->reopened_at)->not->toBeNull()
|
||||
@ -39,11 +39,11 @@
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
|
||||
|
||||
$finding->resolve('permission_granted');
|
||||
$finding->resolve(Finding::RESOLVE_REASON_PERMISSION_GRANTED);
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED);
|
||||
|
||||
$finding->reopen([
|
||||
'display_name' => 'Recovered Permission',
|
||||
@ -103,7 +103,7 @@
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'permission_granted');
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->acknowledged_at)->not->toBeNull()
|
||||
@ -141,5 +141,5 @@
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED);
|
||||
});
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subDay(),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'closed_at' => now()->subHours(2),
|
||||
'closed_reason' => 'legacy-close',
|
||||
'closed_by_user_id' => $user->getKey(),
|
||||
@ -43,7 +43,7 @@
|
||||
'due_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The issue recurred after verification.');
|
||||
$reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
|
||||
expect($reopened->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Finding Outcome Taxonomy & Verification Semantics
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated in one pass. No open clarification markers remain.
|
||||
- The spec keeps the existing findings lifecycle and scopes the change to one bounded outcome taxonomy plus verification semantics; no new queue, entity, or status family is introduced.
|
||||
- Repo-required constitution and surface-guardrail references are treated as product governance constraints, not implementation design leakage.
|
||||
@ -0,0 +1,397 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Finding Outcome Taxonomy Logical Contract
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Logical contract for the findings outcome taxonomy feature. This is not a new
|
||||
public HTTP API commitment. It documents the request and response shapes that
|
||||
existing Filament and service workflows must converge on.
|
||||
|
||||
servers:
|
||||
- url: https://tenantpilot.local
|
||||
description: Logical base URL only
|
||||
|
||||
tags:
|
||||
- name: Findings
|
||||
- name: FindingsInternal
|
||||
|
||||
paths:
|
||||
/tenants/{tenantId}/findings:
|
||||
get:
|
||||
tags: [Findings]
|
||||
summary: List tenant findings with terminal-outcome filters
|
||||
operationId: listTenantFindings
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingStatus'
|
||||
- name: terminal_outcome
|
||||
in: query
|
||||
schema:
|
||||
$ref: '#/components/schemas/TerminalOutcomeKey'
|
||||
- name: verification_state
|
||||
in: query
|
||||
schema:
|
||||
$ref: '#/components/schemas/VerificationState'
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant findings list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FindingSummary'
|
||||
|
||||
/tenants/{tenantId}/findings/{findingId}:
|
||||
get:
|
||||
tags: [Findings]
|
||||
summary: Get one finding with current terminal-outcome semantics
|
||||
operationId: getTenantFinding
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
responses:
|
||||
'200':
|
||||
description: Finding detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingDetail'
|
||||
|
||||
/tenants/{tenantId}/findings/{findingId}/resolve:
|
||||
post:
|
||||
tags: [Findings]
|
||||
summary: Resolve a finding with a bounded operator reason
|
||||
operationId: resolveTenantFinding
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResolveFindingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Finding resolved pending verification
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingDetail'
|
||||
|
||||
/tenants/{tenantId}/findings/{findingId}/close:
|
||||
post:
|
||||
tags: [Findings]
|
||||
summary: Close a finding with a bounded administrative reason
|
||||
operationId: closeTenantFinding
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CloseFindingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Finding closed with a non-remediation outcome
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingDetail'
|
||||
|
||||
/tenants/{tenantId}/findings/{findingId}/reopen:
|
||||
post:
|
||||
tags: [Findings]
|
||||
summary: Reopen a terminal finding with a bounded reopen reason
|
||||
operationId: reopenTenantFinding
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReopenFindingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Finding reopened
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingDetail'
|
||||
|
||||
/internal/tenants/{tenantId}/findings/{findingId}/system-clear:
|
||||
post:
|
||||
tags: [FindingsInternal]
|
||||
summary: Apply a trusted system-clear reason
|
||||
operationId: systemClearFinding
|
||||
x-internal: true
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SystemClearFindingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Finding moved into a verified-cleared outcome
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingDetail'
|
||||
|
||||
/internal/tenants/{tenantId}/findings/{findingId}/system-reopen:
|
||||
post:
|
||||
tags: [FindingsInternal]
|
||||
summary: Reopen a terminal finding due to trusted recurrence or verification failure
|
||||
operationId: systemReopenFinding
|
||||
x-internal: true
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FindingId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SystemReopenFindingRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Finding reopened by trusted automation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindingDetail'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
FindingId:
|
||||
name: findingId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
schemas:
|
||||
FindingStatus:
|
||||
type: string
|
||||
enum:
|
||||
- new
|
||||
- acknowledged
|
||||
- triaged
|
||||
- in_progress
|
||||
- reopened
|
||||
- resolved
|
||||
- closed
|
||||
- risk_accepted
|
||||
|
||||
VerificationState:
|
||||
type: string
|
||||
enum:
|
||||
- pending_verification
|
||||
- verified_cleared
|
||||
- not_applicable
|
||||
|
||||
ResolveReasonKey:
|
||||
type: string
|
||||
enum:
|
||||
- remediated
|
||||
- no_longer_drifting
|
||||
- permission_granted
|
||||
- permission_removed_from_registry
|
||||
- role_assignment_removed
|
||||
- ga_count_within_threshold
|
||||
|
||||
CloseReasonKey:
|
||||
type: string
|
||||
enum:
|
||||
- false_positive
|
||||
- duplicate
|
||||
- no_longer_applicable
|
||||
- accepted_risk
|
||||
|
||||
ReopenReasonKey:
|
||||
type: string
|
||||
enum:
|
||||
- recurred_after_resolution
|
||||
- verification_failed
|
||||
- manual_reassessment
|
||||
|
||||
TerminalOutcomeKey:
|
||||
type: string
|
||||
enum:
|
||||
- resolved_pending_verification
|
||||
- verified_cleared
|
||||
- closed_false_positive
|
||||
- closed_duplicate
|
||||
- closed_no_longer_applicable
|
||||
- risk_accepted
|
||||
|
||||
TerminalOutcome:
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- label
|
||||
- verification_state
|
||||
- report_bucket
|
||||
properties:
|
||||
key:
|
||||
$ref: '#/components/schemas/TerminalOutcomeKey'
|
||||
label:
|
||||
type: string
|
||||
verification_state:
|
||||
$ref: '#/components/schemas/VerificationState'
|
||||
report_bucket:
|
||||
type: string
|
||||
enum:
|
||||
- remediation_pending_verification
|
||||
- remediation_verified
|
||||
- administrative_closure
|
||||
- accepted_risk
|
||||
governance_state:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Present only when the outcome depends on risk-governance validity.
|
||||
|
||||
ResolveFindingRequest:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
enum: [remediated]
|
||||
note:
|
||||
type: string
|
||||
maxLength: 255
|
||||
nullable: true
|
||||
|
||||
CloseFindingRequest:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- false_positive
|
||||
- duplicate
|
||||
- no_longer_applicable
|
||||
note:
|
||||
type: string
|
||||
maxLength: 255
|
||||
nullable: true
|
||||
|
||||
ReopenFindingRequest:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason:
|
||||
$ref: '#/components/schemas/ReopenReasonKey'
|
||||
note:
|
||||
type: string
|
||||
maxLength: 255
|
||||
nullable: true
|
||||
|
||||
SystemClearFindingRequest:
|
||||
type: object
|
||||
required: [reason, observed_at]
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- no_longer_drifting
|
||||
- permission_granted
|
||||
- permission_removed_from_registry
|
||||
- role_assignment_removed
|
||||
- ga_count_within_threshold
|
||||
observed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
SystemReopenFindingRequest:
|
||||
type: object
|
||||
required: [reason, observed_at]
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- recurred_after_resolution
|
||||
- verification_failed
|
||||
observed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
FindingSummary:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- tenant_id
|
||||
- status
|
||||
- severity
|
||||
- terminal_outcome
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
status:
|
||||
$ref: '#/components/schemas/FindingStatus'
|
||||
severity:
|
||||
type: string
|
||||
resolved_reason:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ResolveReasonKey'
|
||||
- type: 'null'
|
||||
closed_reason:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CloseReasonKey'
|
||||
- type: 'null'
|
||||
terminal_outcome:
|
||||
$ref: '#/components/schemas/TerminalOutcome'
|
||||
|
||||
FindingDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/FindingSummary'
|
||||
- type: object
|
||||
properties:
|
||||
resolved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
closed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
reopened_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
audit_context:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Logical placeholder for the readable audit/history payload.
|
||||
174
specs/231-finding-outcome-taxonomy/data-model.md
Normal file
174
specs/231-finding-outcome-taxonomy/data-model.md
Normal file
@ -0,0 +1,174 @@
|
||||
# Data Model: Finding Outcome Taxonomy & Verification Semantics
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not add a new table or a new top-level persisted entity. It reuses the current `findings` row as the source of truth for current terminal meaning, keeps reopen rationale in audit metadata, and derives verification or reporting buckets through one findings-local semantics helper.
|
||||
|
||||
## Entity: Finding
|
||||
|
||||
**Persistence**: existing `findings` table
|
||||
**Owner**: tenant-owned record
|
||||
**Primary responsibility**: current workflow status, current terminal-outcome key, timestamps, and tenant-scoped operational ownership
|
||||
|
||||
### Relevant persisted fields
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|------|------|--------|-------|
|
||||
| `id` | integer | existing | Primary key |
|
||||
| `workspace_id` | integer | existing | Workspace isolation boundary |
|
||||
| `tenant_id` | integer | existing | Tenant isolation boundary |
|
||||
| `status` | string | existing | Primary lifecycle status; remains one of `new`, `acknowledged`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, `risk_accepted` |
|
||||
| `severity` | string | existing | Existing priority and SLA signal |
|
||||
| `resolved_reason` | string nullable | existing | Becomes a bounded canonical resolve key instead of free-form prose |
|
||||
| `closed_reason` | string nullable | existing | Becomes a bounded canonical close key and remains the stored reason for `risk_accepted` |
|
||||
| `resolved_at` | datetime nullable | existing | Marks the current resolved terminal timestamp |
|
||||
| `closed_at` | datetime nullable | existing | Marks the current closed or risk-accepted terminal timestamp |
|
||||
| `reopened_at` | datetime nullable | existing | Marks the current reopen timestamp |
|
||||
| `closed_by_user_id` | integer nullable | existing | Current actor for close and risk-accept paths |
|
||||
| `owner_user_id` | integer nullable | existing | Accountability owner |
|
||||
| `assignee_user_id` | integer nullable | existing | Active assignee |
|
||||
| `evidence_jsonb` | jsonb | existing | Current supporting evidence; unchanged in this slice |
|
||||
|
||||
### Relationships
|
||||
|
||||
| Relationship | Type | Notes |
|
||||
|-------------|------|-------|
|
||||
| `tenant()` | belongsTo | Tenant scope owner |
|
||||
| `ownerUser()` | belongsTo | Accountability owner |
|
||||
| `assigneeUser()` | belongsTo | Active assignee |
|
||||
| `closedByUser()` | belongsTo | Actor for close/risk accept |
|
||||
| `findingException()` | hasOne | Existing risk-governance truth from Spec 154 |
|
||||
|
||||
### Validation rules introduced by this feature
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| `status` | No new status is introduced; all existing transition rules stay in force |
|
||||
| `resolved_reason` | Required for resolve and system-clear transitions; must be a canonical key from the resolve-reason family |
|
||||
| `closed_reason` | Required for close and risk-accept transitions; must be a canonical key from the close-reason family or the risk-accept key |
|
||||
| `reopen_reason` | Required for reopen transitions; remains audit metadata and must be a canonical key from the reopen-reason family |
|
||||
|
||||
### Canonical reason families
|
||||
|
||||
#### Resolve reason keys
|
||||
|
||||
| Key | Meaning | Derived verification state |
|
||||
|-----|---------|----------------------------|
|
||||
| `remediated` | Operator declares that remediation work was completed | `pending_verification` |
|
||||
| `no_longer_drifting` | Trusted compare no longer reproduces prior drift | `verified_cleared` |
|
||||
| `permission_granted` | Trusted permission evidence no longer reproduces the finding condition | `verified_cleared` |
|
||||
| `permission_removed_from_registry` | Trusted permission evidence confirms the triggering permission was removed | `verified_cleared` |
|
||||
| `role_assignment_removed` | Trusted role evidence confirms the triggering assignment was removed | `verified_cleared` |
|
||||
| `ga_count_within_threshold` | Trusted role evidence confirms the triggering count is now safe | `verified_cleared` |
|
||||
|
||||
#### Close reason keys
|
||||
|
||||
| Key | Meaning | Outcome family |
|
||||
|-----|---------|----------------|
|
||||
| `false_positive` | The finding should not have been actionable | `administrative_closure` |
|
||||
| `duplicate` | The finding duplicates another case | `administrative_closure` |
|
||||
| `no_longer_applicable` | The finding no longer applies to the tenant context | `administrative_closure` |
|
||||
| `accepted_risk` | Existing accepted-risk path only; still governed by exception validity | `accepted_risk` |
|
||||
|
||||
#### Reopen reason keys
|
||||
|
||||
| Key | Meaning | Persistence |
|
||||
|-----|---------|-------------|
|
||||
| `recurred_after_resolution` | A previously addressed condition reappeared | audit metadata only |
|
||||
| `verification_failed` | Trusted evidence contradicted the earlier resolved outcome | audit metadata only |
|
||||
| `manual_reassessment` | An operator reopened the finding after review | audit metadata only |
|
||||
|
||||
### Derived facets from the current row
|
||||
|
||||
| Derived facet | Source | Meaning |
|
||||
|--------------|--------|---------|
|
||||
| `verification_state` | `status` + `resolved_reason` | `pending_verification`, `verified_cleared`, or `not_applicable` |
|
||||
| `terminal_outcome_key` | `status` + canonical reason key | Stable UI/reporting key such as `resolved_pending_verification`, `verified_cleared`, `closed_false_positive`, `closed_duplicate`, `closed_no_longer_applicable`, or `risk_accepted` |
|
||||
| `report_bucket` | `terminal_outcome_key` + governance validity | Report-friendly aggregation bucket |
|
||||
| `outcome_label` | `terminal_outcome_key` | Canonical operator wording |
|
||||
|
||||
### State transitions
|
||||
|
||||
| From | Action | Stored result | Derived outcome |
|
||||
|------|--------|---------------|-----------------|
|
||||
| `new`, `triaged`, `in_progress`, `reopened`, `acknowledged` | `resolve(remediated)` | `status=resolved`, `resolved_reason=remediated` | `Resolved pending verification` |
|
||||
| open statuses | `close(false_positive)` | `status=closed`, `closed_reason=false_positive` | `Closed as false positive` |
|
||||
| open statuses | `close(duplicate)` | `status=closed`, `closed_reason=duplicate` | `Closed as duplicate` |
|
||||
| open statuses | `close(no_longer_applicable)` | `status=closed`, `closed_reason=no_longer_applicable` | `Closed as no longer applicable` |
|
||||
| open statuses | `riskAccept(accepted_risk)` | `status=risk_accepted`, `closed_reason=accepted_risk` | `Risk accepted` with separate governance validity |
|
||||
| `resolved` with `resolved_reason=remediated` | trusted system clear | `status=resolved`, `resolved_reason=<system clear key>` | `Verified cleared` |
|
||||
| open statuses | direct trusted system clear | `status=resolved`, `resolved_reason=<system clear key>` | `Verified cleared` |
|
||||
| `resolved`, `closed`, `risk_accepted` | `reopen(<canonical reopen key>)` | `status=reopened`, terminal reason fields cleared, reopen reason recorded in audit metadata | open finding again |
|
||||
|
||||
## Entity: FindingException
|
||||
|
||||
**Persistence**: existing `finding_exceptions` table
|
||||
**Owner**: tenant-owned record
|
||||
**Primary responsibility**: governance validity for `risk_accepted`
|
||||
|
||||
### Relevant fields consumed by this feature
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `status` | string | Existing workflow for exception requests and approvals |
|
||||
| `current_validity_state` | string | Current validity used by `FindingRiskGovernanceResolver` |
|
||||
| `effective_from` | datetime nullable | Existing validity window |
|
||||
| `expires_at` | datetime nullable | Existing validity window |
|
||||
| `review_due_at` | datetime nullable | Existing governance follow-up signal |
|
||||
|
||||
### Rule in this feature
|
||||
|
||||
- `FindingException` continues to determine whether `risk_accepted` is governed safely.
|
||||
- `FindingException` does not contribute to `verified_cleared` and does not change remediation buckets.
|
||||
|
||||
## Derived read model: FindingOutcomeSemantics
|
||||
|
||||
**Persistence**: not persisted
|
||||
**Owner**: findings-local support helper
|
||||
**Primary responsibility**: unify list, detail, filter, and reporting semantics from current finding truth
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Source |
|
||||
|------|--------|
|
||||
| `status` | `Finding` row |
|
||||
| `resolved_reason` | `Finding` row |
|
||||
| `closed_reason` | `Finding` row |
|
||||
| `findingException` validity | `FindingException` relationship via existing resolver |
|
||||
| `system_origin` and prior workflow steps | audit metadata, only when reconstructing history or detailed provenance |
|
||||
|
||||
### Outputs
|
||||
|
||||
| Output | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `terminalOutcomeKey` | string | Stable internal key |
|
||||
| `label` | string | Canonical operator-facing wording |
|
||||
| `verificationState` | string | `pending_verification`, `verified_cleared`, `not_applicable` |
|
||||
| `reportBucket` | string | Aggregation bucket for reviews and exports |
|
||||
| `historicalContext` | string nullable | Reuses current resolver-style explanatory text where appropriate |
|
||||
|
||||
### Report bucket mapping
|
||||
|
||||
| Terminal outcome key | Report bucket |
|
||||
|----------------------|---------------|
|
||||
| `resolved_pending_verification` | `remediation_pending_verification` |
|
||||
| `verified_cleared` | `remediation_verified` |
|
||||
| `closed_false_positive` | `administrative_closure` |
|
||||
| `closed_duplicate` | `administrative_closure` |
|
||||
| `closed_no_longer_applicable` | `administrative_closure` |
|
||||
| `risk_accepted` | `accepted_risk` |
|
||||
|
||||
## Audit metadata additions or constraints
|
||||
|
||||
| Key | Existing/New | Purpose |
|
||||
|-----|--------------|---------|
|
||||
| `resolved_reason` | existing | Canonical current resolve key |
|
||||
| `closed_reason` | existing | Canonical current close or risk-accept key |
|
||||
| `reopened_reason` | existing | Canonical reopen key for reviewability |
|
||||
| `system_origin` | existing | Provenance flag for system transitions |
|
||||
| `resolved_at`, `closed_at`, `reopened_at` | existing | Timeline reconstruction |
|
||||
|
||||
### Audit rule
|
||||
|
||||
- Audit metadata remains the place to reconstruct path history, such as whether a verified-clear outcome happened directly through automation or after a prior manual `remediated` transition.
|
||||
- The current row remains the source of truth for current filters and summaries.
|
||||
259
specs/231-finding-outcome-taxonomy/plan.md
Normal file
259
specs/231-finding-outcome-taxonomy/plan.md
Normal file
@ -0,0 +1,259 @@
|
||||
# Implementation Plan: Finding Outcome Taxonomy & Verification Semantics
|
||||
|
||||
**Branch**: `231-finding-outcome-taxonomy` | **Date**: 2026-04-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/231-finding-outcome-taxonomy/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/231-finding-outcome-taxonomy/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing findings workflow, tenant findings resource, current risk-governance interpretation, shared findings action catalog copy, pre-production lifecycle normalization jobs, system resolve/reopen paths, and current review/report consumers. The intended implementation adds one bounded outcome-semantics seam over existing `Finding` records and existing transition services. It does not add a new findings queue, a new panel, a new asset family, a second workflow state store, or a new primary findings status.
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce one bounded findings outcome taxonomy that sits on top of the existing lifecycle in `FindingWorkflowService` and current `Finding` records. Keep `status` unchanged, tighten `resolved_reason`, `closed_reason`, and `reopen_reason` into canonical keys, distinguish operator-declared resolution from later trusted system-cleared outcomes, and apply that same contract to `FindingResource`, the existing list and detail narratives, system resolve/reopen producers such as `BaselineAutoCloseService`, `EntraAdminRolesFindingGenerator`, `PermissionPostureFindingGenerator`, and `CompareBaselineToTenantJob`, supporting pre-production normalization jobs, shared findings action copy in `GovernanceActionCatalog`, the risk-governance interpreter in `FindingRiskGovernanceResolver`, plus current findings-derived review/report consumers such as `ReviewRegister` and `TenantReviewResource`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
||||
**Primary Dependencies**: `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Findings\FindingRiskGovernanceResolver`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Jobs\BackfillFindingLifecycleJob`, `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `App\Support\Ui\GovernanceActions\GovernanceActionCatalog`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger`
|
||||
**Storage**: PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned
|
||||
**Testing**: Pest v4 feature tests with Filament/Livewire assertions and workflow-service coverage
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
|
||||
**Project Type**: Laravel monolith inside the `wt-plattform` monorepo
|
||||
**Performance Goals**: Keep findings list and review/report rollups free of new N+1 behavior, keep terminal-outcome rendering derived from existing record state, and avoid new background or polling work
|
||||
**Constraints**: No new primary findings status, no new queue surface, no new Graph calls, no new asset family, no cross-tenant leakage, no comments or attachments scope, and no second reporting-only persistence layer
|
||||
**Scale/Scope**: One narrow semantics helper or equivalent local mapping seam, one workflow-service hardening slice, one risk-governance alignment slice, four existing system producer paths, two pre-production normalization jobs, one shared action-catalog touchpoint, two existing operator surfaces, and a small set of focused test suites
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament resource list, native record detail, and existing review/report surfaces only
|
||||
- **Shared-family relevance**: findings workflow surfaces, status messaging, filters, review/report viewers, centralized badge semantics
|
||||
- **State layers in scope**: page, detail, shell
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none; the feature must converge all in-scope outcome language on one bounded semantics seam rather than permitting page-local synonyms
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**:
|
||||
- `App\Filament\Resources\FindingResource`
|
||||
- `App\Services\Findings\FindingWorkflowService`
|
||||
- `App\Services\Findings\FindingRiskGovernanceResolver`
|
||||
- `App\Models\Finding`
|
||||
- `App\Services\Baselines\BaselineAutoCloseService`
|
||||
- `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`
|
||||
- `App\Services\PermissionPosture\PermissionPostureFindingGenerator`
|
||||
- `App\Jobs\CompareBaselineToTenantJob`
|
||||
- `App\Jobs\BackfillFindingLifecycleJob`
|
||||
- `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob`
|
||||
- `App\Filament\Pages\Reviews\ReviewRegister`
|
||||
- `App\Filament\Resources\TenantReviewResource`
|
||||
- `App\Support\Ui\GovernanceActions\GovernanceActionCatalog`
|
||||
- centralized badge and audit metadata paths
|
||||
- **Shared abstractions reused**: existing `FindingWorkflowService`, existing `Finding` model lifecycle constants, existing `BadgeCatalog` and `BadgeRenderer`, existing `GovernanceActionCatalog` copy contracts, existing review/register consumers, and current audit metadata structure in `FindingWorkflowService`
|
||||
- **New abstraction introduced? why?**: one narrow `FindingOutcomeSemantics`-style helper or equivalent local seam is justified because the same terminal-outcome meaning must be reused by workflow mutations, list/detail narratives, filters, and review/report rollups
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the existing findings lifecycle is sufficient as the primary workflow status layer, but it is insufficient for terminal-outcome meaning because free-form reasons and page-local narratives cannot reliably separate operator-resolved, system-verified, and non-remediation closure outcomes
|
||||
- **Bounded deviation / spread control**: no parallel presentation path is allowed; if a review/report consumer needs compressed wording, it must still derive from the same canonical keys and verified-clear rules as the operator surfaces
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before implementation design. Re-check after any scope expansion.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Read/write separation | PASS | The feature only tightens meaning on existing finding transitions and existing read models; no new write family is introduced |
|
||||
| Single contract path / no Graph bypass | PASS | No Graph calls or contract-registry changes are involved |
|
||||
| Deterministic capabilities / RBAC-UX | PASS | Existing tenant findings permissions remain authoritative; no new capability family or plane change is introduced |
|
||||
| Workspace and tenant isolation | PASS | All touched operator surfaces remain tenant-entitlement scoped; review/report rollups must continue to hide unauthorized tenant data |
|
||||
| No new persisted truth without need | PASS | Existing `resolved_reason`, `closed_reason`, audit metadata, and exception validity semantics are reused; no new table or artifact is planned |
|
||||
| No new state without behavioral consequence | PASS | The plan keeps the primary lifecycle statuses unchanged and limits new semantics to terminal-outcome meaning that changes filters, audit reading, and reporting buckets |
|
||||
| No premature abstraction / few layers | PASS | One narrow helper is the maximum justified addition because at least four producer paths and multiple readers need the same semantics |
|
||||
| Shared pattern first | PASS | One shared outcome semantics seam will feed workflow actions, UI narratives, filters, and reporting rather than separate local mappings |
|
||||
| Badge semantics (BADGE-001) | PASS | Existing centralized badge/rendering rules remain authoritative; no page-local color language is planned |
|
||||
| Filament-native UI (UI-FIL-001) | PASS | Findings list/detail and review surfaces stay on existing Filament resources/pages with updated filters, modals, and narratives only |
|
||||
| Livewire v4.0+ / Filament v5 compliance | PASS | The plan stays within existing Filament v5 and Livewire v4 surface patterns |
|
||||
| Provider registration / global search / assets | PASS | Panel providers remain in `apps/platform/bootstrap/providers.php`; no new globally searchable resource, no new asset family, and no deploy-step change beyond the existing `cd apps/platform && php artisan filament:assets` policy |
|
||||
| Test governance (TEST-GOV-001) | PASS | Proof stays in focused feature suites; no browser or heavy-governance expansion is planned |
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: `Feature` for workflow-service semantics, system resolve/reopen paths, list/detail terminal-outcome presentation, filters, and findings-derived review/report buckets
|
||||
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The risk is integrated business meaning across existing findings mutations and existing operator or report consumers. Focused feature tests prove that meaning without adding browser rendering or heavy-governance breadth.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need open, resolved, closed, reopened, and risk-accepted findings; system actor paths that clear or reopen findings; valid and invalid exception coverage; and at least one findings-derived review/report scenario.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any new helpers should stay findings-local and reuse existing findings workflow test concerns
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `standard-native-filament`; keep coverage centered on existing resource and service seams instead of adding browser tests
|
||||
- **Closing validation and reviewer handoff**: Reviewers should verify that no new primary findings status was added, that canonical keys replaced free-form reporting meaning, that `risk_accepted` stayed governed by exception validity, that system-cleared findings are distinct from operator-resolved findings, and that the same semantics appear in workflow service, list filters, detail narrative, and report buckets.
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: Did the implementation add a new status instead of a derived outcome layer? Did any producer path keep a free-form-only reason as the primary meaning? Did review/report code invent a second bucket taxonomy? Did risk-accepted findings collapse into verified-clear logic?
|
||||
- **Escalation path**: document-in-feature unless implementation pressure requires a schema change or a second cross-domain presenter, in which case split or follow up with a dedicated spec
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: The current operator and reporting gap can be closed inside the existing findings workflow and report seams without broader framework work
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/231-finding-outcome-taxonomy/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── finding-outcome-taxonomy.logical.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ └── Reviews/
|
||||
│ │ │ └── ReviewRegister.php
|
||||
│ │ └── Resources/
|
||||
│ │ ├── FindingResource.php
|
||||
│ │ └── TenantReviewResource.php
|
||||
│ ├── Jobs/
|
||||
│ │ └── CompareBaselineToTenantJob.php
|
||||
│ ├── Models/
|
||||
│ │ ├── Finding.php
|
||||
│ │ └── FindingException.php
|
||||
│ ├── Services/
|
||||
│ │ ├── Baselines/
|
||||
│ │ │ └── BaselineAutoCloseService.php
|
||||
│ │ ├── EntraAdminRoles/
|
||||
│ │ │ └── EntraAdminRolesFindingGenerator.php
|
||||
│ │ ├── Findings/
|
||||
│ │ │ └── FindingWorkflowService.php
|
||||
│ │ └── PermissionPosture/
|
||||
│ │ └── PermissionPostureFindingGenerator.php
|
||||
│ └── Support/
|
||||
│ ├── Badges/
|
||||
│ │ ├── BadgeCatalog.php
|
||||
│ │ ├── BadgeDomain.php
|
||||
│ │ └── BadgeRenderer.php
|
||||
│ └── Findings/
|
||||
│ └── FindingOutcomeSemantics.php
|
||||
└── tests/
|
||||
└── Feature/
|
||||
├── Filament/
|
||||
│ └── FindingResolvedReferencePresentationTest.php
|
||||
└── Findings/
|
||||
├── FindingWorkflowServiceTest.php
|
||||
├── FindingRecurrenceTest.php
|
||||
├── FindingRiskGovernanceProjectionTest.php
|
||||
├── FindingOutcomeSummaryReportingTest.php
|
||||
└── FindingsListFiltersTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith. The work stays inside the existing findings workflow, system finding-generator seams, and current report/register consumers. No new base directory, no new panel, and no new persistence layer are required.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| One narrow findings-outcome semantics seam | Multiple producer and consumer paths need the same terminal-outcome meaning | Keeping meaning inside `FindingResource` static methods or free-form workflow-service strings would let reporting and automation drift immediately |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Terminal findings are currently too ambiguous for operators and reviewers to tell whether the issue was fixed, only declared fixed, system-confirmed clear, or closed as non-actionable.
|
||||
- **Existing structure is insufficient because**: The current lifecycle status plus free-form reasons cannot safely drive filters, audit interpretation, and report rollups with the same meaning.
|
||||
- **Narrowest correct implementation**: Preserve the existing status model and existing finding rows, add bounded canonical reason keys plus one narrow verified-clear interpretation seam, and reuse it across workflow producers and readers.
|
||||
- **Ownership cost created**: One findings-local semantics helper or equivalent seam, updates to workflow-service validation and audit metadata, updates to current generators and readers, and focused regression coverage.
|
||||
- **Alternative intentionally rejected**: A new primary findings status such as `verified`, or a generic governance-case framework, was rejected because both add broader workflow complexity than current release truth requires.
|
||||
- **Release truth**: Current-release truth. This feature corrects the meaning of today's findings workflow and today's downstream review/report consumers.
|
||||
|
||||
## Planned Phase Outputs
|
||||
|
||||
- `research.md`: map current free-form reason usage in `FindingWorkflowService`, current system resolve/reopen producers, and current report/list consumers that read terminal findings
|
||||
- `data-model.md`: document the existing `Finding` fields, the canonical reason families, the derived verification meaning, and the report-bucket mapping
|
||||
- `quickstart.md`: record the narrowest implementation and validation workflow for service, UI, and reporting changes
|
||||
- `contracts/finding-outcome-taxonomy.logical.openapi.yaml`: describe the logical transition inputs and terminal-outcome outputs for list/detail/report consumers
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Define the bounded outcome taxonomy close to the finding domain
|
||||
|
||||
**Goal**: Create one canonical source for resolve, close, reopen, and verification meaning without changing primary lifecycle status.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Models/Finding.php` | Add or centralize canonical reason-key lists and any small helper methods needed to describe terminal-outcome meaning while preserving existing status constants |
|
||||
| A.2 | `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php` | Add one narrow findings-local helper that maps current finding state, reason keys, and exception validity into operator-safe outcome labels, verification meaning, and report buckets |
|
||||
| A.3 | `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php` | Keep accepted-risk historical context and warning semantics aligned to the same canonical outcome buckets without collapsing exception validity into verified-clear meaning |
|
||||
| A.4 | `apps/platform/app/Support/Badges/*` or existing finding narrative paths | Reuse centralized badge and narrative rules for any new emphasis instead of page-local color or label mappings |
|
||||
|
||||
### Phase B - Harden workflow transitions and audit metadata around canonical reasons
|
||||
|
||||
**Goal**: Make manual and system transitions persist canonical meaning instead of free-form semantics.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | Replace free-form-only `validatedReason()` behavior for resolve, close, and reopen with canonical bounded keys plus optional secondary note support if needed |
|
||||
| B.2 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | Extend audit metadata and snapshots so resolve, close, reopen, and system-origin transitions preserve structured outcome meaning and verification-safe context |
|
||||
| B.3 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | Keep `status` unchanged, but ensure system resolve and system reopen paths clear or set the derived verified-clear interpretation consistently |
|
||||
| B.4 | `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` and `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` | Replace legacy reason strings in current pre-production lifecycle normalization paths so canonical keys stay single-source during backfill and workspace-run repair flows |
|
||||
|
||||
### Phase C - Align system producers to the same system-clear and recurrence semantics
|
||||
|
||||
**Goal**: Remove semantic drift between manual workflow actions and automated finding producers.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php` | Use canonical system resolve reasons for auto-cleared findings |
|
||||
| C.2 | `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php` | Route system resolve and reopen paths through the same canonical keys and verified-clear semantics |
|
||||
| C.3 | `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` | Keep recurrence-driven reopen behavior aligned with the same structured reopen reasons and verified-clear reset rules |
|
||||
|
||||
### Phase D - Update operator surfaces without creating a second findings UX language
|
||||
|
||||
**Goal**: Make list, detail, and action modals speak the same terminal-outcome language.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php` | Align shared findings action labels, helper copy, and reason policy metadata with the canonical resolve, close, and reopen vocabulary |
|
||||
| D.2 | `apps/platform/app/Filament/Resources/FindingResource.php` | Update grouped resolve, close, and reopen actions to present canonical options and secondary help text instead of free-form-only terminal meaning |
|
||||
| D.3 | `apps/platform/app/Filament/Resources/FindingResource.php` | Update list filters, default-visible terminal summaries, and detail narratives to consume the shared semantics helper |
|
||||
| D.4 | `apps/platform/app/Filament/Resources/FindingResource.php` | Preserve open-backlog defaults from Spec 111 and keep `risk_accepted` distinct through existing governance-validity signals |
|
||||
|
||||
### Phase E - Align findings-derived review and report consumers
|
||||
|
||||
**Goal**: Keep downstream consumers from inventing a second outcome taxonomy.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` | Update any findings-derived bucket or label usage to consume the shared outcome semantics instead of local wording |
|
||||
| E.2 | `apps/platform/app/Filament/Resources/TenantReviewResource.php` | Keep review-pack, executive-pack export cues, and tenant review summary presentation aligned with the same terminal-outcome buckets and verified-clear rules |
|
||||
|
||||
### Phase F - Prove the taxonomy through focused regression coverage
|
||||
|
||||
**Goal**: Lock workflow, UI, and reporting semantics together with the smallest sufficient test set.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| F.1 | `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php` | Extend transition tests for canonical resolve, close, and reopen reasons plus audit metadata |
|
||||
| F.2 | `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` | Prove recurrence reopens remove verified-clear interpretation and keep structured reopen reasons |
|
||||
| F.3 | `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php` and `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php` | Prove list filters and detail presentation distinguish `Resolved pending verification`, `Verified cleared`, and non-remediation closure |
|
||||
| F.4 | `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php` and `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` | Prove report and governance projections keep `risk_accepted`, verified-cleared, and closed-non-remediation buckets distinct |
|
||||
| F.5 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus the focused Pest commands above | Run formatting and the narrow proving set before implementation close-out |
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
- **Read/write separation**: PASS. The design still changes only existing findings transitions and existing read consumers.
|
||||
- **Persisted truth**: PASS. No new table, entity, or second semantic store was introduced by the design artifacts.
|
||||
- **Behavioral state**: PASS. Verification meaning stays derived from bounded keys and existing status, not from a new primary state.
|
||||
- **Abstraction control**: PASS. The design still justifies only one findings-local semantics helper.
|
||||
- **Filament / Livewire / panel safety**: PASS. The design remains within Filament v5 and Livewire v4, keeps provider registration unchanged in `bootstrap/providers.php`, does not add a new globally searchable resource, and does not introduce a new asset family beyond the existing `filament:assets` deploy policy.
|
||||
- **Testing governance**: PASS. Proof remains in focused feature suites and does not widen into browser or heavy-governance lanes.
|
||||
87
specs/231-finding-outcome-taxonomy/quickstart.md
Normal file
87
specs/231-finding-outcome-taxonomy/quickstart.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Quickstart: Finding Outcome Taxonomy & Verification Semantics
|
||||
|
||||
## Goal
|
||||
|
||||
Implement one bounded findings outcome taxonomy on top of the existing findings lifecycle so that:
|
||||
|
||||
- operator-resolved findings surface as `Resolved pending verification`
|
||||
- trusted system-cleared findings surface as `Verified cleared`
|
||||
- non-remediation closures stay separate
|
||||
- `risk_accepted` remains governed by exception validity instead of collapsing into remediation semantics
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Work on branch `231-finding-outcome-taxonomy`
|
||||
- Use the existing tenant findings resource and current review consumers; do not add a new panel or queue
|
||||
- Run all PHP, Artisan, and Pint commands through Sail
|
||||
|
||||
## Narrow implementation order
|
||||
|
||||
### 1. Tighten the finding-domain truth
|
||||
|
||||
- Add canonical reason-key families to `apps/platform/app/Models/Finding.php` or an equivalent findings-local seam
|
||||
- Add `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php` to derive:
|
||||
- terminal outcome key
|
||||
- verification state
|
||||
- report bucket
|
||||
- operator-facing label
|
||||
- Keep `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php` aligned to the same bounded outcome semantics so `risk_accepted` stays separate from verified-clear meaning
|
||||
- Keep reopen reasons in audit metadata; do not add a `reopened_reason` column
|
||||
|
||||
### 2. Harden the workflow service
|
||||
|
||||
- Update `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||
- Replace free-form `validatedReason()` usage with canonical key validation
|
||||
- Keep manual resolve as `remediated` and system-clear reasons as trusted verified-clear keys
|
||||
- Widen the system-clear path narrowly enough to confirm already resolved findings without adding a new primary status
|
||||
- Preserve or extend audit metadata for `resolved_reason`, `closed_reason`, `reopened_reason`, `system_origin`, and timestamps
|
||||
|
||||
### 3. Align automation paths
|
||||
|
||||
- Update these existing producers to use the canonical system-clear and reopen keys:
|
||||
- `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`
|
||||
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- Replace legacy or ad hoc reason strings in `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php` and `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php` in the same slice instead of introducing alias maps
|
||||
|
||||
### 4. Update operator surfaces
|
||||
|
||||
- Align `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php` copy and reason-policy metadata with the canonical findings vocabulary
|
||||
- Update `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
- Replace free-form resolve and close textareas with canonical selections plus optional note fields if needed
|
||||
- Keep destructive-like actions confirmed with `->requiresConfirmation()`
|
||||
- Update list filters, detail summaries, and terminal-outcome labels to use the shared semantics helper
|
||||
- Preserve the existing open backlog defaults from Spec 111
|
||||
|
||||
### 5. Update review and reporting consumers
|
||||
|
||||
- Update `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||
- Update `apps/platform/app/Filament/Resources/TenantReviewResource.php`
|
||||
- Reuse the shared semantics helper anywhere findings-derived summaries or projection buckets are rendered
|
||||
- Do not create a second reporting taxonomy or reporting-only persistence layer
|
||||
|
||||
### 6. Prove the change with focused tests
|
||||
|
||||
- Extend existing findings service and UI tests first
|
||||
- Add one focused reporting summary test only if no existing suite naturally owns that proof
|
||||
- Keep coverage in feature suites; browser coverage is not needed for this slice
|
||||
|
||||
## Validation commands
|
||||
|
||||
Run after implementation:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact 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 bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Review checklist
|
||||
|
||||
- No new primary findings status was introduced
|
||||
- `resolved_reason` and `closed_reason` are bounded canonical keys, not free-form reporting truth
|
||||
- `reopened_reason` remains reviewable through audit metadata without adding new persistence
|
||||
- `risk_accepted` still depends on exception validity and is not counted as verified clear
|
||||
- `FindingResource`, `ReviewRegister`, and `TenantReviewResource` use one shared outcome semantics seam
|
||||
- No compatibility aliases were kept for replaced reason keys unless a spec change explicitly required them
|
||||
57
specs/231-finding-outcome-taxonomy/research.md
Normal file
57
specs/231-finding-outcome-taxonomy/research.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Research: Finding Outcome Taxonomy & Verification Semantics
|
||||
|
||||
## Decision 1: Preserve the primary findings status family and derive verification meaning
|
||||
|
||||
- **Decision**: Keep the existing `Finding` lifecycle statuses (`new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, `risk_accepted`) and derive `Resolved pending verification` versus `Verified cleared` from bounded canonical reason keys plus existing audit history.
|
||||
- **Rationale**: `App\Models\Finding` already defines open and terminal status sets, `FindingWorkflowService` and its tests assume that matrix, and the spec explicitly forbids a new primary status just to represent verification. The narrowest viable change is to make terminal meaning derive from bounded reason families instead of expanding the workflow state machine.
|
||||
- **Alternatives considered**:
|
||||
- Add a new `verified` status: rejected because it widens queue semantics, transition rules, and operator routing for a problem that can remain derived.
|
||||
- Add a second outcome table or reporting-only register: rejected because there is no new independent source of truth or lifecycle to persist.
|
||||
|
||||
## Decision 2: Reuse existing `resolved_reason` and `closed_reason` fields, and keep reopen reasons in audit metadata
|
||||
|
||||
- **Decision**: Reuse the existing `resolved_reason` and `closed_reason` columns as canonical stable keys, and keep `reopened_reason` as structured audit metadata instead of adding a new `reopened_reason` column.
|
||||
- **Rationale**: `FindingWorkflowService` already writes `resolved_reason`, `closed_reason`, `system_origin`, and `reopened_reason` into current record state and audit metadata. The service only needs to stop accepting arbitrary free-form text and instead validate against bounded key families. This keeps the implementation inside existing persistence truth and matches the spec's no-new-entity posture.
|
||||
- **Alternatives considered**:
|
||||
- Add a new JSON outcome payload on `findings`: rejected because it would create a second semantic store for data already represented by existing columns and audit events.
|
||||
- Keep free-form textarea input as the primary meaning: rejected because list filters, review consumers, and audit readers cannot safely depend on prose.
|
||||
|
||||
## Decision 3: Use one findings-local semantics helper rather than page-local mappings or a generic framework
|
||||
|
||||
- **Decision**: Add one findings-local helper, such as `App\Support\Findings\FindingOutcomeSemantics`, to convert current finding truth into terminal-outcome labels, verification state, and report buckets.
|
||||
- **Rationale**: Multiple readers already need the same meaning: `FindingResource`, `FindingRiskGovernanceResolver`, `ReviewRegister`, `TenantReviewResource`, and findings-derived summary or projection code. A single findings-local helper is justified because at least two real consumers already exist, while a generic governance-wide taxonomy engine would be broader than the current release truth.
|
||||
- **Alternatives considered**:
|
||||
- Keep logic inside `FindingResource` only: rejected because review and reporting consumers would immediately drift.
|
||||
- Build a broad cross-domain governance taxonomy framework: rejected because the current scope is bounded to findings terminal outcomes.
|
||||
|
||||
## Decision 4: Use canonical reason keys to make system-cleared outcomes row-visible, and keep audit history for path reconstruction
|
||||
|
||||
- **Decision**: Treat canonical resolve keys as the row-level source for pending-versus-verified meaning. Manual resolve uses a bounded operator key such as `remediated`, while trusted system-clear reasons continue to use bounded system keys such as `no_longer_drifting`, `permission_granted`, `permission_removed_from_registry`, `role_assignment_removed`, and `ga_count_within_threshold`.
|
||||
- **Rationale**: `resolveBySystem()` currently writes `system_origin` into audit metadata, but that audit-only flag is not enough for list filters or report buckets. Using canonical resolve keys as the current-row truth makes terminal meaning queryable without adding a new column, while the audit trail still records whether the final verified-clear state came from a direct system clear or a later confirmation after manual resolution.
|
||||
- **Alternatives considered**:
|
||||
- Add a persisted `verification_state` column: rejected because the same meaning can be derived from the current status plus canonical reason keys.
|
||||
- Depend only on audit metadata for verified-clear meaning: rejected because list filters and read models should not need audit log reconstruction to classify current records.
|
||||
|
||||
## Decision 5: Widen the system resolve path narrowly enough to confirm already resolved findings
|
||||
|
||||
- **Decision**: Allow the system-clear path to update a finding that is already `resolved` when the purpose is to move it from operator-declared resolution to a trusted verified-clear reason.
|
||||
- **Rationale**: `FindingWorkflowService::resolveBySystem()` currently only accepts open findings, which is too narrow for the spec requirement that a previously resolved finding may later be confirmed clear by trusted evidence. The narrowest fix is to widen the system path for this exact case instead of adding a new status or a second persistence field.
|
||||
- **Alternatives considered**:
|
||||
- Force a reopen-then-resolve cycle to represent verification: rejected because it would falsify the user-visible history and generate unnecessary workflow churn.
|
||||
- Add a second workflow method dedicated to verification with separate persisted state: rejected because it adds more surface than the current release needs.
|
||||
|
||||
## Decision 6: Keep `risk_accepted` separate from remediation and verification semantics
|
||||
|
||||
- **Decision**: Preserve `risk_accepted` as its own terminal class governed by exception validity, not as a close reason and not as a verified-clear outcome.
|
||||
- **Rationale**: `FindingRiskGovernanceResolver` already interprets `accepted_risk` and exception validity separately, and the spec requires Spec 154 semantics to remain authoritative. This means the outcome taxonomy should expose `risk_accepted` as a distinct terminal bucket while continuing to compute governance validity independently.
|
||||
- **Alternatives considered**:
|
||||
- Fold `risk_accepted` into generic closed outcomes: rejected because that would hide governance validity consequences.
|
||||
- Treat valid accepted risk as verified clear: rejected because risk acceptance is an administrative governance decision, not proof of remediation.
|
||||
|
||||
## Decision 7: Replace existing ad hoc keys in one pass instead of preserving aliases
|
||||
|
||||
- **Decision**: Replace existing ad hoc or legacy-like reason strings inside current code, factories, and backfill jobs during implementation rather than preserving alias maps.
|
||||
- **Rationale**: The repository is still pre-production, LEAN-001 explicitly rejects compatibility shims when there is no live production data, and current code already shows several ad hoc reason strings such as `duplicate`, `accepted_risk`, and `consolidated_duplicate`. The cleanest implementation is one canonical bounded family, updated everywhere in the same slice.
|
||||
- **Alternatives considered**:
|
||||
- Maintain alias translation tables for old and new reason keys: rejected because it adds avoidable compatibility machinery.
|
||||
- Leave existing reasons mixed and normalize only in the UI: rejected because backend tests, report buckets, and audit semantics would remain ambiguous.
|
||||
276
specs/231-finding-outcome-taxonomy/spec.md
Normal file
276
specs/231-finding-outcome-taxonomy/spec.md
Normal file
@ -0,0 +1,276 @@
|
||||
# Feature Specification: Finding Outcome Taxonomy & Verification Semantics
|
||||
|
||||
**Feature Branch**: `231-finding-outcome-taxonomy`
|
||||
**Created**: 2026-04-22
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Finding Outcome Taxonomy & Verification Semantics"
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Findings can currently end in materially different ways, but the product does not communicate those differences with one bounded, structured meaning. `Resolved`, `closed`, auto-cleared, false-positive, duplicate, and no-longer-applicable paths can all collapse into ambiguous operator or reporting language.
|
||||
- **Today's failure**: An operator, reviewer, or auditor cannot reliably tell whether a finding was fixed by an operator, later verified as cleared by the system, closed as non-actionable, or still only waiting for confirmation. Filters, summaries, and review outputs therefore drift away from the real governance meaning.
|
||||
- **User-visible improvement**: Findings surfaces show one honest terminal-outcome language: operators can choose bounded terminal reasons, lists and detail views distinguish `Resolved pending verification` from `Verified cleared`, and reporting can separate remediation outcomes from administrative closure outcomes.
|
||||
- **Smallest enterprise-capable version**: Keep the existing findings lifecycle from Spec 111, add one bounded structured outcome taxonomy plus one bounded verification layer on top of existing findings, and apply that contract consistently to action forms, detail summaries, list filters, reopen reasons, audit copy, and downstream reporting consumers.
|
||||
- **Explicit non-goals**: No comments or case-notes system, no attachments, no new findings queue, no external ticket handoff, no risk-exception redesign, no generic case-management engine, and no new primary lifecycle status family for findings.
|
||||
- **Permanent complexity imported**: One bounded findings outcome taxonomy, one bounded verification-outcome vocabulary, one bounded reopen-reason family, focused action and filter updates on existing findings surfaces, and regression coverage for action, filter, audit, and reporting semantics.
|
||||
- **Why now**: The roadmap's `Findings Workflow v2 / Execution Layer` already has ownership, inbox, intake, notifications, and hygiene slices specced. The remaining hardening point is exactly `resolved-versus-verified outcome semantics`, and downstream review and reporting quality depends on it.
|
||||
- **Why not local**: A local wording fix in one modal or one report would still leave detail views, reopen behavior, filters, audit events, and review outputs speaking different terminal-outcome languages.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: One new structured reason family and one new verification interpretation layer. Scope remains acceptable because the feature reuses the existing finding lifecycle, existing exception validity semantics, and existing finding records instead of adding a new entity or queue.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 1 | Produktnahe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/findings` as the existing tenant findings queue and filter surface
|
||||
- `/admin/t/{tenant}/findings/{finding}` as the existing finding detail and transition surface
|
||||
- Existing findings-derived review, summary, and export consumers that already aggregate terminal findings outcomes, without introducing a new primary route in this slice
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned findings remain the only source of truth for workflow status, outcome meaning, verification meaning, and reopen semantics.
|
||||
- Existing review, export, and governance summary consumers remain derived read models over finding and exception truth; no second outcome register or reporting-only persistence layer is introduced.
|
||||
- Existing audit events remain the durable mutation trail for resolve, close, and reopen transitions.
|
||||
- **RBAC**:
|
||||
- Tenant membership is required for read or mutation behavior on tenant findings surfaces.
|
||||
- Existing findings view permission gates read access.
|
||||
- Existing findings transition or management permissions gate resolve, close, and reopen mutations.
|
||||
- Existing review and export consumers must remain tenant-entitlement filtered; users must not learn terminal-outcome counts or labels for unauthorized tenants.
|
||||
- Non-members and cross-tenant requests remain deny-as-not-found. In-scope users lacking the required mutation capability remain explicitly forbidden for protected actions.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, action-modal semantics, filters, reporting and export summaries, audit prose
|
||||
- **Systems touched**:
|
||||
- Existing findings lifecycle action surfaces on the tenant findings list and finding detail page
|
||||
- Existing findings list filters and terminal-state presentation
|
||||
- Existing findings-derived review, report, and export summaries
|
||||
- Existing audit or history rendering for finding workflow transitions
|
||||
- **Existing pattern(s) to extend**:
|
||||
- Findings lifecycle contract from Spec 111
|
||||
- Risk-acceptance validity semantics from Spec 154
|
||||
- Operator-language and reason-translation foundations from Specs 156, 157, 161, and 214
|
||||
- **Shared contract / presenter / builder / renderer to reuse**:
|
||||
- Reuse the existing findings workflow lifecycle as the primary status layer
|
||||
- Reuse the existing findings list and detail surface families as the primary operator surfaces
|
||||
- Reuse existing findings-derived summary and reporting consumers as downstream readers of the new outcome taxonomy rather than inventing a second summary path
|
||||
- **Why the existing shared path is sufficient or insufficient**: The existing findings lifecycle is sufficient as the main status layer and should remain unchanged. It is insufficient on its own because status plus free-form reason text does not separate operator-resolved, system-verified, and non-remediation closure outcomes in a stable way.
|
||||
- **Allowed deviation and why**: none. Read-only reporting consumers may compress the wording for stakeholder readability, but they must preserve the same taxonomy and bucket boundaries as the operator surfaces.
|
||||
- **Consistency impact**: Resolve, close, and reopen labels; modal help text; default-visible terminal outcome summaries; filters; audit prose; and review or export buckets must all use the same canonical outcome language.
|
||||
- **Review focus**: Reviewers must confirm that one structured taxonomy drives actions, detail summaries, filters, audit copy, and reporting rollups, and that no page-local synonym or free-text-only outcome path remains the primary source of meaning.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | yes | Native Filament resource table, grouped actions, filters, and existing shared findings primitives | Same findings workflow family as Spec 219, 221, 222, 224, and 225 | workflow status, terminal outcome, verification meaning, risk-governance validity | no | Existing queue surface only; no new queue |
|
||||
| Finding detail and terminal action modals | yes | Native Filament record page, infolist or detail primitives, and existing action modals | Same findings workflow family as the tenant list | workflow status, terminal outcome, verification meaning, audit-facing summary | no | Existing detail surface only; no new detail shell |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator decides how an open finding should end and later scans whether terminal findings were resolved, verified, or administratively closed | Lifecycle status, severity, due state, owner, assignee, terminal outcome summary, and verification meaning for terminal rows | Full evidence, raw payloads, audit trail, and exception history after opening the finding | Primary because this is where operators scan and route work at backlog scale | Follows the findings workflow instead of forcing report-first reconstruction | Removes the need to open each terminal finding just to learn whether it was actually fixed, only claimed fixed, or closed as non-actionable |
|
||||
| Finding detail and terminal action modals | Secondary Context Surface | A tenant operator confirms the correct terminal meaning for one finding before resolving, closing, or reopening it | Finding summary, current workflow state, current outcome summary, exception validity when relevant, and the exact action choice | Extended evidence, history, and full audit context | Secondary because it resolves one case after the list already identified the record | Preserves the single-finding workflow without inventing a separate governance case page | Avoids cross-page reconstruction when choosing between remediation, non-actionable closure, and reopen paths |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or apply a bounded terminal action | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain grouped and confirmed where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, severity, due state, workflow filters, terminal outcome filters | Findings / Finding | Whether a terminal finding is resolved pending verification, verified cleared, closed for a non-remediation reason, or governed accepted risk | none |
|
||||
| Finding detail and terminal action modals | Record / Detail / Actions | View-first operational detail | Resolve, close, or reopen one finding with the correct meaning | Finding | N/A - detail surface | Structured existing header actions and bounded transition modals | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, workflow status, severity, outcome summary, and risk-governance context | Findings / Finding | Why this finding is terminal right now and whether that terminal state is operator-declared, system-confirmed, or administratively closed | 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route findings to the correct terminal meaning and scan the existing backlog honestly | Workflow queue | Was this finding fixed, only marked fixed, system-confirmed clear, or closed as non-actionable? | Severity, lifecycle state, due state, owner, assignee, terminal outcome summary, and verification summary for terminal findings | Raw evidence, provider details, long audit history, and related run metadata | lifecycle, severity, terminal outcome, verification meaning, risk-governance validity | TenantPilot only | Open finding, Resolve, Close, Reopen | Risk accept remains dangerous and governed separately; existing dangerous transitions stay confirmed |
|
||||
| Finding detail and terminal action modals | Tenant operator or tenant manager | Choose the correct terminal path for one finding and verify current terminal meaning | Detail/action surface | Am I resolving this issue, confirming it was later cleared, closing it as non-actionable, or reopening it because the issue persists? | Finding summary, current state, current terminal outcome summary, current exception validity when relevant, and the next transition choices | Raw evidence payloads, historical audit entries, and related governance context | lifecycle, terminal outcome, verification meaning, risk-governance validity | TenantPilot only | Resolve, Close, Reopen | Request exception remains governed by Spec 154 and stays separately confirmed where required |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes - one bounded findings outcome taxonomy and verification interpretation contract over existing finding records, action forms, filters, audit entries, and reporting consumers
|
||||
- **New enum/state/reason family?**: yes - one bounded resolve-reason family, one bounded close-reason family, one bounded reopen-reason family, and one bounded verification-outcome vocabulary
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Terminal findings can currently look similar even when the underlying meaning is very different, which weakens operator trust and corrupts reporting quality.
|
||||
- **Existing structure is insufficient because**: The existing lifecycle from Spec 111 tells whether a finding is open or terminal, but not whether a terminal finding was remediated, only declared remediated, system-confirmed clear, or closed as non-actionable. Free-form reason text cannot safely carry that distinction across filters, reports, and audit views.
|
||||
- **Narrowest correct implementation**: Preserve the existing lifecycle, add structured bounded reasons and one verification layer, and apply them only to existing findings actions and readers.
|
||||
- **Ownership cost**: Ongoing maintenance for the bounded reason vocabularies, audit and reporting mapping discipline, and focused regression coverage.
|
||||
- **Alternative intentionally rejected**: Introducing a new primary findings status such as `verified`, or a generic case-management framework, was rejected because both would import broader workflow complexity than the actual operator problem requires.
|
||||
- **Release truth**: Current-release truth. This feature makes the current findings workflow and reporting honest now instead of preparing a speculative later platform.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: The proving burden is operator-visible and report-visible semantics on existing findings workflow surfaces. Focused feature coverage is sufficient to prove structured terminal-action behavior, verification or reopen behavior, filter rollups, audit copy, and downstream summary meaning without requiring browser or heavy-governance tests.
|
||||
- **New or expanded test families**: Add focused finding transition tests for structured resolve, close, and reopen reasons; finding detail and list rendering tests for terminal-outcome summaries; filter and reporting summary tests for distinct terminal buckets; and audit-entry tests for structured outcome keys.
|
||||
- **Fixture / helper cost impact**: Moderate. Tests need open, resolved, closed, reopened, and risk-accepted findings; trusted detection or verification inputs for confirm-clear and recurrence cases; valid and invalid exception coverage; and mixed tenant-visibility scenarios for report consumers.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament
|
||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit proof that open-backlog defaults remain unchanged, that verified-cleared and operator-resolved outcomes stay distinct, and that review or export buckets do not collapse non-remediation closures into remediation outcomes.
|
||||
- **Reviewer handoff**: Reviewers must confirm that the feature does not create a new primary findings status, that free-form text is no longer the primary reporting key, that risk-accepted findings remain governed by exception validity instead of merging into verified-clear semantics, and that the same taxonomy appears in actions, list filters, detail summaries, audit prose, and downstream rollups.
|
||||
- **Budget / baseline / trend impact**: none
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - End a finding with the correct terminal meaning (Priority: P1)
|
||||
|
||||
As a tenant operator, I want bounded resolve and close reasons instead of free-form terminal outcomes, so that I can end a finding honestly and downstream reporting keeps the same meaning.
|
||||
|
||||
**Why this priority**: This is the smallest operator-visible slice and the source of all later reporting quality. If the transition itself stays ambiguous, no summary or report can fix the semantics afterward.
|
||||
|
||||
**Independent Test**: Can be fully tested by executing resolve and close transitions on open findings, then verifying that the workflow response, detail narrative, and terminal-outcome filters show the structured meaning without relying on free-form text.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an open finding, **When** an authorized operator resolves it as remediated work, **Then** the finding becomes terminal with the meaning `Resolved pending verification` rather than `Closed`.
|
||||
2. **Given** an open finding, **When** an authorized operator closes it as `False positive`, **Then** the finding becomes `closed` and all operator surfaces show the non-remediation closure meaning rather than a remediation outcome.
|
||||
3. **Given** an open finding, **When** an authorized operator closes it as `Duplicate` or `No longer applicable`, **Then** list filters and detail summaries use that canonical close reason instead of free-form prose.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - See whether a terminal finding was actually verified clear (Priority: P1)
|
||||
|
||||
As a reviewer or accountable owner, I want to distinguish operator-resolved findings from system-verified cleared findings, so that I can trust whether the issue was only declared fixed or later confirmed gone.
|
||||
|
||||
**Why this priority**: This is the roadmap's explicit unresolved gap. Without it, the product cannot honestly answer whether terminal findings are truly cleared or only awaiting verification.
|
||||
|
||||
**Independent Test**: Can be fully tested by resolving a finding, then simulating a later trusted verification-clear event and a later recurrence event, and verifying that one path becomes verified clear while the other reopens with a structured reason.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a finding is already `resolved`, **When** later trusted system evidence confirms that the issue is no longer present, **Then** the finding remains terminal and surfaces as `Verified cleared` rather than plain unresolved `resolved` wording.
|
||||
2. **Given** a finding is already `resolved`, **When** later trusted system evidence shows the issue is present again, **Then** the finding reopens with a structured reopen reason and does not remain reported as verified clear.
|
||||
3. **Given** a finding was system-cleared without an operator transition, **When** the finding is shown on detail or reporting surfaces, **Then** the product distinguishes that system-confirmed terminal outcome from a manually declared resolution.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Filter and summarize terminal outcomes consistently (Priority: P2)
|
||||
|
||||
As a reviewer or report consumer, I want terminal findings filters and summaries to use one canonical taxonomy, so that review packs, exports, and list filters separate remediation outcomes from administrative closure outcomes.
|
||||
|
||||
**Why this priority**: Once transitions are honest, summaries must preserve that meaning. This is the smallest slice that makes the taxonomy operationally useful beyond the single record.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding mixed terminal findings and verifying that list filters and reporting consumers keep `Resolved pending verification`, `Verified cleared`, `Closed as false positive`, `Closed as duplicate`, `Closed as no longer applicable`, and governed `Risk accepted` in distinct buckets.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has terminal findings across multiple outcome meanings, **When** an operator filters or summarizes terminal findings, **Then** the buckets remain distinct instead of collapsing into one generic `resolved` or `closed` group.
|
||||
2. **Given** a report or review summary consumes findings outcomes, **When** it renders terminal findings, **Then** operator-resolved pending verification and verified-cleared findings are counted separately.
|
||||
3. **Given** an operator opens the default open findings queue, **When** no terminal-outcome filter is intentionally applied, **Then** the existing open-backlog behavior from Spec 111 remains unchanged.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A finding may be auto-cleared by trusted system evidence without a prior manual resolve action; that path must not be reported as operator-resolved.
|
||||
- A finding in `risk_accepted` status without a currently valid governing exception must remain a separate governance-validity problem and must not be counted as verified clear.
|
||||
- A manually reopened `closed` or `risk_accepted` finding must record an explicit reopen reason and must not inherit any prior verified-clear meaning.
|
||||
- Domains that do not yet have trustworthy system verification signals must remain `Resolved pending verification` until such a signal exists; the product must not imply silent verification.
|
||||
- Free-form notes may still exist as secondary context, but filters and reporting must not depend on note wording to determine terminal meaning.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no new long-running job, no new scheduler, and no new `OperationRun`. It changes how existing finding transitions, findings filters, detail summaries, audit entries, and findings-derived reporting consumers interpret terminal outcomes.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces new bounded reason families and one bounded verification vocabulary because the current product truth cannot honestly separate operator-declared resolution from system-confirmed clearance or non-remediation closure. It reuses existing finding records and existing lifecycle statuses instead of adding a new entity or status family.
|
||||
|
||||
**Constitution alignment (XCUT-001):** The feature is cross-cutting within the findings workflow family. It must extend one shared outcome taxonomy across actions, detail, filters, audit prose, and reporting consumers rather than allowing each surface to invent local wording.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Focused feature tests are the narrowest sufficient proof. The tests must prove transition semantics, verification or reopen behavior, filter and reporting buckets, and audit-entry meaning. No browser or heavy-governance lane is justified.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature operates on tenant findings surfaces and downstream tenant-entitlement-filtered reporting consumers. Existing tenant-safe 404 versus 403 behavior remains unchanged. Non-members and cross-tenant viewers receive `404`. In-scope users lacking the relevant finding transition capability receive `403` for protected mutations. Reporting consumers must not expose terminal-outcome counts or labels for unauthorized tenants.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Findings workflow status remains the primary centralized status family. Any added terminal-outcome or verification emphasis must reuse centralized presentation rules and must not introduce page-local color semantics.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature must use existing Filament table filters, grouped actions, detail surfaces, and action modals on the tenant findings resource. No custom badge markup, custom workflow shell, or page-local status component may be introduced.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The canonical operator-facing vocabulary is `Resolve`, `Resolved pending verification`, `Verified cleared`, `Close`, `False positive`, `Duplicate`, `No longer applicable`, and `Reopen`. The term `risk accepted` remains governed by Spec 154 and must not be used as a synonym for verified clearance or closure.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The tenant findings list remains the primary decision surface for scanning work and understanding terminal meaning at backlog scale. Finding detail remains the focused single-record decision context for choosing resolve, close, or reopen.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** This feature changes the meaning and form content of existing actions only. It does not add a second findings queue, a second detail page, or a second inspect model. Row click remains the primary inspect affordance on the list. Existing dangerous actions stay grouped and confirmed where already required.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from status plus free-form text is insufficient because it cannot safely express terminal outcome meaning across operator surfaces and reports. This feature adds one bounded interpretation layer and must prove business consequences rather than only raw field storage.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST preserve the findings lifecycle statuses from Spec 111. This feature MUST NOT introduce a new primary workflow status solely to represent verified clearance.
|
||||
- **FR-002**: The system MUST define one bounded findings outcome taxonomy that distinguishes at minimum: operator-resolved pending verification, system-verified cleared, and closed non-remediation outcomes.
|
||||
- **FR-003**: The single-record and bulk `Resolve` actions MUST require a structured resolve reason from a canonical bounded list. Optional explanatory notes MAY remain secondary context but MUST NOT be the primary reporting key.
|
||||
- **FR-004**: The single-record and bulk `Close` actions MUST require a structured close reason from a canonical bounded list that includes at minimum `False positive`, `Duplicate`, and `No longer applicable`.
|
||||
- **FR-005**: The system MUST define a bounded reopen-reason family for manual and automatic reopen paths. It MUST distinguish at minimum recurrence after prior resolution from manual reassessment or verification failure.
|
||||
- **FR-006**: A finding in `resolved` status MUST surface by default as `Resolved pending verification` until later trusted evidence confirms the issue is actually clear.
|
||||
- **FR-007**: When later trusted system evidence confirms that a previously resolved issue is no longer present, the system MUST surface that finding as `Verified cleared` without requiring a new primary findings status.
|
||||
- **FR-008**: When later trusted system evidence shows that a previously resolved issue persists or recurs, the system MUST reopen the existing finding with a structured reopen reason and MUST remove any verified-clear classification.
|
||||
- **FR-009**: System-cleared findings that become terminal through trusted automation or detection logic MUST remain distinguishable from manually resolved findings on detail, filters, and reporting consumers.
|
||||
- **FR-010**: `Closed` findings MUST remain semantically separate from remediation-complete outcomes. They MUST NOT be counted or presented as verified clear or remediated unless a future explicit feature changes that rule.
|
||||
- **FR-011**: Findings in `risk_accepted` status MUST remain governed by exception validity semantics from Spec 154 and MUST NOT collapse into resolved, verified-cleared, or closed-non-remediation buckets.
|
||||
- **FR-012**: Tenant findings list and detail surfaces MUST show default-visible terminal outcome summaries whenever a finding is not open. Operators MUST NOT need to inspect raw audit payloads to learn the primary terminal meaning.
|
||||
- **FR-013**: Terminal findings filters MUST allow operators to distinguish at minimum `Resolved pending verification`, `Verified cleared`, `False positive`, `Duplicate`, `No longer applicable`, and `Risk accepted` when those outcomes are present in scope.
|
||||
- **FR-014**: Existing open-backlog defaults for the tenant findings list, `My Findings`, and intake-oriented workflow surfaces MUST remain based on the open status set from Spec 111 and MUST NOT be widened or redefined by terminal-outcome semantics.
|
||||
- **FR-015**: Findings-derived reporting, review, and export consumers that summarize findings outcomes MUST consume the canonical taxonomy instead of free-form reason text or local labels.
|
||||
- **FR-016**: Reporting and review consumers MUST count operator-resolved pending verification separately from verified-cleared findings whenever both are present.
|
||||
- **FR-017**: Audit or history entries for resolve, close, and reopen transitions MUST record the structured outcome key and readable operator copy so later reviewers can reconstruct terminal meaning without parsing free-form prose alone.
|
||||
- **FR-018**: Existing manual reopen paths from `resolved`, `closed`, and `risk_accepted` MUST continue to exist per Spec 111, but they MUST now require an explicit reopen reason that makes the cause of reopening reviewable.
|
||||
- **FR-019**: This feature MUST NOT introduce comments, attachments, a second workflow state store, or a new canonical findings queue.
|
||||
- **FR-020**: This feature MUST NOT redefine the exception approval or validity model from Spec 154. It only consumes that validity model to keep `risk_accepted` semantically separate from other terminal outcomes.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant findings list and grouped bulk actions | `/admin/t/{tenant}/findings` | No new header actions; existing filters gain terminal-outcome semantics | Full-row open into finding detail | Existing primary inspect behavior only; terminal actions remain inside the structured existing row or grouped action path | Existing grouped `Resolve`, `Close`, and `Reopen` flows now require structured reasons; other grouped actions remain unchanged and out of scope | Existing empty-state behavior remains; no new CTA required in this slice | n/a | n/a | Yes for resolve, close, and reopen transitions through existing audit paths | Action Surface Contract remains satisfied. No new queue, no second inspect affordance, no empty action groups, and no page-local action family introduced. |
|
||||
| Finding detail and terminal action modals | `/admin/t/{tenant}/findings/{finding}` | Existing detail actions remain; updated actions in scope are `Resolve`, `Close`, and `Reopen` | Detail surface | Existing bounded detail actions only | n/a | n/a | Existing `Resolve`, `Close`, and `Reopen` action modals now require structured reasons and show canonical terminology | n/a | Yes for resolve, close, and reopen transitions through existing audit paths | UI-FIL-001 satisfied through existing native detail and action-modal primitives. No exemption needed. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Finding terminal outcome**: The bounded operator-facing meaning of how a finding became terminal, distinct from the primary workflow status.
|
||||
- **Verification outcome**: A bounded distinction between operator-declared resolution and later trusted system-confirmed clearance.
|
||||
- **Reopen reason**: The bounded explanation for why a previously terminal finding re-entered the open workflow.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance review, an operator can identify from the tenant findings list or detail page whether a terminal finding is `Resolved pending verification`, `Verified cleared`, or closed for a non-remediation reason without opening raw audit detail.
|
||||
- **SC-002**: 100% of covered transition tests show that resolve, close, and reopen actions persist structured outcome semantics and do not rely on free-form text as the primary meaning.
|
||||
- **SC-003**: 100% of covered filter and reporting tests keep `Resolved pending verification`, `Verified cleared`, `False positive`, `Duplicate`, `No longer applicable`, and governed `Risk accepted` in distinct outcome buckets when present.
|
||||
- **SC-004**: 100% of covered queue tests show that the default open findings backlog remains unchanged and excludes terminal findings regardless of their terminal-outcome bucket.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing finding records and audit paths can carry the structured outcome metadata needed by this slice without introducing a new top-level entity.
|
||||
- The first release limits `Verified cleared` semantics to finding families where the product already has trustworthy system evidence for clearance or recurrence.
|
||||
- Free-form explanatory text may remain as optional secondary context, but it is not the canonical key for filters, summaries, or reports.
|
||||
- Risk-acceptance validity continues to derive from the exception workflow defined in Spec 154.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Introduce a new primary findings status such as `verified` or `confirmed_cleared`.
|
||||
- Add comments, decision logs, attachments, or case-note collaboration.
|
||||
- Add external ticket handoff, team workboards, or a new personal queue.
|
||||
- Redesign risk-acceptance approvals, expiry, or revocation semantics.
|
||||
- Build a generic evidence-attestation engine or universal governance case framework.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Spec 111 remains the source of truth for the core findings lifecycle, open-status defaults, and allowed reopen paths.
|
||||
- Spec 154 remains the source of truth for exception validity and governed `risk_accepted` semantics.
|
||||
- Spec 155 remains the downstream review-layer consumer that should inherit this taxonomy instead of inventing local terminal buckets.
|
||||
- Specs 156, 157, 161, and 214 remain the semantic foundation for operator-safe language and explanation patterns across outcome-bearing surfaces.
|
||||
229
specs/231-finding-outcome-taxonomy/tasks.md
Normal file
229
specs/231-finding-outcome-taxonomy/tasks.md
Normal file
@ -0,0 +1,229 @@
|
||||
# Tasks: Finding Outcome Taxonomy & Verification Semantics
|
||||
|
||||
**Input**: Design documents from `/specs/231-finding-outcome-taxonomy/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/finding-outcome-taxonomy.logical.openapi.yaml`, `quickstart.md`, `checklists/requirements.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior in the tenant findings workflow, terminal-outcome presentation, and findings-derived review/report buckets, so Pest coverage must be added or updated in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php`, `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`, `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php`, and `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`.
|
||||
**Operations**: No new `OperationRun` is introduced. Existing `operation_run_id` provenance remains in scope only for trusted system clear and system reopen audit context; no new monitoring surface, queued notification surface, or run lifecycle contract may be introduced.
|
||||
**RBAC**: This feature stays on the tenant `/admin/t/{tenant}/findings` plane plus existing tenant-scoped review consumers. The implementation must preserve non-member or cross-tenant `404`, in-scope missing-capability `403`, current server-side authorization in `FindingWorkflowService`, and existing tenant-safe review visibility semantics.
|
||||
**UI / Surface Guardrails**: The existing tenant findings list, finding detail surface, and findings-derived review readers remain the only changed surfaces. This is a `standard-native-filament` slice with `review-mandatory` treatment; no new queue, no second detail shell, and no page-local outcome language may be introduced.
|
||||
**Filament UI Action Surfaces**: `FindingResource` remains the only mutated Filament action surface, and `ReviewRegister` plus `TenantReviewResource` remain read-only consumers of the shared outcome semantics. No new page family, no second inspect model, no new global search entry point, and no empty action groups may be introduced.
|
||||
**Badges**: Existing `BadgeCatalog` and `BadgeRenderer` semantics remain authoritative. Any terminal-outcome emphasis must extend shared finding presentation rules instead of adding page-local badge or color mappings.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each slice remains independently testable once the shared outcome seam exists. Recommended delivery order is `US1 -> US2 -> US3` because manual canonical reasons must stabilize before trusted system-clear semantics and downstream reporting buckets can converge.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [X] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||
- [X] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||
- [X] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [X] The declared surface test profile or `standard-native-filament` relief is explicit.
|
||||
- [X] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||
|
||||
## Phase 1: Setup (Focused Findings Outcome Test Surfaces)
|
||||
|
||||
**Purpose**: Prepare the narrow regression surfaces that will prove manual terminal reasons, trusted verification, and findings-derived report buckets.
|
||||
|
||||
- [X] T001 [P] Extend the canonical workflow transition test scaffold in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||
- [X] T002 [P] Extend the trusted recurrence and verification-failure scaffold in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`
|
||||
- [X] T003 [P] Extend the terminal-outcome filter scaffold in `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||
- [X] T004 [P] Extend the findings detail presentation scaffold in `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`
|
||||
- [X] T005 [P] Create the findings-derived summary bucket scaffold in `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php`
|
||||
- [X] T006 [P] Extend the accepted-risk projection scaffold in `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||
|
||||
**Checkpoint**: Focused findings workflow, presentation, and reporting test surfaces are ready for implementation work.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Canonical Outcome Seam)
|
||||
|
||||
**Purpose**: Establish the canonical outcome seam and pre-production reason-key normalization before any user story implementation begins.
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T007 Implement canonical resolve, close, and reopen reason-key families in `apps/platform/app/Models/Finding.php`
|
||||
- [X] T008 Create the shared derived terminal-outcome mapper in `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php`
|
||||
- [X] T009 Update canonical historical-context interpretation in `apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php`
|
||||
- [X] T010 Normalize canonical finding outcome fixture states in `apps/platform/database/factories/FindingFactory.php`
|
||||
- [X] T011 Replace pre-production legacy reason keys in `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`
|
||||
- [X] T012 Replace workspace-run legacy reason keys in `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php`
|
||||
- [X] T013 Add baseline canonical-outcome and audit-metadata contract assertions in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||
|
||||
**Checkpoint**: The canonical outcome seam exists, historical context is aligned to canonical keys, and all supporting fixtures and backfill paths use one pre-production key family.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - End A Finding With The Correct Terminal Meaning (Priority: P1) 🎯
|
||||
|
||||
**Goal**: Give operators bounded resolve and close reasons so manual terminal outcomes are explicit instead of prose-driven.
|
||||
|
||||
**Independent Test**: Resolve and close open findings through the existing actions, then verify the workflow response, detail narrative, and terminal-outcome filters show `Resolved pending verification` or the correct administrative closure outcome without depending on free-form text.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T014 [P] [US1] Add canonical manual resolve, close, risk-accept, and reopen validation plus audit-entry assertions in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||
- [X] T015 [P] [US1] Add manual terminal-outcome detail assertions in `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`
|
||||
- [X] T016 [P] [US1] Add manual terminal-outcome filter assertions in `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T017 [US1] Enforce canonical manual resolve, close, risk-accept, and reopen reason keys plus structured audit outcome copy in `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||
- [X] T018 [US1] Align close-action labels, modal copy, and field contract in `apps/platform/app/Support/Ui/GovernanceActions/GovernanceActionCatalog.php`
|
||||
- [X] T019 [US1] Replace single-record resolve and close inputs with canonical selections in `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
- [X] T020 [US1] Replace bulk resolve and close inputs plus completion copy with canonical selections in `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
- [X] T021 [US1] Surface manual terminal-outcome summaries on the finding detail surface in `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional and operators can end findings with bounded manual terminal meanings.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - See Whether A Terminal Finding Was Actually Verified Clear (Priority: P1)
|
||||
|
||||
**Goal**: Distinguish operator-declared resolution from later trusted verified-clear outcomes and structured recurrence reopen paths.
|
||||
|
||||
**Independent Test**: Resolve a finding manually, then apply trusted system clear and trusted recurrence paths and verify the result becomes `Verified cleared` or reopens with a structured reason without adding a new primary status.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T022 [P] [US2] Add verified-clear transition and audit assertions for direct and post-manual system clears in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||
- [X] T023 [P] [US2] Add recurrence and verification-failure reopen plus audit assertions in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`
|
||||
- [X] T024 [P] [US2] Add verified-cleared detail presentation assertions in `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T025 [US2] Widen trusted system clear and reopen handling for verified-clear semantics and audit context in `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||
- [X] T026 [US2] Normalize baseline auto-close system-clear reason keys in `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`
|
||||
- [X] T027 [US2] Normalize Entra admin-role system clear and reopen reason keys in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||
- [X] T028 [US2] Normalize permission-posture system clear and reopen reason keys in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||
- [X] T029 [US2] Align recurrence-driven system reopen keys in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T030 [US2] Surface verified-cleared summaries and historical context on the finding resource in `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional and terminal findings can now distinguish manual resolution from trusted verified clearance.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Filter And Summarize Terminal Outcomes Consistently (Priority: P2)
|
||||
|
||||
**Goal**: Apply one canonical taxonomy across queue filters, review readers, and findings-derived reporting buckets.
|
||||
|
||||
**Independent Test**: Seed mixed terminal outcomes and verify the queue filters, default open backlog, review views, executive-pack export cues, and findings-derived summaries keep `Resolved pending verification`, `Verified cleared`, administrative closures, and `Risk accepted` in distinct buckets.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T031 [P] [US3] Add queue filter coverage for all canonical manual and verified terminal buckets plus default open-backlog regression assertions in `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||
- [X] T032 [P] [US3] Add findings-derived review, reporting, and export-summary bucket coverage in `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php`
|
||||
- [X] T033 [P] [US3] Add accepted-risk governance-bucket separation coverage in `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T034 [US3] Implement terminal-outcome filters and default-visible queue summaries while preserving existing open-backlog defaults in `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
- [X] T035 [US3] Reuse shared outcome semantics in review register findings summaries in `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||
- [X] T036 [US3] Reuse shared outcome semantics in tenant review outcome presentation and executive-pack export cues in `apps/platform/app/Filament/Resources/TenantReviewResource.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional and downstream readers no longer invent their own terminal-outcome taxonomy.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish copy review, formatting, and the narrow proving workflow for the full feature.
|
||||
|
||||
- [X] T037 Review operator-facing outcome labels and modal helper copy in `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||
- [X] T038 Run formatting for the touched findings outcome files referenced in `specs/231-finding-outcome-taxonomy/quickstart.md` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T039 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowServiceTest.php tests/Feature/Findings/FindingRecurrenceTest.php` and `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Filament/FindingResolvedReferencePresentationTest.php tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php` for `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php`, `apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`, `apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php`, and `apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and prepares the focused test surfaces.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user story work until the canonical outcome seam and pre-production key normalization are in place.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the first shippable behavior slice.
|
||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 because trusted verification semantics build on the manual canonical reason contract.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because queue filters and review/report buckets must consume the settled shared taxonomy.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: Requires the canonical manual reason contract from US1.
|
||||
- **US3**: Requires the settled manual and trusted verification semantics from US1 and US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the story tests first and confirm they fail before implementation is considered complete.
|
||||
- Keep `apps/platform/app/Support/Findings/FindingOutcomeSemantics.php` authoritative for derived terminal meaning instead of duplicating page-local mappings.
|
||||
- Finish story-level verification before moving to the next priority slice.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001` through `T006` can run in parallel during Setup.
|
||||
- `T014`, `T015`, and `T016` can run in parallel for User Story 1.
|
||||
- `T022`, `T023`, and `T024` can run in parallel for User Story 2.
|
||||
- `T031`, `T032`, and `T033` can run in parallel for User Story 3.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# User Story 1 tests in parallel
|
||||
T014 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php
|
||||
T015 apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php
|
||||
T016 apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# User Story 2 tests in parallel
|
||||
T022 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php
|
||||
T023 apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php
|
||||
T024 apps/platform/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# User Story 3 tests in parallel
|
||||
T031 apps/platform/tests/Feature/Findings/FindingsListFiltersTest.php
|
||||
T032 apps/platform/tests/Feature/Findings/FindingOutcomeSummaryReportingTest.php
|
||||
T033 apps/platform/tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Complete Phase 4: User Story 2.
|
||||
5. Validate the feature against the focused US1 and US2 tests before widening to downstream review/report consumers.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to replace free-form terminal reasons with bounded manual outcomes.
|
||||
2. Add US2 to close the roadmap gap between operator-declared resolution and trusted verified clearance.
|
||||
3. Add US3 to converge queue filters and findings-derived review/report buckets on the same taxonomy.
|
||||
4. Finish with copy review, formatting, and the focused validation pack.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor can prepare the shared helper and canonical key normalization while another extends the focused test scaffolds.
|
||||
2. After Foundational work lands, manual findings actions and trusted system-clear producer paths can progress in parallel across different files.
|
||||
3. Review-register and tenant-review consumers can be updated in parallel once the settled outcome seam and queue filters are complete.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared.
|
||||
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
|
||||
- The suggested MVP scope is Phase 1 through Phase 4 because the feature promise is incomplete without the verified-clear slice.
|
||||
- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.
|
||||
Loading…
Reference in New Issue
Block a user