From ebf88cd05b90f32ea019c2a3743cd39cfe761070 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 24 Mar 2026 12:23:07 +0100 Subject: [PATCH] feat: implement operator explanation layer --- .github/agents/copilot-instructions.md | 3 +- app/Filament/Pages/BaselineCompareLanding.php | 4 + app/Filament/Pages/Monitoring/AuditLog.php | 33 +++ .../TenantlessOperationRunViewer.php | 26 +- app/Filament/Pages/Reviews/ReviewRegister.php | 4 +- .../Resources/BaselineSnapshotResource.php | 4 +- .../Resources/OperationRunResource.php | 20 +- .../Resources/TenantReviewResource.php | 5 +- app/Jobs/CompareBaselineToTenantJob.php | 35 +++ app/Models/OperationRun.php | 5 + .../Baselines/BaselineCompareService.php | 27 +- .../BaselineSnapshotPresenter.php | 16 +- app/Support/Badges/BadgeCatalog.php | 2 + app/Support/Badges/BadgeDomain.php | 2 + ...eratorExplanationEvaluationResultBadge.php | 28 ++ ...peratorExplanationTrustworthinessBadge.php | 27 ++ .../Badges/OperatorOutcomeTaxonomy.php | 91 ++++++- .../BaselineCompareExplanationRegistry.php | 203 ++++++++++++++ .../Baselines/BaselineCompareReasonCode.php | 36 +++ .../Baselines/BaselineCompareStats.php | 27 ++ app/Support/Baselines/BaselineReasonCodes.php | 54 ++++ app/Support/OperationCatalog.php | 8 + app/Support/OpsUx/OperationUxPresenter.php | 66 ++++- .../FallbackReasonTranslator.php | 35 +++ .../ReasonTranslation/ReasonPresenter.php | 55 +++- .../ReasonResolutionEnvelope.php | 35 +++ .../ReasonTranslation/ReasonTranslator.php | 66 +++++ .../ArtifactTruthCause.php | 26 ++ .../ArtifactTruthEnvelope.php | 8 +- .../ArtifactTruthPresenter.php | 140 +++++++++- .../OperatorExplanation/CountDescriptor.php | 67 +++++ .../OperatorExplanation/ExplanationFamily.php | 17 ++ .../OperatorExplanationBuilder.php | 245 +++++++++++++++++ .../OperatorExplanationPattern.php | 135 ++++++++++ .../TrustworthinessLevel.php | 13 + .../WorkspaceIsolation/TenantOwnedTables.php | 1 + docs/product/spec-candidates.md | 58 +++- .../governance-artifact-truth.blade.php | 77 +++++- .../entries/tenant-review-summary.blade.php | 21 ++ .../pages/baseline-compare-landing.blade.php | 98 +++++++ .../pages/monitoring/audit-log.blade.php | 12 + .../pages/monitoring/operations.blade.php | 6 +- .../bulk-operation-progress.blade.php | 2 + .../checklists/requirements.md | 36 +++ .../contracts/openapi.yaml | 235 +++++++++++++++++ .../data-model.md | 163 ++++++++++++ specs/161-operator-explanation-layer/plan.md | 247 ++++++++++++++++++ .../quickstart.md | 58 ++++ .../research.md | 57 ++++ specs/161-operator-explanation-layer/spec.md | 152 +++++++++++ specs/161-operator-explanation-layer/tasks.md | 217 +++++++++++++++ ...torExplanationSurfaceAuthorizationTest.php | 101 +++++++ ...BaselineCompareExplanationFallbackTest.php | 50 ++++ ...lineCompareWhyNoFindingsReasonCodeTest.php | 32 +++ .../BuildsOperatorExplanationFixtures.php | 67 +++++ .../Evidence/EvidenceSnapshotResourceTest.php | 2 +- ...ineCaptureResultExplanationSurfaceTest.php | 44 ++++ .../BaselineCompareExplanationSurfaceTest.php | 86 ++++++ .../OperationRunBaselineTruthSurfaceTest.php | 56 ++++ .../Monitoring/ArtifactTruthRunDetailTest.php | 13 +- .../GovernanceRunExplanationFallbackTest.php | 47 ++++ .../Monitoring/OperationsTenantScopeTest.php | 6 +- ...ionRunBlockedExecutionPresentationTest.php | 2 +- .../ReasonTranslationExplanationTest.php | 37 +++ .../ReviewPack/ReviewPackResourceTest.php | 2 +- .../TenantReviewExplanationSurfaceTest.php | 52 ++++ .../TenantReviewLifecycleTest.php | 2 +- .../TenantReviewRegisterPrefilterTest.php | 4 +- .../TenantReviewRegisterRbacTest.php | 2 +- .../TenantReviewUiContractTest.php | 2 +- .../Badges/GovernanceArtifactTruthTest.php | 42 ++- .../Badges/OperatorOutcomeTaxonomyTest.php | 7 + .../Evidence/EvidenceSnapshotBadgeTest.php | 4 +- .../OperationRunExplanationTest.php | 39 +++ .../OperatorExplanationBuilderTest.php | 66 +++++ .../TenantReview/TenantReviewBadgeTest.php | 2 +- 76 files changed, 3705 insertions(+), 70 deletions(-) create mode 100644 app/Support/Badges/Domains/OperatorExplanationEvaluationResultBadge.php create mode 100644 app/Support/Badges/Domains/OperatorExplanationTrustworthinessBadge.php create mode 100644 app/Support/Baselines/BaselineCompareExplanationRegistry.php create mode 100644 app/Support/Ui/OperatorExplanation/CountDescriptor.php create mode 100644 app/Support/Ui/OperatorExplanation/ExplanationFamily.php create mode 100644 app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php create mode 100644 app/Support/Ui/OperatorExplanation/OperatorExplanationPattern.php create mode 100644 app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php create mode 100644 specs/161-operator-explanation-layer/checklists/requirements.md create mode 100644 specs/161-operator-explanation-layer/contracts/openapi.yaml create mode 100644 specs/161-operator-explanation-layer/data-model.md create mode 100644 specs/161-operator-explanation-layer/plan.md create mode 100644 specs/161-operator-explanation-layer/quickstart.md create mode 100644 specs/161-operator-explanation-layer/research.md create mode 100644 specs/161-operator-explanation-layer/spec.md create mode 100644 specs/161-operator-explanation-layer/tasks.md create mode 100644 tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php create mode 100644 tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php create mode 100644 tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php create mode 100644 tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php create mode 100644 tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php create mode 100644 tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php create mode 100644 tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php create mode 100644 tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php create mode 100644 tests/Unit/Support/OperatorExplanation/OperationRunExplanationTest.php create mode 100644 tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 8637d2fc..35b9ec3e 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -102,6 +102,7 @@ ## Active Technologies - PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics) - PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees) - PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees) +- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer) - PHP 8.4.15 (feat/005-bulk-operations) @@ -121,8 +122,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 160-operation-lifecycle-guarantees: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages - 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 -- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index fe5371b1..25fcf44e 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -89,6 +89,9 @@ class BaselineCompareLanding extends Page /** @var array|null */ public ?array $rbacRoleDefinitionSummary = null; + /** @var array|null */ + public ?array $operatorExplanation = null; + public static function canAccess(): bool { $user = auth()->user(); @@ -140,6 +143,7 @@ public function refreshStats(): void $this->evidenceGapsCount = $stats->evidenceGapsCount; $this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null; $this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null; + $this->operatorExplanation = $stats->operatorExplanation()->toArray(); } /** diff --git a/app/Filament/Pages/Monitoring/AuditLog.php b/app/Filament/Pages/Monitoring/AuditLog.php index 38f556bd..4413ae22 100644 --- a/app/Filament/Pages/Monitoring/AuditLog.php +++ b/app/Filament/Pages/Monitoring/AuditLog.php @@ -43,6 +43,8 @@ class AuditLog extends Page implements HasTable { use InteractsWithTable; + public ?int $selectedAuditLogId = null; + protected static bool $isDiscovered = false; protected static bool $shouldRegisterNavigation = false; @@ -89,6 +91,7 @@ public function mount(): void if ($requestedEventId !== null) { $this->resolveAuditLog($requestedEventId); + $this->selectedAuditLogId = $requestedEventId; $this->mountTableAction('inspect', (string) $requestedEventId); } } @@ -174,6 +177,9 @@ public function table(Table $table): Table ->label('Inspect event') ->icon('heroicon-o-eye') ->color('gray') + ->before(function (AuditLogModel $record): void { + $this->selectedAuditLogId = (int) $record->getKey(); + }) ->slideOver() ->stickyModalHeader() ->modalSubmitAction(false) @@ -285,6 +291,33 @@ private function resolveAuditLog(int $auditLogId): AuditLogModel return $record; } + public function selectedAuditRecord(): ?AuditLogModel + { + if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) { + return null; + } + + try { + return $this->resolveAuditLog($this->selectedAuditLogId); + } catch (NotFoundHttpException) { + return null; + } + } + + /** + * @return array{label: string, url: string}|null + */ + public function selectedAuditTargetLink(): ?array + { + $record = $this->selectedAuditRecord(); + + if (! $record instanceof AuditLogModel) { + return null; + } + + return $this->auditTargetLink($record); + } + /** * @return array{label: string, url: string}|null */ diff --git a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index 62480362..57f140b7 100644 --- a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -24,6 +24,8 @@ use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantOperabilityQuestion; +use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; +use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Notifications\Notification; @@ -170,11 +172,18 @@ public function blockedExecutionBanner(): ?array return null; } + $operatorExplanation = $this->governanceOperatorExplanation(); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail'); - $lines = $reasonEnvelope?->toBodyLines() ?? [ - OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.', - OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.', - ]; + $lines = $operatorExplanation instanceof OperatorExplanationPattern + ? array_values(array_filter([ + $operatorExplanation->headline, + $operatorExplanation->dominantCauseExplanation, + OperationUxPresenter::surfaceGuidance($this->run), + ])) + : ($reasonEnvelope?->toBodyLines() ?? [ + OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.', + OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.', + ]); return [ 'tone' => 'amber', @@ -451,4 +460,13 @@ private function relatedLinksTenant(): ?Tenant lane: TenantInteractionLane::StandardActiveOperating, )->allowed ? $tenant : null; } + + private function governanceOperatorExplanation(): ?OperatorExplanationPattern + { + if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) { + return null; + } + + return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation; + } } diff --git a/app/Filament/Pages/Reviews/ReviewRegister.php b/app/Filament/Pages/Reviews/ReviewRegister.php index 44363c7f..c72042ee 100644 --- a/app/Filament/Pages/Reviews/ReviewRegister.php +++ b/app/Filament/Pages/Reviews/ReviewRegister.php @@ -122,7 +122,7 @@ public function table(Table $table): Table ->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color) ->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon) ->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor) - ->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation) + ->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation) ->wrap(), TextColumn::make('completeness_state') ->label('Completeness') @@ -154,7 +154,7 @@ public function table(Table $table): Table )->iconColor), TextColumn::make('artifact_next_step') ->label('Next step') - ->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText()) + ->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText()) ->wrap(), ]) ->filters([ diff --git a/app/Filament/Resources/BaselineSnapshotResource.php b/app/Filament/Resources/BaselineSnapshotResource.php index 525f2011..1fac0486 100644 --- a/app/Filament/Resources/BaselineSnapshotResource.php +++ b/app/Filament/Resources/BaselineSnapshotResource.php @@ -179,7 +179,7 @@ public static function table(Table $table): Table ->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color) ->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon) ->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor) - ->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryExplanation) + ->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation) ->wrap(), TextColumn::make('lifecycle_state') ->label('Lifecycle') @@ -203,7 +203,7 @@ public static function table(Table $table): Table ->wrap(), TextColumn::make('artifact_next_step') ->label('Next step') - ->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText()) + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText()) ->wrap(), ]) ->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record) diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index c6c9016c..31cba965 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -261,9 +261,10 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support $referencedTenantLifecycle = $record->tenant instanceof Tenant ? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant) : null; - $artifactTruth = $record->isGovernanceArtifactOperation() + $artifactTruth = $record->supportsOperatorExplanation() ? app(ArtifactTruthPresenter::class)->forOperationRun($record) : null; + $operatorExplanation = $artifactTruth?->operatorExplanation; $artifactTruthBadge = $artifactTruth !== null ? $factory->statusBadge( $artifactTruth->primaryBadgeSpec()->label, @@ -307,7 +308,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support title: 'Artifact truth', view: 'filament.infolists.entries.governance-artifact-truth', viewData: ['artifactTruthState' => $artifactTruth?->toArray()], - visible: $record->isGovernanceArtifactOperation(), + visible: $artifactTruth !== null, description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.', ), $factory->viewSection( @@ -330,6 +331,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support $artifactTruth !== null ? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge) : null, + $operatorExplanation !== null + ? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel()) + : null, + $operatorExplanation !== null + ? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel()) + : null, $referencedTenantLifecycle !== null ? $factory->keyFact( 'Tenant lifecycle', @@ -360,8 +367,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support static::reconciliationSourceLabel($record) !== null ? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record)) : null, - $artifactTruth !== null - ? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText()) + $operatorExplanation !== null + ? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText) + : ($artifactTruth !== null + ? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText()) + : null), + $operatorExplanation !== null && $operatorExplanation->coverageStatement !== null + ? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement) : null, OperationUxPresenter::surfaceGuidance($record) !== null ? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record)) diff --git a/app/Filament/Resources/TenantReviewResource.php b/app/Filament/Resources/TenantReviewResource.php index 848753fa..f717b9a3 100644 --- a/app/Filament/Resources/TenantReviewResource.php +++ b/app/Filament/Resources/TenantReviewResource.php @@ -257,7 +257,7 @@ public static function table(Table $table): Table ->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color) ->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon) ->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor) - ->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation) + ->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation) ->wrap(), Tables\Columns\TextColumn::make('completeness_state') ->label('Completeness') @@ -295,7 +295,7 @@ public static function table(Table $table): Table ->boolean(), Tables\Columns\TextColumn::make('artifact_next_step') ->label('Next step') - ->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText()) + ->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText()) ->wrap(), Tables\Columns\TextColumn::make('fingerprint') ->toggleable(isToggledHiddenByDefault: true) @@ -563,6 +563,7 @@ private static function summaryPresentation(TenantReview $record): array $summary = is_array($record->summary) ? $record->summary : []; return [ + 'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(), 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['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'] : [], diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index fa240806..1d022724 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -47,6 +47,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; +use App\Support\ReasonTranslation\ReasonPresenter; use Carbon\CarbonImmutable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -319,6 +320,7 @@ public function handle( 'snapshot_id' => (int) $snapshot->getKey(), ], ); + $context = $this->withCompareReasonTranslation($context, $reasonCode); $this->operationRun->update(['context' => $context]); $this->operationRun->refresh(); @@ -597,6 +599,10 @@ public function handle( 'findings_resolved' => $resolvedCount, 'severity_breakdown' => $severityBreakdown, ]; + $updatedContext = $this->withCompareReasonTranslation( + $updatedContext, + $reasonCode?->value, + ); $this->operationRun->update(['context' => $updatedContext]); $this->auditCompleted( @@ -842,6 +848,7 @@ private function completeWithCoverageWarning( 'findings_resolved' => 0, 'severity_breakdown' => [], ]; + $updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value); $this->operationRun->update(['context' => $updatedContext]); @@ -948,6 +955,34 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array ]; } + /** + * @param array $context + * @return array + */ + private function withCompareReasonTranslation(array $context, ?string $reasonCode): array + { + if (! is_string($reasonCode) || trim($reasonCode) === '') { + unset($context['reason_translation'], $context['next_steps']); + + return $context; + } + + $translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare'); + + if ($translation === null) { + return $context; + } + + $context['reason_translation'] = $translation->toArray(); + $context['reason_code'] = $reasonCode; + + if ($translation->toLegacyNextSteps() !== []) { + $context['next_steps'] = $translation->toLegacyNextSteps(); + } + + return $context; + } + /** * Load current inventory items keyed by "policy_type|subject_key". * diff --git a/app/Models/OperationRun.php b/app/Models/OperationRun.php index 8f728601..ffc3447e 100644 --- a/app/Models/OperationRun.php +++ b/app/Models/OperationRun.php @@ -135,6 +135,11 @@ public function isGovernanceArtifactOperation(): bool return OperationCatalog::isGovernanceArtifactOperation((string) $this->type); } + public function supportsOperatorExplanation(): bool + { + return OperationCatalog::supportsOperatorExplanation((string) $this->type); + } + public function governanceArtifactFamily(): ?string { return OperationCatalog::governanceArtifactFamily((string) $this->type); diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php index 11886548..46664068 100644 --- a/app/Services/Baselines/BaselineCompareService.php +++ b/app/Services/Baselines/BaselineCompareService.php @@ -18,6 +18,7 @@ use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineScope; use App\Support\OperationRunType; +use App\Support\ReasonTranslation\ReasonPresenter; final class BaselineCompareService { @@ -28,7 +29,7 @@ public function __construct( ) {} /** - * @return array{ok: bool, run?: OperationRun, reason_code?: string} + * @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array} */ public function startCompare( Tenant $tenant, @@ -41,19 +42,19 @@ public function startCompare( ->first(); if (! $assignment instanceof BaselineTenantAssignment) { - return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT]; + return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT); } $profile = BaselineProfile::query()->find($assignment->baseline_profile_id); if (! $profile instanceof BaselineProfile) { - return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE]; + return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); } $precondition = $this->validatePreconditions($profile); if ($precondition !== null) { - return ['ok' => false, 'reason_code' => $precondition]; + return $this->failedStart($precondition); } $selectedSnapshot = null; @@ -66,14 +67,14 @@ public function startCompare( ->first(); if (! $selectedSnapshot instanceof BaselineSnapshot) { - return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT]; + return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT); } } $snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot); if (! ($snapshotResolution['ok'] ?? false)) { - return ['ok' => false, 'reason_code' => $snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT]; + return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT); } /** @var BaselineSnapshot $snapshot */ @@ -133,4 +134,18 @@ private function validatePreconditions(BaselineProfile $profile): ?string return null; } + + /** + * @return array{ok: false, reason_code: string, reason_translation?: array} + */ + private function failedStart(string $reasonCode): array + { + $translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare'); + + return array_filter([ + 'ok' => false, + 'reason_code' => $reasonCode, + 'reason_translation' => $translation?->toArray(), + ], static fn (mixed $value): bool => $value !== null); + } } diff --git a/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php b/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php index 156bb040..1e2e770b 100644 --- a/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php +++ b/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php @@ -136,6 +136,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat $currentTruth['icon'], $currentTruth['iconColor'], ); + $operatorExplanation = $truth->operatorExplanation; return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace') ->header(new SummaryHeaderData( @@ -191,12 +192,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat $factory->supportingFactsCard( kind: 'status', title: 'Snapshot truth', - items: [ + items: array_values(array_filter([ $factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge), + $operatorExplanation !== null + ? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel()) + : null, + $operatorExplanation !== null + ? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel()) + : null, $factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge), $factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge), - $factory->keyFact('Next step', $truth->nextStepText()), - ], + $operatorExplanation !== null && $operatorExplanation->coverageStatement !== null + ? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement) + : null, + $factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()), + ])), ), $factory->supportingFactsCard( kind: 'coverage', diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php index c1c844ff..90387cda 100644 --- a/app/Support/Badges/BadgeCatalog.php +++ b/app/Support/Badges/BadgeCatalog.php @@ -20,6 +20,8 @@ final class BadgeCatalog BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class, BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class, BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class, + BadgeDomain::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class, + BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class, BadgeDomain::BaselineSnapshotLifecycle->value => Domains\BaselineSnapshotLifecycleBadge::class, BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class, BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class, diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index 8b8e4b95..2b0314b8 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -11,6 +11,8 @@ enum BadgeDomain: string case GovernanceArtifactFreshness = 'governance_artifact_freshness'; case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness'; case GovernanceArtifactActionability = 'governance_artifact_actionability'; + case OperatorExplanationEvaluationResult = 'operator_explanation_evaluation_result'; + case OperatorExplanationTrustworthiness = 'operator_explanation_trustworthiness'; case BaselineSnapshotLifecycle = 'baseline_snapshot_lifecycle'; case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity'; case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status'; diff --git a/app/Support/Badges/Domains/OperatorExplanationEvaluationResultBadge.php b/app/Support/Badges/Domains/OperatorExplanationEvaluationResultBadge.php new file mode 100644 index 00000000..3a112c0d --- /dev/null +++ b/app/Support/Badges/Domains/OperatorExplanationEvaluationResultBadge.php @@ -0,0 +1,28 @@ + OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'), + 'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'), + 'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'), + 'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'), + 'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'), + default => BadgeSpec::unknown(), + } ?? BadgeSpec::unknown(); + } +} diff --git a/app/Support/Badges/Domains/OperatorExplanationTrustworthinessBadge.php b/app/Support/Badges/Domains/OperatorExplanationTrustworthinessBadge.php new file mode 100644 index 00000000..10675f48 --- /dev/null +++ b/app/Support/Badges/Domains/OperatorExplanationTrustworthinessBadge.php @@ -0,0 +1,27 @@ + OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-check-badge'), + 'limited_confidence' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-exclamation-triangle'), + 'diagnostic_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-beaker'), + 'unusable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-x-circle'), + default => BadgeSpec::unknown(), + } ?? BadgeSpec::unknown(); + } +} diff --git a/app/Support/Badges/OperatorOutcomeTaxonomy.php b/app/Support/Badges/OperatorOutcomeTaxonomy.php index f7eebe53..8e886dec 100644 --- a/app/Support/Badges/OperatorOutcomeTaxonomy.php +++ b/app/Support/Badges/OperatorOutcomeTaxonomy.php @@ -71,7 +71,7 @@ final class OperatorOutcomeTaxonomy ], 'partial' => [ 'axis' => 'data_coverage', - 'label' => 'Partial', + 'label' => 'Partially complete', 'color' => 'warning', 'classification' => 'primary', 'next_action_policy' => 'required', @@ -136,7 +136,7 @@ final class OperatorOutcomeTaxonomy ], 'stale' => [ 'axis' => 'data_freshness', - 'label' => 'Stale', + 'label' => 'Refresh recommended', 'color' => 'warning', 'classification' => 'primary', 'next_action_policy' => 'optional', @@ -183,7 +183,7 @@ final class OperatorOutcomeTaxonomy ], 'blocked' => [ 'axis' => 'publication_readiness', - 'label' => 'Blocked', + 'label' => 'Publication blocked', 'color' => 'warning', 'classification' => 'primary', 'next_action_policy' => 'required', @@ -220,6 +220,91 @@ final class OperatorOutcomeTaxonomy 'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.', ], ], + 'operator_explanation_evaluation_result' => [ + 'full_result' => [ + 'axis' => 'execution_outcome', + 'label' => 'Complete result', + 'color' => 'success', + 'classification' => 'primary', + 'next_action_policy' => 'none', + 'legacy_aliases' => ['Full result'], + 'notes' => 'The result can be read as complete for the intended operator decision.', + ], + 'incomplete_result' => [ + 'axis' => 'data_coverage', + 'label' => 'Incomplete result', + 'color' => 'warning', + 'classification' => 'primary', + 'next_action_policy' => 'required', + 'legacy_aliases' => ['Partial result'], + 'notes' => 'A result exists, but missing or partial coverage limits what it means.', + ], + 'suppressed_result' => [ + 'axis' => 'data_coverage', + 'label' => 'Suppressed result', + 'color' => 'warning', + 'classification' => 'primary', + 'next_action_policy' => 'required', + 'legacy_aliases' => ['Suppressed'], + 'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.', + ], + 'no_result' => [ + 'axis' => 'execution_outcome', + 'label' => 'No issues detected', + 'color' => 'success', + 'classification' => 'primary', + 'next_action_policy' => 'none', + 'legacy_aliases' => ['No result'], + 'notes' => 'The workflow produced no decision-relevant follow-up for the operator.', + ], + 'unavailable' => [ + 'axis' => 'execution_outcome', + 'label' => 'Result unavailable', + 'color' => 'warning', + 'classification' => 'primary', + 'next_action_policy' => 'required', + 'legacy_aliases' => ['Unavailable'], + 'notes' => 'A usable result is not currently available for this surface.', + ], + ], + 'operator_explanation_trustworthiness' => [ + 'trustworthy' => [ + 'axis' => 'data_coverage', + 'label' => 'Trustworthy', + 'color' => 'success', + 'classification' => 'primary', + 'next_action_policy' => 'none', + 'legacy_aliases' => ['Decision grade'], + 'notes' => 'The operator can rely on this result for the intended task.', + ], + 'limited_confidence' => [ + 'axis' => 'data_coverage', + 'label' => 'Limited confidence', + 'color' => 'warning', + 'classification' => 'primary', + 'next_action_policy' => 'optional', + 'legacy_aliases' => ['Use with caution'], + 'notes' => 'The result is still useful, but the operator should account for documented limitations.', + ], + 'diagnostic_only' => [ + 'axis' => 'evidence_depth', + 'label' => 'Diagnostic only', + 'color' => 'info', + 'classification' => 'diagnostic', + 'next_action_policy' => 'none', + 'legacy_aliases' => ['Diagnostics only'], + 'notes' => 'The result is suitable for diagnostics only, not for a final decision.', + ], + 'unusable' => [ + 'axis' => 'operator_actionability', + 'label' => 'Not usable yet', + 'color' => 'danger', + 'classification' => 'primary', + 'next_action_policy' => 'required', + 'legacy_aliases' => ['Unusable'], + 'notes' => 'The operator should not rely on this result until the blocking issue is resolved.', + ], + ], 'baseline_snapshot_lifecycle' => [ 'building' => [ 'axis' => 'execution_lifecycle', diff --git a/app/Support/Baselines/BaselineCompareExplanationRegistry.php b/app/Support/Baselines/BaselineCompareExplanationRegistry.php new file mode 100644 index 00000000..3811b1a3 --- /dev/null +++ b/app/Support/Baselines/BaselineCompareExplanationRegistry.php @@ -0,0 +1,203 @@ +reasonCode !== null + ? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare') + : null; + $hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true); + $hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0; + $hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps; + $findingsCount = (int) ($stats->findingsCount ?? 0); + $executionOutcome = match ($stats->state) { + 'comparing' => 'in_progress', + 'failed' => 'failed', + default => $hasWarnings ? 'completed_with_follow_up' : 'completed', + }; + $executionOutcomeLabel = match ($executionOutcome) { + 'in_progress' => 'In progress', + 'failed' => 'Execution failed', + 'completed_with_follow_up' => 'Completed with follow-up', + default => 'Completed successfully', + }; + $family = $reason?->absencePattern !== null + ? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null + : null; + $family ??= match (true) { + $stats->state === 'comparing' => ExplanationFamily::InProgress, + $stats->state === 'failed' => ExplanationFamily::BlockedPrerequisite, + $stats->state === 'no_tenant', + $stats->state === 'no_assignment', + $stats->state === 'no_snapshot', + $stats->state === 'idle' => ExplanationFamily::Unavailable, + $findingsCount === 0 && ! $hasWarnings => ExplanationFamily::NoIssuesDetected, + $hasWarnings => ExplanationFamily::CompletedButLimited, + default => ExplanationFamily::TrustworthyResult, + }; + $trustworthiness = $reason?->trustImpact !== null + ? TrustworthinessLevel::tryFrom($reason->trustImpact) + : null; + $trustworthiness ??= match (true) { + $family === ExplanationFamily::NoIssuesDetected, + $family === ExplanationFamily::TrustworthyResult => TrustworthinessLevel::Trustworthy, + $family === ExplanationFamily::CompletedButLimited => TrustworthinessLevel::LimitedConfidence, + $family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly, + default => TrustworthinessLevel::Unusable, + }; + $evaluationResult = match ($family) { + ExplanationFamily::TrustworthyResult => 'full_result', + ExplanationFamily::NoIssuesDetected => 'no_result', + ExplanationFamily::SuppressedOutput => 'suppressed_result', + ExplanationFamily::MissingInput, + ExplanationFamily::BlockedPrerequisite, + ExplanationFamily::Unavailable, + ExplanationFamily::InProgress => 'unavailable', + ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output' + ? 'suppressed_result' + : 'incomplete_result', + }; + $headline = match ($family) { + ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.', + ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.', + ExplanationFamily::CompletedButLimited => $findingsCount > 0 + ? 'The comparison found drift, but the result needs caution.' + : 'The comparison finished, but the current result is not an all-clear.', + ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.', + ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.', + ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.', + ExplanationFamily::InProgress => 'The comparison is still running.', + ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.', + }; + $coverageStatement = match (true) { + $stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.', + $stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.', + $stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.', + $hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.', + $stats->state === 'comparing' => 'Counts will become decision-grade after the compare run finishes.', + in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.', + default => 'Coverage matched the in-scope compare input for this run.', + }; + $reliabilityStatement = match ($trustworthiness) { + TrustworthinessLevel::Trustworthy => $findingsCount > 0 + ? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.' + : 'The compare completed with enough coverage to treat the absence of findings as trustworthy.', + TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.', + TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.', + TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.', + }; + $nextActionText = $reason?->firstNextStep()?->label ?? match ($family) { + ExplanationFamily::NoIssuesDetected => 'No action needed', + ExplanationFamily::TrustworthyResult => 'Review the detected drift findings', + ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete', + ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare', + ExplanationFamily::InProgress => 'Wait for the compare to finish', + ExplanationFamily::MissingInput, + ExplanationFamily::BlockedPrerequisite, + ExplanationFamily::Unavailable => $stats->state === 'idle' + ? 'Run the baseline compare to generate a result' + : 'Review the blocking baseline or scope prerequisite', + }; + + return $this->builder->build( + family: $family, + headline: $headline, + executionOutcome: $executionOutcome, + executionOutcomeLabel: $executionOutcomeLabel, + evaluationResult: $evaluationResult, + trustworthinessLevel: $trustworthiness, + reliabilityStatement: $reliabilityStatement, + coverageStatement: $coverageStatement, + dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode, + dominantCauseLabel: $reason?->operatorLabel, + dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason, + nextActionCategory: $family === ExplanationFamily::NoIssuesDetected + ? 'none' + : match ($family) { + ExplanationFamily::TrustworthyResult => 'manual_validate', + ExplanationFamily::MissingInput, + ExplanationFamily::BlockedPrerequisite, + ExplanationFamily::Unavailable => 'fix_prerequisite', + default => 'review_evidence_gaps', + }, + nextActionText: $nextActionText, + countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps), + diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null, + diagnosticsSummary: 'Run evidence and low-level compare diagnostics remain available below the primary explanation.', + ); + } + + /** + * @return array + */ + private function countDescriptors( + BaselineCompareStats $stats, + bool $hasCoverageWarnings, + bool $hasEvidenceGaps, + ): array { + $descriptors = []; + + if ($stats->findingsCount !== null) { + $descriptors[] = new CountDescriptor( + label: 'Findings shown', + value: (int) $stats->findingsCount, + role: CountDescriptor::ROLE_EVALUATION_OUTPUT, + qualifier: $hasCoverageWarnings || $hasEvidenceGaps ? 'not complete' : null, + ); + } + + if ($stats->uncoveredTypesCount !== null) { + $descriptors[] = new CountDescriptor( + label: 'Uncovered types', + value: (int) $stats->uncoveredTypesCount, + role: CountDescriptor::ROLE_COVERAGE, + qualifier: (int) $stats->uncoveredTypesCount > 0 ? 'coverage gap' : null, + ); + } + + if ($stats->evidenceGapsCount !== null) { + $descriptors[] = new CountDescriptor( + label: 'Evidence gaps', + value: (int) $stats->evidenceGapsCount, + role: CountDescriptor::ROLE_RELIABILITY_SIGNAL, + qualifier: (int) $stats->evidenceGapsCount > 0 ? 'review needed' : null, + ); + } + + if ($stats->severityCounts !== []) { + foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) { + $value = (int) ($stats->severityCounts[$key] ?? 0); + + if ($value === 0) { + continue; + } + + $descriptors[] = new CountDescriptor( + label: $label, + value: $value, + role: CountDescriptor::ROLE_EVALUATION_OUTPUT, + visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC, + ); + } + } + + return $descriptors; + } +} diff --git a/app/Support/Baselines/BaselineCompareReasonCode.php b/app/Support/Baselines/BaselineCompareReasonCode.php index fbbbef57..b52d8ec1 100644 --- a/app/Support/Baselines/BaselineCompareReasonCode.php +++ b/app/Support/Baselines/BaselineCompareReasonCode.php @@ -4,6 +4,9 @@ namespace App\Support\Baselines; +use App\Support\Ui\OperatorExplanation\ExplanationFamily; +use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; + enum BaselineCompareReasonCode: string { case NoSubjectsInScope = 'no_subjects_in_scope'; @@ -22,4 +25,37 @@ public function message(): string self::NoDriftDetected => 'No drift was detected for in-scope subjects.', }; } + + public function explanationFamily(): ExplanationFamily + { + return match ($this) { + self::NoDriftDetected => ExplanationFamily::NoIssuesDetected, + self::CoverageUnproven, + self::EvidenceCaptureIncomplete, + self::RolloutDisabled => ExplanationFamily::CompletedButLimited, + self::NoSubjectsInScope => ExplanationFamily::MissingInput, + }; + } + + public function trustworthinessLevel(): TrustworthinessLevel + { + return match ($this) { + self::NoDriftDetected => TrustworthinessLevel::Trustworthy, + self::CoverageUnproven, + self::EvidenceCaptureIncomplete => TrustworthinessLevel::LimitedConfidence, + self::RolloutDisabled, + self::NoSubjectsInScope => TrustworthinessLevel::Unusable, + }; + } + + public function absencePattern(): ?string + { + return match ($this) { + self::NoDriftDetected => 'true_no_result', + self::CoverageUnproven, + self::EvidenceCaptureIncomplete => 'suppressed_output', + self::RolloutDisabled => 'blocked_prerequisite', + self::NoSubjectsInScope => 'missing_input', + }; + } } diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index c2b78815..94b9f207 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -14,6 +14,8 @@ use App\Services\Baselines\BaselineSnapshotTruthResolver; use App\Support\OperationRunStatus; use App\Support\OperationRunType; +use App\Support\Ui\OperatorExplanation\CountDescriptor; +use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use Illuminate\Support\Facades\Cache; final class BaselineCompareStats @@ -583,6 +585,31 @@ private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?ar ]; } + public function operatorExplanation(): OperatorExplanationPattern + { + /** @var BaselineCompareExplanationRegistry $registry */ + $registry = app(BaselineCompareExplanationRegistry::class); + + return $registry->forStats($this); + } + + /** + * @return array + */ + public function explanationCountDescriptors(): array + { + return array_map( + static fn (CountDescriptor $descriptor): array => $descriptor->toArray(), + $this->operatorExplanation()->countDescriptors, + ); + } + private static function empty( string $state, ?string $message, diff --git a/app/Support/Baselines/BaselineReasonCodes.php b/app/Support/Baselines/BaselineReasonCodes.php index 07e02e1c..e20fad6c 100644 --- a/app/Support/Baselines/BaselineReasonCodes.php +++ b/app/Support/Baselines/BaselineReasonCodes.php @@ -85,4 +85,58 @@ public static function isKnown(?string $reasonCode): bool { return is_string($reasonCode) && in_array(trim($reasonCode), self::all(), true); } + + public static function trustImpact(?string $reasonCode): ?string + { + return match (trim((string) $reasonCode)) { + self::SNAPSHOT_CAPTURE_FAILED => 'limited_confidence', + self::COMPARE_ROLLOUT_DISABLED, + self::CAPTURE_ROLLOUT_DISABLED, + self::SNAPSHOT_BUILDING, + self::SNAPSHOT_INCOMPLETE, + self::SNAPSHOT_SUPERSEDED, + self::SNAPSHOT_COMPLETION_PROOF_FAILED, + self::SNAPSHOT_LEGACY_NO_PROOF, + self::SNAPSHOT_LEGACY_CONTRADICTORY, + self::COMPARE_NO_ASSIGNMENT, + self::COMPARE_PROFILE_NOT_ACTIVE, + self::COMPARE_NO_ACTIVE_SNAPSHOT, + self::COMPARE_NO_CONSUMABLE_SNAPSHOT, + self::COMPARE_NO_ELIGIBLE_TARGET, + self::COMPARE_INVALID_SNAPSHOT, + self::COMPARE_SNAPSHOT_BUILDING, + self::COMPARE_SNAPSHOT_INCOMPLETE, + self::COMPARE_SNAPSHOT_SUPERSEDED, + self::CAPTURE_MISSING_SOURCE_TENANT, + self::CAPTURE_PROFILE_NOT_ACTIVE => 'unusable', + default => null, + }; + } + + public static function absencePattern(?string $reasonCode): ?string + { + return match (trim((string) $reasonCode)) { + self::SNAPSHOT_BUILDING, + self::SNAPSHOT_INCOMPLETE, + self::SNAPSHOT_COMPLETION_PROOF_FAILED, + self::SNAPSHOT_LEGACY_NO_PROOF, + self::SNAPSHOT_LEGACY_CONTRADICTORY, + self::COMPARE_NO_ACTIVE_SNAPSHOT, + self::COMPARE_NO_CONSUMABLE_SNAPSHOT, + self::COMPARE_SNAPSHOT_BUILDING, + self::COMPARE_SNAPSHOT_INCOMPLETE => 'missing_input', + self::CAPTURE_MISSING_SOURCE_TENANT, + self::CAPTURE_PROFILE_NOT_ACTIVE, + self::CAPTURE_ROLLOUT_DISABLED, + self::COMPARE_NO_ASSIGNMENT, + self::COMPARE_PROFILE_NOT_ACTIVE, + self::COMPARE_NO_ELIGIBLE_TARGET, + self::COMPARE_INVALID_SNAPSHOT, + self::COMPARE_ROLLOUT_DISABLED, + self::SNAPSHOT_SUPERSEDED, + self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite', + self::SNAPSHOT_CAPTURE_FAILED => 'unavailable', + default => null, + }; + } } diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index 69fca878..351f942b 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -121,4 +121,12 @@ public static function isGovernanceArtifactOperation(string $operationType): boo { return self::governanceArtifactFamily($operationType) !== null; } + + public static function supportsOperatorExplanation(string $operationType): bool + { + $operationType = trim($operationType); + + return self::isGovernanceArtifactOperation($operationType) + || $operationType === 'baseline_compare'; + } } diff --git a/app/Support/OpsUx/OperationUxPresenter.php b/app/Support/OpsUx/OperationUxPresenter.php index 222ae0b9..a1992d6b 100644 --- a/app/Support/OpsUx/OperationUxPresenter.php +++ b/app/Support/OpsUx/OperationUxPresenter.php @@ -10,6 +10,8 @@ use App\Support\Operations\OperationRunFreshnessState; use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\RedactionIntegrity; +use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; +use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use Filament\Notifications\Notification as FilamentNotification; final class OperationUxPresenter @@ -99,6 +101,7 @@ public static function surfaceGuidance(OperationRun $run): ?string $uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome); $reasonEnvelope = self::reasonEnvelope($run); $reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope); + $operatorExplanationGuidance = self::operatorExplanationGuidance($run); $nextStepLabel = self::firstNextStepLabel($run); $freshnessState = self::freshnessState($run); @@ -107,11 +110,23 @@ public static function surfaceGuidance(OperationRun $run): ?string } if ($freshnessState->isReconciledFailed()) { - return $reasonGuidance ?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.'; + return $operatorExplanationGuidance + ?? $reasonGuidance + ?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.'; } - if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) { - return $reasonGuidance; + if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true)) { + if ($operatorExplanationGuidance !== null) { + return $operatorExplanationGuidance; + } + + if ($reasonGuidance !== null) { + return $reasonGuidance; + } + } + + if ($uxStatus === 'succeeded' && $operatorExplanationGuidance !== null) { + return $operatorExplanationGuidance; } return match ($uxStatus) { @@ -134,6 +149,19 @@ public static function surfaceGuidance(OperationRun $run): ?string public static function surfaceFailureDetail(OperationRun $run): ?string { + $operatorExplanation = self::governanceOperatorExplanation($run); + + if (is_string($operatorExplanation?->dominantCauseExplanation) && trim($operatorExplanation->dominantCauseExplanation) !== '') { + return trim($operatorExplanation->dominantCauseExplanation); + } + + $failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? ''); + $sanitizedFailureMessage = self::sanitizeFailureMessage($failureMessage); + + if ($sanitizedFailureMessage !== null) { + return $sanitizedFailureMessage; + } + $reasonEnvelope = self::reasonEnvelope($run); if ($reasonEnvelope !== null) { @@ -144,9 +172,7 @@ public static function surfaceFailureDetail(OperationRun $run): ?string return 'This run is no longer within its normal lifecycle window and may no longer be progressing.'; } - $failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? ''); - - return self::sanitizeFailureMessage($failureMessage); + return null; } public static function freshnessState(OperationRun $run): OperationRunFreshnessState @@ -260,4 +286,32 @@ private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonT { return app(ReasonPresenter::class)->forOperationRun($run, 'notification'); } + + private static function operatorExplanationGuidance(OperationRun $run): ?string + { + $operatorExplanation = self::governanceOperatorExplanation($run); + + if (! is_string($operatorExplanation?->nextActionText) || trim($operatorExplanation->nextActionText) === '') { + return null; + } + + $text = trim($operatorExplanation->nextActionText); + + if (str_ends_with($text, '.')) { + return $text; + } + + return $text === 'No action needed' + ? 'No action needed.' + : 'Next step: '.$text.'.'; + } + + private static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern + { + if (! $run->supportsOperatorExplanation()) { + return null; + } + + return app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation; + } } diff --git a/app/Support/ReasonTranslation/FallbackReasonTranslator.php b/app/Support/ReasonTranslation/FallbackReasonTranslator.php index 05776be2..13cc83fc 100644 --- a/app/Support/ReasonTranslation/FallbackReasonTranslator.php +++ b/app/Support/ReasonTranslation/FallbackReasonTranslator.php @@ -5,6 +5,7 @@ namespace App\Support\ReasonTranslation; use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode; +use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use Illuminate\Support\Str; final class FallbackReasonTranslator implements TranslatesReasonCode @@ -43,6 +44,8 @@ public function translate(string $reasonCode, string $surface = 'detail', array nextSteps: $nextSteps, showNoActionNeeded: $actionability === 'non_actionable', diagnosticCodeLabel: $normalizedCode, + trustImpact: $this->trustImpactFor($actionability), + absencePattern: $this->absencePatternFor($normalizedCode, $actionability), ); } @@ -109,4 +112,36 @@ private function fallbackNextStepsFor(string $actionability): array default => [NextStepOption::instruction('Review access and configuration before retrying.')], }; } + + private function trustImpactFor(string $actionability): string + { + return match ($actionability) { + 'non_actionable' => TrustworthinessLevel::Trustworthy->value, + 'retryable_transient' => TrustworthinessLevel::LimitedConfidence->value, + default => TrustworthinessLevel::Unusable->value, + }; + } + + private function absencePatternFor(string $reasonCode, string $actionability): ?string + { + $normalizedCode = strtolower($reasonCode); + + if (str_contains($normalizedCode, 'suppressed')) { + return 'suppressed_output'; + } + + if (str_contains($normalizedCode, 'missing') || str_contains($normalizedCode, 'stale')) { + return 'missing_input'; + } + + if ($actionability === 'prerequisite_missing') { + return 'blocked_prerequisite'; + } + + if ($actionability === 'non_actionable') { + return 'true_no_result'; + } + + return 'unavailable'; + } } diff --git a/app/Support/ReasonTranslation/ReasonPresenter.php b/app/Support/ReasonTranslation/ReasonPresenter.php index c124b6f6..a897d74b 100644 --- a/app/Support/ReasonTranslation/ReasonPresenter.php +++ b/app/Support/ReasonTranslation/ReasonPresenter.php @@ -25,14 +25,16 @@ public function __construct( public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope { $context = is_array($run->context) ? $run->context : []; - $storedTranslation = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null; + $storedTranslation = $this->storedOperationRunTranslation($context); if ($storedTranslation !== null) { $storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation); if ($storedEnvelope instanceof ReasonResolutionEnvelope) { - if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) { - return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps'])); + $nextSteps = $this->operationRunNextSteps($context); + + if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) { + return $storedEnvelope->withNextSteps($nextSteps); } return $storedEnvelope; @@ -40,7 +42,8 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'): } $contextReasonCode = data_get($context, 'execution_legitimacy.reason_code') - ?? data_get($context, 'reason_code'); + ?? data_get($context, 'reason_code') + ?? data_get($context, 'baseline_compare.reason_code'); if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') { return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context); @@ -68,11 +71,33 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'): return $envelope; } - $legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : []; + $legacyNextSteps = $this->operationRunNextSteps($context); return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope; } + /** + * @param array $context + * @return array|null + */ + private function storedOperationRunTranslation(array $context): ?array + { + $storedTranslation = $context['reason_translation'] ?? data_get($context, 'baseline_compare.reason_translation'); + + return is_array($storedTranslation) ? $storedTranslation : null; + } + + /** + * @param array $context + * @return array + */ + private function operationRunNextSteps(array $context): array + { + $nextSteps = $context['next_steps'] ?? data_get($context, 'baseline_compare.next_steps'); + + return is_array($nextSteps) ? NextStepOption::collect($nextSteps) : []; + } + /** * @param array $context */ @@ -169,6 +194,26 @@ public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string return $envelope?->shortExplanation; } + public function dominantCauseLabel(?ReasonResolutionEnvelope $envelope): ?string + { + return $envelope?->operatorLabel; + } + + public function dominantCauseExplanation(?ReasonResolutionEnvelope $envelope): ?string + { + return $envelope?->shortExplanation; + } + + public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string + { + return $envelope?->trustImpact; + } + + public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string + { + return $envelope?->absencePattern; + } + public function guidance(?ReasonResolutionEnvelope $envelope): ?string { return $envelope?->guidanceText(); diff --git a/app/Support/ReasonTranslation/ReasonResolutionEnvelope.php b/app/Support/ReasonTranslation/ReasonResolutionEnvelope.php index 65fab04f..edd36600 100644 --- a/app/Support/ReasonTranslation/ReasonResolutionEnvelope.php +++ b/app/Support/ReasonTranslation/ReasonResolutionEnvelope.php @@ -4,6 +4,7 @@ namespace App\Support\ReasonTranslation; +use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use InvalidArgumentException; final readonly class ReasonResolutionEnvelope @@ -19,6 +20,8 @@ public function __construct( public array $nextSteps = [], public bool $showNoActionNeeded = false, public ?string $diagnosticCodeLabel = null, + public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value, + public ?string $absencePattern = null, ) { if (trim($this->internalCode) === '') { throw new InvalidArgumentException('Reason envelopes must preserve an internal code.'); @@ -41,6 +44,24 @@ public function __construct( throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability); } + if (! in_array($this->trustImpact, array_map( + static fn (TrustworthinessLevel $level): string => $level->value, + TrustworthinessLevel::cases(), + ), true)) { + throw new InvalidArgumentException('Unsupported reason trust impact: '.$this->trustImpact); + } + + if ($this->absencePattern !== null && ! in_array($this->absencePattern, [ + 'none', + 'true_no_result', + 'missing_input', + 'blocked_prerequisite', + 'suppressed_output', + 'unavailable', + ], true)) { + throw new InvalidArgumentException('Unsupported reason absence pattern: '.$this->absencePattern); + } + foreach ($this->nextSteps as $nextStep) { if (! $nextStep instanceof NextStepOption) { throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.'); @@ -70,6 +91,12 @@ public static function fromArray(array $data): ?self $diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null) ? trim((string) $data['diagnostic_code_label']) : (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null); + $trustImpact = is_string($data['trust_impact'] ?? null) + ? trim((string) $data['trust_impact']) + : (is_string($data['trustImpact'] ?? null) ? trim((string) $data['trustImpact']) : TrustworthinessLevel::LimitedConfidence->value); + $absencePattern = is_string($data['absence_pattern'] ?? null) + ? trim((string) $data['absence_pattern']) + : (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null); if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') { return null; @@ -83,6 +110,8 @@ public static function fromArray(array $data): ?self nextSteps: $nextSteps, showNoActionNeeded: $showNoActionNeeded, diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null, + trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value, + absencePattern: $absencePattern !== '' ? $absencePattern : null, ); } @@ -99,6 +128,8 @@ public function withNextSteps(array $nextSteps): self nextSteps: $nextSteps, showNoActionNeeded: $this->showNoActionNeeded, diagnosticCodeLabel: $this->diagnosticCodeLabel, + trustImpact: $this->trustImpact, + absencePattern: $this->absencePattern, ); } @@ -179,6 +210,8 @@ public function toLegacyNextSteps(): array * }>, * show_no_action_needed: bool, * diagnostic_code_label: string + * trust_impact: string, + * absence_pattern: ?string * } */ public function toArray(): array @@ -194,6 +227,8 @@ public function toArray(): array ), 'show_no_action_needed' => $this->showNoActionNeeded, 'diagnostic_code_label' => $this->diagnosticCode(), + 'trust_impact' => $this->trustImpact, + 'absence_pattern' => $this->absencePattern, ]; } } diff --git a/app/Support/ReasonTranslation/ReasonTranslator.php b/app/Support/ReasonTranslation/ReasonTranslator.php index 49826996..a815a3c6 100644 --- a/app/Support/ReasonTranslation/ReasonTranslator.php +++ b/app/Support/ReasonTranslation/ReasonTranslator.php @@ -4,6 +4,7 @@ namespace App\Support\ReasonTranslation; +use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineReasonCodes; use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\LifecycleReconciliationReason; @@ -11,6 +12,7 @@ use App\Support\Providers\ProviderReasonTranslator; use App\Support\RbacReason; use App\Support\Tenants\TenantOperabilityReasonCode; +use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; final class ReasonTranslator { @@ -45,6 +47,8 @@ public function translate( return match (true) { $artifactKey === ProviderReasonTranslator::ARTIFACT_KEY, $artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), + $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), + $artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode), $artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode), $artifactKey === self::EXECUTION_DENIAL_ARTIFACT, @@ -195,6 +199,68 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn NextStepOption::instruction($nextStep), ], diagnosticCodeLabel: $reasonCode, + trustImpact: BaselineReasonCodes::trustImpact($reasonCode) ?? TrustworthinessLevel::Unusable->value, + absencePattern: BaselineReasonCodes::absencePattern($reasonCode), + ); + } + + private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope + { + $enum = BaselineCompareReasonCode::tryFrom($reasonCode); + + if (! $enum instanceof BaselineCompareReasonCode) { + return $this->fallbackReasonTranslator->translate($reasonCode) ?? new ReasonResolutionEnvelope( + internalCode: $reasonCode, + operatorLabel: 'Baseline compare needs review', + shortExplanation: 'TenantPilot recorded a baseline-compare state that needs operator review.', + actionability: 'permanent_configuration', + ); + } + + [$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) { + BaselineCompareReasonCode::NoDriftDetected => [ + 'No drift detected', + 'The comparison completed for the in-scope subjects without recording drift findings.', + 'non_actionable', + 'No action needed unless you expected findings.', + ], + BaselineCompareReasonCode::CoverageUnproven => [ + 'Coverage proof missing', + 'The comparison finished, but missing coverage proof means some findings may have been suppressed for safety.', + 'prerequisite_missing', + 'Run inventory sync and compare again before treating this as complete.', + ], + BaselineCompareReasonCode::EvidenceCaptureIncomplete => [ + 'Evidence capture incomplete', + 'The comparison finished, but incomplete evidence capture limits how much confidence you should place in the visible result.', + 'prerequisite_missing', + 'Resume or rerun evidence capture before relying on this compare result.', + ], + BaselineCompareReasonCode::RolloutDisabled => [ + 'Compare rollout disabled', + 'The comparison path was limited by rollout configuration, so the result is not decision-grade.', + 'prerequisite_missing', + 'Enable the rollout or use the supported compare mode before retrying.', + ], + BaselineCompareReasonCode::NoSubjectsInScope => [ + 'Nothing was eligible to compare', + 'No in-scope subjects were available for evaluation, so the compare could not produce a normal result.', + 'prerequisite_missing', + 'Review scope selection and baseline inputs before comparing again.', + ], + }; + + return new ReasonResolutionEnvelope( + internalCode: $reasonCode, + operatorLabel: $operatorLabel, + shortExplanation: $shortExplanation, + actionability: $actionability, + nextSteps: [ + NextStepOption::instruction($nextStep), + ], + diagnosticCodeLabel: $reasonCode, + trustImpact: $enum->trustworthinessLevel()->value, + absencePattern: $enum->absencePattern(), ); } } diff --git a/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php b/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php index b229ace1..b487cefa 100644 --- a/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php +++ b/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php @@ -6,6 +6,7 @@ use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\ReasonResolutionEnvelope; +use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; final readonly class ArtifactTruthCause { @@ -18,6 +19,8 @@ public function __construct( public ?string $operatorLabel, public ?string $shortExplanation, public ?string $diagnosticCode, + public string $trustImpact, + public ?string $absencePattern, public array $nextSteps = [], ) {} @@ -35,6 +38,8 @@ public static function fromReasonResolutionEnvelope( operatorLabel: $reason->operatorLabel, shortExplanation: $reason->shortExplanation, diagnosticCode: $reason->diagnosticCode(), + trustImpact: $reason->trustImpact, + absencePattern: $reason->absencePattern, nextSteps: array_values(array_map( static fn (NextStepOption $nextStep): string => $nextStep->label, $reason->nextSteps, @@ -42,6 +47,23 @@ public static function fromReasonResolutionEnvelope( ); } + public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope + { + return new ReasonResolutionEnvelope( + internalCode: $this->reasonCode ?? 'artifact_truth_reason', + operatorLabel: $this->operatorLabel ?? 'Operator attention required', + shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.', + actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing', + nextSteps: array_map( + static fn (string $label): NextStepOption => NextStepOption::instruction($label), + $this->nextSteps, + ), + diagnosticCodeLabel: $this->diagnosticCode, + trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value, + absencePattern: $this->absencePattern, + ); + } + /** * @return array{ * reasonCode: ?string, @@ -49,6 +71,8 @@ public static function fromReasonResolutionEnvelope( * operatorLabel: ?string, * shortExplanation: ?string, * diagnosticCode: ?string, + * trustImpact: string, + * absencePattern: ?string, * nextSteps: array * } */ @@ -60,6 +84,8 @@ public function toArray(): array 'operatorLabel' => $this->operatorLabel, 'shortExplanation' => $this->shortExplanation, 'diagnosticCode' => $this->diagnosticCode, + 'trustImpact' => $this->trustImpact, + 'absencePattern' => $this->absencePattern, 'nextSteps' => $this->nextSteps, ]; } diff --git a/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php b/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php index 9fed1a7e..e301a4d0 100644 --- a/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php +++ b/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php @@ -5,6 +5,7 @@ namespace App\Support\Ui\GovernanceArtifactTruth; use App\Support\Badges\BadgeSpec; +use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; final readonly class ArtifactTruthEnvelope { @@ -32,6 +33,7 @@ public function __construct( public ?string $relatedArtifactUrl, public array $dimensions = [], public ?ArtifactTruthCause $reason = null, + public ?OperatorExplanationPattern $operatorExplanation = null, ) {} public function primaryDimension(): ?ArtifactTruthDimension @@ -99,8 +101,11 @@ public function nextStepText(): string * operatorLabel: ?string, * shortExplanation: ?string, * diagnosticCode: ?string, + * trustImpact: string, + * absencePattern: ?string, * nextSteps: array - * } + * }, + * operatorExplanation: ?array * } */ public function toArray(): array @@ -132,6 +137,7 @@ public function toArray(): array ), )), 'reason' => $this->reason?->toArray(), + 'operatorExplanation' => $this->operatorExplanation?->toArray(), ]; } } diff --git a/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php b/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php index a9fc0e02..808c0af6 100644 --- a/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php +++ b/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php @@ -21,11 +21,14 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; +use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReviewPackStatus; use App\Support\TenantReviewCompletenessState; use App\Support\TenantReviewStatus; +use App\Support\Ui\OperatorExplanation\CountDescriptor; +use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder; use Illuminate\Support\Arr; final class ArtifactTruthPresenter @@ -33,6 +36,7 @@ final class ArtifactTruthPresenter public function __construct( private readonly ReasonPresenter $reasonPresenter, private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver, + private readonly OperatorExplanationBuilder $operatorExplanationBuilder, ) {} public function for(mixed $record): ?ArtifactTruthEnvelope @@ -164,6 +168,19 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn relatedRunId: null, relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'), includePublicationDimension: false, + countDescriptors: [ + new CountDescriptor( + label: 'Captured items', + value: (int) ($summary['total_items'] ?? 0), + role: CountDescriptor::ROLE_EVALUATION_OUTPUT, + ), + new CountDescriptor( + label: 'Evidence gaps', + value: (int) (Arr::get($summary, 'gaps.count', 0)), + role: CountDescriptor::ROLE_COVERAGE, + qualifier: (int) (Arr::get($summary, 'gaps.count', 0)) > 0 ? 'review needed' : null, + ), + ], ); } @@ -287,6 +304,25 @@ public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEn ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) : null, includePublicationDimension: false, + countDescriptors: [ + new CountDescriptor( + label: 'Evidence dimensions', + value: (int) ($summary['dimension_count'] ?? 0), + role: CountDescriptor::ROLE_EVALUATION_OUTPUT, + ), + new CountDescriptor( + label: 'Missing dimensions', + value: $missingDimensions, + role: CountDescriptor::ROLE_COVERAGE, + qualifier: $missingDimensions > 0 ? 'partial' : null, + ), + new CountDescriptor( + label: 'Stale dimensions', + value: $staleDimensions, + role: CountDescriptor::ROLE_RELIABILITY_SIGNAL, + qualifier: $staleDimensions > 0 ? 'refresh recommended' : null, + ), + ], ); } @@ -416,6 +452,24 @@ public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope ? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant) : null, includePublicationDimension: true, + countDescriptors: [ + new CountDescriptor( + label: 'Findings', + value: (int) ($summary['finding_count'] ?? 0), + role: CountDescriptor::ROLE_EVALUATION_OUTPUT, + ), + new CountDescriptor( + label: 'Sections', + value: (int) ($summary['section_count'] ?? 0), + role: CountDescriptor::ROLE_EXECUTION, + ), + new CountDescriptor( + label: 'Publish blockers', + value: count($publishBlockers), + role: CountDescriptor::ROLE_RELIABILITY_SIGNAL, + qualifier: $publishBlockers !== [] ? 'resolve before publish' : null, + ), + ], ); } @@ -536,6 +590,24 @@ public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope ? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant) : null, includePublicationDimension: true, + countDescriptors: [ + new CountDescriptor( + label: 'Findings', + value: (int) ($summary['finding_count'] ?? 0), + role: CountDescriptor::ROLE_EVALUATION_OUTPUT, + ), + new CountDescriptor( + label: 'Reports', + value: (int) ($summary['report_count'] ?? 0), + role: CountDescriptor::ROLE_EXECUTION, + ), + new CountDescriptor( + label: 'Operations', + value: (int) ($summary['operation_count'] ?? 0), + role: CountDescriptor::ROLE_EXECUTION, + visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC, + ), + ], ); } @@ -577,6 +649,10 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope relatedRunId: (int) $run->getKey(), relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl, includePublicationDimension: $artifactEnvelope->publicationReadiness !== null, + countDescriptors: array_merge( + $artifactEnvelope->operatorExplanation?->countDescriptors ?? [], + $this->runCountDescriptors($run), + ), ); } } @@ -618,18 +694,16 @@ public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope }, diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label, reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT), - nextActionLabel: $this->nextActionLabel( - $actionability, - $reason, - $actionability === 'required' + nextActionLabel: $reason?->firstNextStep()?->label + ?? ($actionability === 'required' ? 'Inspect the blocked run details before retrying' - : 'Wait for the artifact-producing run to finish', - ), + : 'Wait for the artifact-producing run to finish'), nextActionUrl: null, relatedRunId: (int) $run->getKey(), relatedArtifactUrl: null, includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack' || OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review', + countDescriptors: $this->runCountDescriptors($run), ); } @@ -715,6 +789,7 @@ private function makeEnvelope( ?int $relatedRunId, ?string $relatedArtifactUrl, bool $includePublicationDimension, + array $countDescriptors = [], ): ArtifactTruthEnvelope { $primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState); $dimensions = [ @@ -748,7 +823,7 @@ classification: 'diagnostic', ); } - return new ArtifactTruthEnvelope( + $draftEnvelope = new ArtifactTruthEnvelope( artifactFamily: $artifactFamily, artifactKey: $artifactKey, workspaceId: $workspaceId, @@ -770,6 +845,30 @@ classification: 'diagnostic', dimensions: array_values($dimensions), reason: $reason, ); + + return new ArtifactTruthEnvelope( + artifactFamily: $draftEnvelope->artifactFamily, + artifactKey: $draftEnvelope->artifactKey, + workspaceId: $draftEnvelope->workspaceId, + tenantId: $draftEnvelope->tenantId, + executionOutcome: $draftEnvelope->executionOutcome, + artifactExistence: $draftEnvelope->artifactExistence, + contentState: $draftEnvelope->contentState, + freshnessState: $draftEnvelope->freshnessState, + publicationReadiness: $draftEnvelope->publicationReadiness, + supportState: $draftEnvelope->supportState, + actionability: $draftEnvelope->actionability, + primaryLabel: $draftEnvelope->primaryLabel, + primaryExplanation: $draftEnvelope->primaryExplanation, + diagnosticLabel: $draftEnvelope->diagnosticLabel, + nextActionLabel: $draftEnvelope->nextActionLabel, + nextActionUrl: $draftEnvelope->nextActionUrl, + relatedRunId: $draftEnvelope->relatedRunId, + relatedArtifactUrl: $draftEnvelope->relatedArtifactUrl, + dimensions: $draftEnvelope->dimensions, + reason: $draftEnvelope->reason, + operatorExplanation: $this->operatorExplanationBuilder->fromArtifactTruthEnvelope($draftEnvelope, $countDescriptors), + ); } private function dimension( @@ -787,4 +886,31 @@ classification: $classification, badgeState: $state, ); } + + /** + * @return array + */ + private function runCountDescriptors(OperationRun $run): array + { + $descriptors = []; + + foreach (SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []) as $key => $value) { + $role = match (true) { + in_array($key, ['total', 'processed'], true) => CountDescriptor::ROLE_EXECUTION, + str_contains($key, 'failed') || str_contains($key, 'warning') || str_contains($key, 'blocked') => CountDescriptor::ROLE_RELIABILITY_SIGNAL, + default => CountDescriptor::ROLE_EVALUATION_OUTPUT, + }; + + $descriptors[] = new CountDescriptor( + label: SummaryCountsNormalizer::label($key), + value: (int) $value, + role: $role, + visibilityTier: in_array($key, ['total', 'processed'], true) + ? CountDescriptor::VISIBILITY_PRIMARY + : CountDescriptor::VISIBILITY_DIAGNOSTIC, + ); + } + + return $descriptors; + } } diff --git a/app/Support/Ui/OperatorExplanation/CountDescriptor.php b/app/Support/Ui/OperatorExplanation/CountDescriptor.php new file mode 100644 index 00000000..ade38176 --- /dev/null +++ b/app/Support/Ui/OperatorExplanation/CountDescriptor.php @@ -0,0 +1,67 @@ +label) === '') { + throw new InvalidArgumentException('Count descriptors require a label.'); + } + + if (! in_array($this->role, [ + self::ROLE_EXECUTION, + self::ROLE_EVALUATION_OUTPUT, + self::ROLE_COVERAGE, + self::ROLE_RELIABILITY_SIGNAL, + ], true)) { + throw new InvalidArgumentException('Unsupported count descriptor role: '.$this->role); + } + + if (! in_array($this->visibilityTier, [self::VISIBILITY_PRIMARY, self::VISIBILITY_DIAGNOSTIC], true)) { + throw new InvalidArgumentException('Unsupported count descriptor visibility tier: '.$this->visibilityTier); + } + } + + /** + * @return array{ + * label: string, + * value: int, + * role: string, + * qualifier: ?string, + * visibilityTier: string + * } + */ + public function toArray(): array + { + return [ + 'label' => $this->label, + 'value' => $this->value, + 'role' => $this->role, + 'qualifier' => $this->qualifier, + 'visibilityTier' => $this->visibilityTier, + ]; + } +} diff --git a/app/Support/Ui/OperatorExplanation/ExplanationFamily.php b/app/Support/Ui/OperatorExplanation/ExplanationFamily.php new file mode 100644 index 00000000..4901d006 --- /dev/null +++ b/app/Support/Ui/OperatorExplanation/ExplanationFamily.php @@ -0,0 +1,17 @@ + $countDescriptors + */ + public function build( + ExplanationFamily $family, + string $headline, + string $executionOutcome, + string $executionOutcomeLabel, + string $evaluationResult, + TrustworthinessLevel $trustworthinessLevel, + string $reliabilityStatement, + ?string $coverageStatement, + ?string $dominantCauseCode, + ?string $dominantCauseLabel, + ?string $dominantCauseExplanation, + string $nextActionCategory, + string $nextActionText, + array $countDescriptors = [], + bool $diagnosticsAvailable = false, + ?string $diagnosticsSummary = null, + ): OperatorExplanationPattern { + return new OperatorExplanationPattern( + family: $family, + headline: $headline, + executionOutcome: $executionOutcome, + executionOutcomeLabel: $executionOutcomeLabel, + evaluationResult: $evaluationResult, + trustworthinessLevel: $trustworthinessLevel, + reliabilityStatement: $reliabilityStatement, + coverageStatement: $coverageStatement, + dominantCauseCode: $dominantCauseCode, + dominantCauseLabel: $dominantCauseLabel, + dominantCauseExplanation: $dominantCauseExplanation, + nextActionCategory: $nextActionCategory, + nextActionText: $nextActionText, + countDescriptors: $countDescriptors, + diagnosticsAvailable: $diagnosticsAvailable, + diagnosticsSummary: $diagnosticsSummary, + ); + } + + /** + * @param array $countDescriptors + */ + public function fromArtifactTruthEnvelope( + ArtifactTruthEnvelope $truth, + array $countDescriptors = [], + ): OperatorExplanationPattern { + $reason = $truth->reason?->toReasonResolutionEnvelope(); + $family = $this->familyForTruth($truth, $reason); + $trustworthiness = $this->trustworthinessForTruth($truth, $reason); + $evaluationResult = $this->evaluationResultForTruth($truth, $family); + $executionOutcome = $this->executionOutcomeKey($truth->executionOutcome); + $executionOutcomeLabel = $this->executionOutcomeLabel($truth->executionOutcome); + $dominantCauseCode = $reason?->internalCode; + $dominantCauseLabel = $reason?->operatorLabel ?? $truth->primaryLabel; + $dominantCauseExplanation = $reason?->shortExplanation ?? $truth->primaryExplanation; + $headline = $this->headlineForTruth($truth, $family, $trustworthiness); + $reliabilityStatement = $this->reliabilityStatementForTruth($truth, $trustworthiness); + $coverageStatement = $this->coverageStatementForTruth($truth, $reason); + $nextActionText = $truth->nextStepText(); + $nextActionCategory = $this->nextActionCategory($truth->actionability, $reason); + $diagnosticsAvailable = $truth->reason !== null + || $truth->diagnosticLabel !== null + || $countDescriptors !== []; + + return $this->build( + family: $family, + headline: $headline, + executionOutcome: $executionOutcome, + executionOutcomeLabel: $executionOutcomeLabel, + evaluationResult: $evaluationResult, + trustworthinessLevel: $trustworthiness, + reliabilityStatement: $reliabilityStatement, + coverageStatement: $coverageStatement, + dominantCauseCode: $dominantCauseCode, + dominantCauseLabel: $dominantCauseLabel, + dominantCauseExplanation: $dominantCauseExplanation, + nextActionCategory: $nextActionCategory, + nextActionText: $nextActionText, + countDescriptors: $countDescriptors, + diagnosticsAvailable: $diagnosticsAvailable, + diagnosticsSummary: $diagnosticsAvailable + ? 'Technical truth detail remains available below the primary explanation.' + : null, + ); + } + + private function familyForTruth( + ArtifactTruthEnvelope $truth, + ?ReasonResolutionEnvelope $reason, + ): ExplanationFamily { + return match (true) { + $reason?->absencePattern === 'suppressed_output' => ExplanationFamily::SuppressedOutput, + $reason?->absencePattern === 'blocked_prerequisite' => ExplanationFamily::BlockedPrerequisite, + $truth->executionOutcome === 'pending' || $truth->artifactExistence === 'not_created' && $truth->actionability !== 'required' => ExplanationFamily::InProgress, + $truth->executionOutcome === 'failed' || $truth->executionOutcome === 'blocked' => ExplanationFamily::BlockedPrerequisite, + $truth->artifactExistence === 'created_but_not_usable' || $truth->contentState === 'missing_input' => ExplanationFamily::MissingInput, + $truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' && $truth->primaryLabel === 'Trustworthy artifact' => ExplanationFamily::TrustworthyResult, + $truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => ExplanationFamily::NoIssuesDetected, + $truth->artifactExistence === 'historical_only' => ExplanationFamily::Unavailable, + default => ExplanationFamily::CompletedButLimited, + }; + } + + private function trustworthinessForTruth( + ArtifactTruthEnvelope $truth, + ?ReasonResolutionEnvelope $reason, + ): TrustworthinessLevel { + if ($reason?->trustImpact !== null) { + return TrustworthinessLevel::tryFrom($reason->trustImpact) ?? TrustworthinessLevel::LimitedConfidence; + } + + return match (true) { + $truth->artifactExistence === 'created_but_not_usable', + $truth->contentState === 'missing_input', + $truth->executionOutcome === 'failed', + $truth->executionOutcome === 'blocked' => TrustworthinessLevel::Unusable, + $truth->supportState === 'limited_support', + in_array($truth->contentState, ['reference_only', 'unsupported'], true) => TrustworthinessLevel::DiagnosticOnly, + $truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => TrustworthinessLevel::Trustworthy, + default => TrustworthinessLevel::LimitedConfidence, + }; + } + + private function evaluationResultForTruth( + ArtifactTruthEnvelope $truth, + ExplanationFamily $family, + ): string { + return match ($family) { + ExplanationFamily::TrustworthyResult => 'full_result', + ExplanationFamily::NoIssuesDetected => 'no_result', + ExplanationFamily::SuppressedOutput => 'suppressed_result', + ExplanationFamily::MissingInput, + ExplanationFamily::BlockedPrerequisite, + ExplanationFamily::Unavailable => 'unavailable', + ExplanationFamily::InProgress => 'unavailable', + ExplanationFamily::CompletedButLimited => 'incomplete_result', + }; + } + + private function executionOutcomeKey(?string $executionOutcome): string + { + $normalized = BadgeCatalog::normalizeState($executionOutcome); + + return match ($normalized) { + 'queued', 'running', 'pending' => 'in_progress', + 'partially_succeeded' => 'completed_with_follow_up', + 'blocked' => 'blocked', + 'failed' => 'failed', + default => 'completed', + }; + } + + private function executionOutcomeLabel(?string $executionOutcome): string + { + if (! is_string($executionOutcome) || trim($executionOutcome) === '') { + return 'Completed'; + } + + $spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $executionOutcome); + + return $spec->label !== 'Unknown' ? $spec->label : ucfirst(str_replace('_', ' ', trim($executionOutcome))); + } + + private function headlineForTruth( + ArtifactTruthEnvelope $truth, + ExplanationFamily $family, + TrustworthinessLevel $trustworthiness, + ): string { + return match ($family) { + ExplanationFamily::TrustworthyResult => 'The result is ready to use.', + ExplanationFamily::NoIssuesDetected => 'No follow-up was detected from this result.', + ExplanationFamily::SuppressedOutput => 'The run completed, but normal output was intentionally suppressed.', + ExplanationFamily::MissingInput => 'The result exists, but missing inputs keep it from being decision-grade.', + ExplanationFamily::BlockedPrerequisite => 'The workflow did not produce a usable result because a prerequisite blocked it.', + ExplanationFamily::InProgress => 'The result is still being prepared.', + ExplanationFamily::Unavailable => 'A result is not currently available for this surface.', + ExplanationFamily::CompletedButLimited => match ($trustworthiness) { + TrustworthinessLevel::DiagnosticOnly => 'The result is available for diagnostics, not for a final decision.', + TrustworthinessLevel::LimitedConfidence => 'The result is available, but it should be read with caution.', + TrustworthinessLevel::Unusable => 'The result is not reliable enough to use as-is.', + default => 'The result completed with operator follow-up.', + }, + }; + } + + private function reliabilityStatementForTruth( + ArtifactTruthEnvelope $truth, + TrustworthinessLevel $trustworthiness, + ): string { + return match ($trustworthiness) { + TrustworthinessLevel::Trustworthy => 'Trustworthiness is high for the intended operator task.', + TrustworthinessLevel::LimitedConfidence => $truth->primaryExplanation + ?? 'Trustworthiness is limited because coverage, freshness, or publication readiness still need review.', + TrustworthinessLevel::DiagnosticOnly => 'This output is suitable for diagnostics only and should not be treated as the final answer.', + TrustworthinessLevel::Unusable => 'This output is not reliable enough to support the intended operator action yet.', + }; + } + + private function coverageStatementForTruth( + ArtifactTruthEnvelope $truth, + ?ReasonResolutionEnvelope $reason, + ): ?string { + return match (true) { + $truth->contentState === 'trusted' && $truth->freshnessState === 'current' => 'Coverage and artifact quality are sufficient for the default reading path.', + $truth->freshnessState === 'stale' => 'The artifact exists, but freshness limits how confidently it should be used.', + $truth->contentState === 'partial' => 'Coverage is incomplete, so the visible output should be treated as partial.', + $truth->contentState === 'missing_input' => $reason?->shortExplanation ?? 'Required inputs were missing or unusable when this result was assembled.', + in_array($truth->contentState, ['reference_only', 'unsupported'], true) => 'Only reduced-fidelity support is available for this result.', + $truth->publicationReadiness === 'blocked' => 'The artifact exists, but it is still blocked from the intended downstream use.', + default => null, + }; + } + + private function nextActionCategory( + string $actionability, + ?ReasonResolutionEnvelope $reason, + ): string { + if ($reason?->actionability === 'retryable_transient') { + return 'retry_later'; + } + + return match ($actionability) { + 'none' => 'none', + 'optional' => 'review_evidence_gaps', + default => $reason?->actionability === 'prerequisite_missing' + ? 'fix_prerequisite' + : 'manual_validate', + }; + } +} diff --git a/app/Support/Ui/OperatorExplanation/OperatorExplanationPattern.php b/app/Support/Ui/OperatorExplanation/OperatorExplanationPattern.php new file mode 100644 index 00000000..e538e505 --- /dev/null +++ b/app/Support/Ui/OperatorExplanation/OperatorExplanationPattern.php @@ -0,0 +1,135 @@ + $countDescriptors + */ + public function __construct( + public ExplanationFamily $family, + public string $headline, + public string $executionOutcome, + public string $executionOutcomeLabel, + public string $evaluationResult, + public TrustworthinessLevel $trustworthinessLevel, + public string $reliabilityStatement, + public ?string $coverageStatement, + public ?string $dominantCauseCode, + public ?string $dominantCauseLabel, + public ?string $dominantCauseExplanation, + public string $nextActionCategory, + public string $nextActionText, + public array $countDescriptors = [], + public bool $diagnosticsAvailable = false, + public ?string $diagnosticsSummary = null, + ) { + if (trim($this->headline) === '') { + throw new InvalidArgumentException('Operator explanation patterns require a headline.'); + } + + if (trim($this->executionOutcome) === '' || trim($this->executionOutcomeLabel) === '') { + throw new InvalidArgumentException('Operator explanation patterns require an execution outcome and label.'); + } + + if (trim($this->evaluationResult) === '') { + throw new InvalidArgumentException('Operator explanation patterns require an evaluation result state.'); + } + + if (trim($this->reliabilityStatement) === '') { + throw new InvalidArgumentException('Operator explanation patterns require a reliability statement.'); + } + + if (trim($this->nextActionCategory) === '' || trim($this->nextActionText) === '') { + throw new InvalidArgumentException('Operator explanation patterns require a next action category and text.'); + } + + foreach ($this->countDescriptors as $descriptor) { + if (! $descriptor instanceof CountDescriptor) { + throw new InvalidArgumentException('Operator explanation count descriptors must contain CountDescriptor instances.'); + } + } + } + + public function evaluationResultLabel(): string + { + return BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $this->evaluationResult)->label; + } + + public function trustworthinessLabel(): string + { + return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $this->trustworthinessLevel)->label; + } + + /** + * @return array{ + * family: string, + * headline: string, + * executionOutcome: string, + * executionOutcomeLabel: string, + * evaluationResult: string, + * evaluationResultLabel: string, + * trustworthinessLevel: string, + * reliabilityLevel: string, + * trustworthinessLabel: string, + * reliabilityStatement: string, + * coverageStatement: ?string, + * dominantCause: array{ + * code: ?string, + * label: ?string, + * explanation: ?string + * }, + * nextAction: array{ + * category: string, + * text: string + * }, + * countDescriptors: array, + * diagnosticsAvailable: bool, + * diagnosticsSummary: ?string + * } + */ + public function toArray(): array + { + return [ + 'family' => $this->family->value, + 'headline' => $this->headline, + 'executionOutcome' => $this->executionOutcome, + 'executionOutcomeLabel' => $this->executionOutcomeLabel, + 'evaluationResult' => $this->evaluationResult, + 'evaluationResultLabel' => $this->evaluationResultLabel(), + 'trustworthinessLevel' => $this->trustworthinessLevel->value, + 'reliabilityLevel' => $this->trustworthinessLevel->value, + 'trustworthinessLabel' => $this->trustworthinessLabel(), + 'reliabilityStatement' => $this->reliabilityStatement, + 'coverageStatement' => $this->coverageStatement, + 'dominantCause' => [ + 'code' => $this->dominantCauseCode, + 'label' => $this->dominantCauseLabel, + 'explanation' => $this->dominantCauseExplanation, + ], + 'nextAction' => [ + 'category' => $this->nextActionCategory, + 'text' => $this->nextActionText, + ], + 'countDescriptors' => array_map( + static fn (CountDescriptor $descriptor): array => $descriptor->toArray(), + $this->countDescriptors, + ), + 'diagnosticsAvailable' => $this->diagnosticsAvailable, + 'diagnosticsSummary' => $this->diagnosticsSummary, + ]; + } +} diff --git a/app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php b/app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php new file mode 100644 index 00000000..b80d4252 --- /dev/null +++ b/app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php @@ -0,0 +1,13 @@ + > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` -**Last reviewed**: 2026-03-23 (added Operator Explanation Layer candidate; added governance operator outcome compression follow-up; promoted Spec 158 into ledger) +**Last reviewed**: 2026-03-24 (added Operation Run Active-State Visibility & Stale Escalation candidate) --- @@ -293,6 +293,62 @@ ### Operator Explanation Layer for Degraded / Partial / Suppressed Results > > **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. The Operator Explanation Layer defines the shared interpretation semantics and explanation patterns. Governance operator outcome compression is a UI-information-architecture adoption slice across governance artifact surfaces. Humanized diagnostic summaries are an adoption slice for governance run-detail pages. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language. +### Operation Run Active-State Visibility & Stale Escalation +- **Type**: hardening +- **Source**: product/operator visibility analysis 2026-03-24; operation-run lifecycle and stale-state communication review +- **Vehicle**: new standalone candidate +- **Problem**: TenantPilot already has the core lifecycle foundations for `OperationRun` records: canonical run modelling, workspace-level run viewing, per-type lifecycle policies, freshness and stale detection, overdue-run reconciliation, terminal notifications, and tenant-local active-run hints. The gap is no longer primarily lifecycle logic. The gap is that the same lifecycle truth is not communicated with enough consistency and urgency across the operator surfaces that matter. A run can be past its expected lifecycle or likely stuck while still looking like normal active work on tenant-local cards or dashboard attention surfaces. Operators then have to drill into the canonical run viewer to learn that the run is no longer healthy, which weakens monitoring trust and makes hanging work look deceptively normal. +- **Why it matters**: This is an observability and operator-trust problem in a core platform layer, not visual polish. If `queued` or `running` remains visually neutral after lifecycle expectations have been exceeded, operators receive false reassurance, support burden rises, queue or worker issues are discovered later, and the product trains users that active-state surfaces are not trustworthy without manual drill-down. As TenantPilot pushes more governance, review, drift, and evidence workflows through `OperationRun`, stale active work must never read as healthy progress anywhere in the product. +- **Proposed direction**: + - Reuse the existing lifecycle, freshness, and reconciliation truth to define one **cross-surface active-state presentation contract** that distinguishes at least: `active / normal`, `active / past expected lifecycle`, `stale / likely stuck`, and `terminal / no longer active` + - Upgrade **tenant-local active-run and progress cards** so stale or past-lifecycle runs are visibly and linguistically different from healthy active work instead of reading as neutral `Queued • 1d` or `Running • 45m` + - Upgrade **tenant dashboard and attention surfaces** so they distinguish between healthy activity, activity that needs attention, and activity that is likely stale or hanging + - Upgrade the **workspace operations list / monitoring views** so problematic active runs become scanable at row level instead of being discoverable only through subtle secondary text or by opening each run + - Preserve the **workspace-level canonical run viewer** as the authoritative diagnostic surface, while ensuring compact and summary surfaces do not contradict it + - Apply a **same meaning, different density** rule: tenant cards, dashboard signals, list rows, and run detail may vary in information density, but not in lifecycle meaning or operator implication +- **Core product principles**: + - Execution lifecycle, freshness, and operator attention are related but not identical dimensions + - Compact surfaces may compress information, but must not downplay stale or hanging work + - The workspace-level run viewer remains canonical; this candidate improves visibility, not source-of-truth ownership + - Stale or past-lifecycle work must not look like healthy progress anywhere +- **Candidate requirements**: + - **R1 Cross-surface lifecycle visibility**: all relevant active-run surfaces can distinguish at least normal active, past-lifecycle active, stale/likely stuck, and terminal states + - **R2 Tenant active-run escalation**: tenant-local active-run and progress cards visibly and linguistically escalate stale or past-lifecycle work + - **R3 Dashboard attention separation**: dashboard and attention surfaces distinguish healthy activity from concerning active work + - **R4 Operations-list scanability**: the workspace operations list makes problematic active runs quickly identifiable without requiring row-by-row interpretation or drill-in + - **R5 Canonical viewer preservation**: the workspace-level run viewer remains the detailed and authoritative truth surface + - **R6 No hidden contradiction**: a run that is clearly stale or lifecycle-problematic on the detail page must not appear as ordinary active work on tenant or monitoring surfaces + - **R7 Existing lifecycle logic reuse**: the candidate reuses current freshness, lifecycle, and reconciliation semantics instead of introducing parallel UI-only heuristics + - **R8 No new backend lifecycle semantics unless necessary**: new status values or model-level lifecycle semantics are out unless the current semantics cannot carry the presentation contract cleanly +- **Scope boundaries**: + - **In scope**: tenant-local active-run cards, tenant dashboard activity and attention surfaces, workspace operations list and monitoring surfaces, shared lifecycle presentation contract for active-state visibility, copy and visual semantics needed to distinguish healthy active work from stale active work + - **Out of scope**: retry, cancel, force-fail, or reconcile-now operator actions; queue or worker architecture changes; new scheduler or timeout engines; new notification channels; a full operations-hub redesign; cross-workspace fleet monitoring; introducing new `OperationRun` status values unless existing semantics are proven insufficient +- **Acceptance points**: + - An active run outside its lifecycle expectation is visibly distinct from healthy active work on tenant-local progress cards + - Tenant dashboard and attention surfaces clearly represent the difference between healthy activity and active work that needs attention + - The workspace operations list makes stale or problematic active runs quickly scanable + - No surface shows a run as stale/problematic while another still presents it as normal active work + - The canonical workspace-level run viewer remains the most detailed lifecycle and diagnosis surface + - Existing lifecycle and freshness logic is reused rather than duplicated into local UI-only state rules + - No retry, cancel, or force-fail intervention actions are introduced by this candidate + - Fresh active runs do not regress into false escalation + - Tenant and workspace scoping remain correct; no cross-tenant leakage appears in cards or monitoring views + - Regression coverage includes fresh and stale active runs across tenant and workspace surfaces +- **Suggested test matrix**: + - queued run within expected lifecycle + - queued run well past expected lifecycle + - running run within expected lifecycle + - running run well past expected lifecycle + - run becomes terminal while an operator navigates between tenant and run-detail surfaces + - stale state on detail surface remains semantically stale on tenant and monitoring surfaces + - fresh active runs do not escalate falsely + - tenant-scoped surfaces never show another tenant's runs + - operations list clearly surfaces problematic active runs for fast scan +- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces +- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Operator Explanation Layer for Degraded / Partial / Suppressed Results (adjacent but broader interpretation layer), Provider-Backed Action Preflight and Dispatch Gate Unification (neighboring operational hardening lane) +- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language. +- **Priority**: high + ### Baseline Snapshot Fidelity Semantics - **Type**: hardening - **Source**: semantic clarity & operator-language audit 2026-03-21 diff --git a/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php b/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php index 86ba1ee0..4ee09b4a 100644 --- a/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php +++ b/resources/views/filament/infolists/entries/governance-artifact-truth.blade.php @@ -31,16 +31,36 @@ $actionabilitySpec = $specFor($actionability); $reason = is_array($state['reason'] ?? null) ? $state['reason'] : []; $nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : []; + $operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : []; + $evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null) + ? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult']) + : null; + $trustSpec = is_string($operatorExplanation['trustworthinessLevel'] ?? null) + ? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel']) + : null; + $operatorCounts = collect(is_array($operatorExplanation['countDescriptors'] ?? null) ? $operatorExplanation['countDescriptors'] : []); @endphp
+ @if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') + + {{ $evaluationSpec->label }} + + @endif + + @if ($trustSpec && $trustSpec->label !== 'Unknown') + + {{ $trustSpec->label }} + + @endif + @if ($primarySpec) {{ $primarySpec->label }} - @endif + @endif @if ($actionabilitySpec) @@ -51,15 +71,31 @@
- {{ $state['primaryLabel'] ?? 'Artifact truth' }} + {{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
- @if (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '') + @if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '') +

+ {{ $operatorExplanation['reliabilityStatement'] }} +

+ @elseif (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')

{{ $state['primaryExplanation'] }}

@endif + @if (is_string(data_get($operatorExplanation, 'dominantCause.explanation')) && trim(data_get($operatorExplanation, 'dominantCause.explanation')) !== '') +

+ {{ data_get($operatorExplanation, 'dominantCause.explanation') }} +

+ @endif + + @if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '') +

+ Coverage: {{ $operatorExplanation['coverageStatement'] }} +

+ @endif + @if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')

Diagnostic: {{ $state['diagnosticLabel'] }} @@ -102,14 +138,47 @@

@endif + @if ($trustSpec && $trustSpec->label !== 'Unknown') +
+
Result trust
+
+ + {{ $trustSpec->label }} + +
+
+ @endif +
Next step
- {{ $state['nextActionLabel'] ?? 'No action needed' }} + {{ data_get($operatorExplanation, 'nextAction.text') ?? ($state['nextActionLabel'] ?? 'No action needed') }}
+ @if ($operatorCounts->isNotEmpty()) +
+ @foreach ($operatorCounts as $count) + @continue(! is_array($count)) + +
+
+ {{ $count['label'] ?? 'Count' }} +
+
+ {{ (int) ($count['value'] ?? 0) }} +
+ @if (filled($count['qualifier'] ?? null)) +
+ {{ $count['qualifier'] }} +
+ @endif +
+ @endforeach +
+ @endif + @if ($nextSteps !== [])
Guidance
diff --git a/resources/views/filament/infolists/entries/tenant-review-summary.blade.php b/resources/views/filament/infolists/entries/tenant-review-summary.blade.php index 280847b9..67c7a2e8 100644 --- a/resources/views/filament/infolists/entries/tenant-review-summary.blade.php +++ b/resources/views/filament/infolists/entries/tenant-review-summary.blade.php @@ -6,9 +6,30 @@ $highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : []; $nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : []; $publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : []; + $operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : []; @endphp
+ @if ($operatorExplanation !== []) +
+
+ {{ $operatorExplanation['headline'] ?? 'Review explanation' }} +
+ + @if (filled($operatorExplanation['reliabilityStatement'] ?? null)) +
+ {{ $operatorExplanation['reliabilityStatement'] }} +
+ @endif + + @if (filled(data_get($operatorExplanation, 'nextAction.text'))) +
+ {{ data_get($operatorExplanation, 'nextAction.text') }} +
+ @endif +
+ @endif +
@foreach ($metrics as $metric) @php diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 55ccbd5d..957e48e5 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -6,6 +6,14 @@ @php $duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0); + $explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null; + $explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []); + $evaluationSpec = is_string($explanation['evaluationResult'] ?? null) + ? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult']) + : null; + $trustSpec = is_string($explanation['trustworthinessLevel'] ?? null) + ? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationTrustworthiness, $explanation['trustworthinessLevel']) + : null; @endphp @if ($duplicateNamePoliciesCountValue > 0) @@ -27,6 +35,96 @@
@endif + @if ($explanation !== null) + +
+
+ @if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') + + {{ $evaluationSpec->label }} + + @endif + + @if ($trustSpec && $trustSpec->label !== 'Unknown') + + {{ $trustSpec->label }} + + @endif +
+ +
+
+ {{ $explanation['headline'] ?? 'Compare explanation' }} +
+ + @if (filled($explanation['reliabilityStatement'] ?? null)) +

+ {{ $explanation['reliabilityStatement'] }} +

+ @endif + + @if (filled(data_get($explanation, 'dominantCause.explanation'))) +

+ {{ data_get($explanation, 'dominantCause.explanation') }} +

+ @endif +
+ +
+
+
Execution outcome
+
+ {{ $explanation['executionOutcomeLabel'] ?? 'Completed' }} +
+
+ +
+
Result trust
+
+ {{ $explanation['trustworthinessLabel'] ?? 'Needs review' }} +
+
+ +
+
What to do next
+
+ {{ data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }} +
+
+
+ + @if (filled($explanation['coverageStatement'] ?? null)) +
+ Coverage: + {{ $explanation['coverageStatement'] }} +
+ @endif + + @if ($explanationCounts->isNotEmpty()) +
+ @foreach ($explanationCounts as $count) + @continue(! is_array($count)) + +
+
+ {{ $count['label'] ?? 'Count' }} +
+
+ {{ (int) ($count['value'] ?? 0) }} +
+ @if (filled($count['qualifier'] ?? null)) +
+ {{ $count['qualifier'] }} +
+ @endif +
+ @endforeach +
+ @endif +
+
+ @endif + {{-- Row 1: Stats Overview --}} @if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
diff --git a/resources/views/filament/pages/monitoring/audit-log.blade.php b/resources/views/filament/pages/monitoring/audit-log.blade.php index 66cb4a60..4ad3236f 100644 --- a/resources/views/filament/pages/monitoring/audit-log.blade.php +++ b/resources/views/filament/pages/monitoring/audit-log.blade.php @@ -1,4 +1,7 @@ + @php($selectedAudit = $this->selectedAuditRecord()) + @php($selectedAuditLink = $this->selectedAuditTargetLink()) +
@@ -15,5 +18,14 @@
+ @if ($selectedAudit) + + @include('filament.pages.monitoring.partials.audit-log-inspect-event', [ + 'selectedAudit' => $selectedAudit, + 'selectedAuditLink' => $selectedAuditLink, + ]) + + @endif + {{ $this->table }} diff --git a/resources/views/filament/pages/monitoring/operations.blade.php b/resources/views/filament/pages/monitoring/operations.blade.php index e5ea170e..ff21466b 100644 --- a/resources/views/filament/pages/monitoring/operations.blade.php +++ b/resources/views/filament/pages/monitoring/operations.blade.php @@ -24,19 +24,19 @@ :active="$this->activeTab === 'succeeded'" wire:click="$set('activeTab', 'succeeded')" > - Completed successfully + Succeeded - Needs follow-up + Partial - Execution failed + Failed diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index c7a90ed8..237fdd4c 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -2,6 +2,8 @@ @php($overflowCount = (int) ($overflowCount ?? 0)) @php($tenant = $tenant ?? null) +{{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}} + {{-- Widget must always be mounted, even when empty, so it can receive Livewire events --}}
**NOTE: Write these tests first and confirm they fail before implementation.** + +- [X] T011 [P] [US1] Extend baseline compare reason-code regression coverage in `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php` +- [X] T012 [P] [US1] Add baseline compare explanation-surface coverage in `tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php` +- [X] T013 [P] [US1] Add tenant-surface authorization regression coverage for Baseline Compare in `tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` + +### Implementation for User Story 1 + +- [X] T014 [US1] Add explanation-oriented count classification and trust-state helpers in `app/Support/Baselines/BaselineCompareStats.php` +- [X] T015 [US1] Build baseline compare explanation payloads from translated reasons in `app/Services/Baselines/BaselineCompareService.php` and `app/Jobs/CompareBaselineToTenantJob.php` +- [X] T016 [US1] Refactor page state mapping to explanation-first rendering in `app/Filament/Pages/BaselineCompareLanding.php` +- [X] T017 [US1] Rework the baseline compare primary explanation and diagnostics layout in `resources/views/filament/pages/baseline-compare-landing.blade.php` +- [X] T018 [US1] Prefer operator explanation fields over raw reason-message fallbacks in `app/Support/ReasonTranslation/ReasonPresenter.php` + +**Checkpoint**: Baseline Compare no longer lets `0 findings` or absent output read as implicit all-clear when evaluation was limited. + +--- + +## Phase 4: User Story 2 - Separate Execution Success From Result Trust (Priority: P2) + +**Goal**: Governance-oriented Monitoring run detail and baseline-capture result presentation show execution outcome, result trust, dominant cause, and next action as distinct visible concepts. + +**Independent Test**: Open a governance run detail page and a Baseline Snapshot result surface for technically completed but limited-confidence outcomes, then verify both surfaces separate execution success from decision confidence without relying on JSON. + +### Tests for User Story 2 ⚠️ + +- [X] T019 [P] [US2] Extend governance run-detail truth coverage in `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php` +- [X] T020 [P] [US2] Extend operation-run explanation-surface assertions in `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` +- [X] T021 [P] [US2] Add operation-run explanation builder unit coverage in `tests/Unit/Support/OperatorExplanation/OperationRunExplanationTest.php` +- [X] T022 [P] [US2] Add baseline-capture result explanation coverage in `tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php` +- [X] T023 [P] [US2] Extend canonical-surface authorization regression coverage for run detail and Baseline Snapshot result presentation in `tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` + +### Implementation for User Story 2 + +- [X] T024 [US2] Compose operation-run and baseline-capture explanation patterns from artifact truth in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` +- [X] T025 [US2] Align Monitoring run-detail explanation sections and count semantics in `app/Filament/Resources/OperationRunResource.php` +- [X] T026 [US2] Route canonical tenantless run viewing through explanation-first rendering in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- [X] T027 [US2] Normalize governance run summaries and next-action wording in `app/Support/OpsUx/OperationUxPresenter.php` +- [X] T028 [US2] Render baseline-capture result explanation-first state in `app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php` and `app/Filament/Resources/BaselineSnapshotResource.php` + +**Checkpoint**: Monitoring run detail and Baseline Snapshot result presentation answer what happened, how reliable the result is, why it looks this way, and what to do next before diagnostics. + +--- + +## Phase 5: User Story 3 - Reuse One Explanation Pattern Across Domains (Priority: P3) + +**Goal**: At least one additional governance artifact family reuses the same explanation pattern and next-action semantics beyond baseline compare and run detail. + +**Independent Test**: Compare a tenant-review surface to the baseline compare reference case and verify both use the same reading order and explanation-family semantics for degraded or missing-input states. + +### Tests for User Story 3 ⚠️ + +- [X] T029 [P] [US3] Add tenant review detail and Review Register explanation reuse coverage in `tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php` +- [X] T030 [P] [US3] Extend shared governance-artifact explanation unit coverage in `tests/Unit/Badges/GovernanceArtifactTruthTest.php` +- [X] T031 [P] [US3] Extend secondary-governance authorization regression coverage for Tenant Review detail and Review Register in `tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` + +### Implementation for User Story 3 + +- [X] T032 [US3] Map tenant review artifact truth into shared explanation patterns in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` +- [X] T033 [US3] Render explanation-first tenant review detail state in `app/Filament/Resources/TenantReviewResource.php` +- [X] T034 [US3] Align Review Register row outcome and next-step columns with shared explanation patterns in `app/Filament/Pages/Reviews/ReviewRegister.php` + +**Checkpoint**: The explanation layer is proven reusable on a non-baseline governance surface without inventing a new state dialect. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Harden regression coverage, confirm centralized semantics, and validate the rollout against the documented surface rules. + +- [X] T035 [P] Refresh centralized taxonomy and badge regression coverage in `tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php` +- [X] T036 [P] Add absent-output and suppressed-output fallback coverage in `tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php` and `tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php` +- [X] T037 [P] Review changed operator surfaces against `docs/product/standards/list-surface-review-checklist.md` and record any approved implementation notes in `specs/161-operator-explanation-layer/plan.md` +- [X] T038 Run focused verification commands from `specs/161-operator-explanation-layer/quickstart.md` +- [X] T039 Run formatting for changed files with `vendor/bin/sail bin pint --dirty --format agent` after updating `specs/161-operator-explanation-layer/quickstart.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories. +- **User Stories (Phases 3-5)**: All depend on Foundational completion. +- **Polish (Phase 6)**: Depends on all implemented user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: Starts after Foundational and delivers the MVP reference implementation on Baseline Compare. +- **User Story 2 (P2)**: Starts after Foundational and reuses the same explanation infrastructure on Monitoring run detail. +- **User Story 3 (P3)**: Starts after Foundational and proves the explanation layer is reusable on a second governance domain. + +### Within Each User Story + +- Write tests first and confirm they fail before implementation. +- Update shared builders or presenters before wiring them into Filament surfaces. +- Finish the main support-layer logic before page-resource rendering changes. +- Validate each story against its independent checkpoint before moving to the next priority. + +## Parallel Opportunities + +- `T002` and `T003` can run in parallel after `T001`. +- `T005` through `T009` can run in parallel once `T004` establishes the shared explanation-state vocabulary. +- Story test tasks marked `[P]` can run in parallel within each user story. +- Surface adoption work in different files can split after the shared explanation infrastructure lands. + +## Parallel Example: User Story 1 + +```bash +# Write and run Baseline Compare tests in parallel: +T011 tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php +T012 tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php +T013 tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php + +# After shared explanation primitives are ready, split story files: +T014 app/Support/Baselines/BaselineCompareStats.php +T016 app/Filament/Pages/BaselineCompareLanding.php +T017 resources/views/filament/pages/baseline-compare-landing.blade.php +``` + +## Parallel Example: User Story 2 + +```bash +# Prepare run-detail coverage together: +T019 tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php +T020 tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php +T021 tests/Unit/Support/OperatorExplanation/OperationRunExplanationTest.php +T022 tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php +T023 tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php + +# Then split implementation by layer: +T024 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php +T025 app/Filament/Resources/OperationRunResource.php +T026 app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +T028 app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php +``` + +## Parallel Example: User Story 3 + +```bash +# Reuse coverage can be written together: +T029 tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php +T030 tests/Unit/Badges/GovernanceArtifactTruthTest.php +T031 tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php + +# Then split the second-domain rollout: +T032 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php +T033 app/Filament/Resources/TenantReviewResource.php +T034 app/Filament/Pages/Reviews/ReviewRegister.php +``` + +## Implementation Strategy + +### MVP First + +1. Complete Phase 1 and Phase 2. +2. Deliver User Story 1 on Baseline Compare as the first shippable explanation-layer slice. +3. Validate the motivating `0 findings` plus evidence-gap case before moving on. + +### Incremental Delivery + +1. Add User Story 2 to align governance Monitoring run detail and Baseline Snapshot capture-result presentation with the same reading model. +2. Add User Story 3 to prove the pattern is reusable across governance domains on Tenant Review detail and Review Register. +3. Finish Phase 6 for regression hardening, checklist review, and verification. + +### Suggested MVP Scope + +- Phase 1: Setup +- Phase 2: Foundational +- Phase 3: User Story 1 only \ No newline at end of file diff --git a/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php b/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php new file mode 100644 index 00000000..706d9594 --- /dev/null +++ b/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php @@ -0,0 +1,101 @@ +create(); + + $this->actingAs($nonMember) + ->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant')) + ->assertNotFound(); +}); + +it('returns 403 for members missing the required capability on the canonical run detail surface', function (): void { + $tenant = Tenant::factory()->create(); + [$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + [$readonly] = createUserWithTenant(tenant: $tenant, user: User::factory()->create(), role: 'readonly'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'inventory_sync', + ]); + + $this->actingAs($readonly) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertForbidden(); +}); + +it('returns 403 for workspace members missing baseline snapshot visibility on explanation-first baseline capture surfaces', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) + ->assertForbidden(); +}); + +it('returns 404 for non-members on the tenant review explanation detail surface', function (): void { + $targetTenant = Tenant::factory()->create(); + [$member] = createUserWithTenant(role: 'owner'); + $reviewOwner = User::factory()->create(); + createUserWithTenant(tenant: $targetTenant, user: $reviewOwner, role: 'owner'); + $review = composeTenantReviewForTest($targetTenant, $reviewOwner); + + $this->actingAs($member) + ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $targetTenant)) + ->assertNotFound(); +}); + +it('returns 404 for workspace members without entitled tenant visibility on the review register explanation surface', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(ReviewRegister::getUrl(panel: 'admin')) + ->assertNotFound(); +}); diff --git a/tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php b/tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php new file mode 100644 index 00000000..79e99a07 --- /dev/null +++ b/tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php @@ -0,0 +1,50 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $stats = BaselineCompareStats::forTenant($tenant); + $explanation = $stats->operatorExplanation(); + + expect($stats->state)->toBe('idle') + ->and($explanation->family)->toBe(ExplanationFamily::Unavailable) + ->and($explanation->nextActionText)->toBe('Run the baseline compare to generate a result'); + + Livewire::actingAs($user) + ->test(BaselineCompareLanding::class) + ->assertSee($explanation->headline) + ->assertSee($explanation->nextActionText) + ->assertSee($explanation->coverageStatement ?? ''); +}); diff --git a/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php b/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php index 36aff6b4..b9e84d5e 100644 --- a/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php +++ b/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php @@ -20,6 +20,8 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OperationRunType; +use App\Support\Ui\OperatorExplanation\ExplanationFamily; +use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use Carbon\CarbonImmutable; it('records no_subjects_in_scope when the resolved subject list is empty', function (): void { @@ -606,3 +608,33 @@ public function capture( expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value); expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::CoverageUnproven->value); }); + +it('maps baseline-compare why-no-findings reasons into operator explanation semantics', function ( + BaselineCompareReasonCode $reasonCode, + ExplanationFamily $expectedFamily, + TrustworthinessLevel $expectedTrust, + ?string $expectedAbsencePattern, +): void { + expect($reasonCode->explanationFamily())->toBe($expectedFamily) + ->and($reasonCode->trustworthinessLevel())->toBe($expectedTrust) + ->and($reasonCode->absencePattern())->toBe($expectedAbsencePattern); +})->with([ + 'coverage unproven is suppressed output' => [ + BaselineCompareReasonCode::CoverageUnproven, + ExplanationFamily::CompletedButLimited, + TrustworthinessLevel::LimitedConfidence, + 'suppressed_output', + ], + 'no drift detected is trustworthy no-result' => [ + BaselineCompareReasonCode::NoDriftDetected, + ExplanationFamily::NoIssuesDetected, + TrustworthinessLevel::Trustworthy, + 'true_no_result', + ], + 'no subjects in scope is missing input' => [ + BaselineCompareReasonCode::NoSubjectsInScope, + ExplanationFamily::MissingInput, + TrustworthinessLevel::Unusable, + 'missing_input', + ], +]); diff --git a/tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php b/tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php new file mode 100644 index 00000000..e40210b2 --- /dev/null +++ b/tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php @@ -0,0 +1,67 @@ + $overrides + */ + protected function makeExplanationReasonEnvelope(array $overrides = []): ReasonResolutionEnvelope + { + $nextSteps = $overrides['nextSteps'] ?? [NextStepOption::instruction('Review the recorded prerequisite before retrying.')]; + + return new ReasonResolutionEnvelope( + internalCode: (string) ($overrides['internalCode'] ?? 'operator.explanation.test'), + operatorLabel: (string) ($overrides['operatorLabel'] ?? 'Operator attention required'), + shortExplanation: (string) ($overrides['shortExplanation'] ?? 'TenantPilot recorded a missing prerequisite for this workflow.'), + actionability: (string) ($overrides['actionability'] ?? 'prerequisite_missing'), + nextSteps: is_array($nextSteps) ? $nextSteps : [], + showNoActionNeeded: (bool) ($overrides['showNoActionNeeded'] ?? false), + diagnosticCodeLabel: $overrides['diagnosticCodeLabel'] ?? 'operator.explanation.test', + trustImpact: (string) ($overrides['trustImpact'] ?? TrustworthinessLevel::Unusable->value), + absencePattern: $overrides['absencePattern'] ?? 'blocked_prerequisite', + ); + } + + /** + * @param array $overrides + */ + protected function makeArtifactTruthEnvelope( + array $overrides = [], + ?ReasonResolutionEnvelope $reason = null, + ): ArtifactTruthEnvelope { + return new ArtifactTruthEnvelope( + artifactFamily: (string) ($overrides['artifactFamily'] ?? 'test_artifact'), + artifactKey: (string) ($overrides['artifactKey'] ?? 'test_artifact:1'), + workspaceId: (int) ($overrides['workspaceId'] ?? 1), + tenantId: $overrides['tenantId'] ?? 1, + executionOutcome: $overrides['executionOutcome'] ?? 'completed', + artifactExistence: (string) ($overrides['artifactExistence'] ?? 'created'), + contentState: (string) ($overrides['contentState'] ?? 'trusted'), + freshnessState: (string) ($overrides['freshnessState'] ?? 'current'), + publicationReadiness: $overrides['publicationReadiness'] ?? null, + supportState: (string) ($overrides['supportState'] ?? 'normal'), + actionability: (string) ($overrides['actionability'] ?? 'none'), + primaryLabel: (string) ($overrides['primaryLabel'] ?? 'Trustworthy artifact'), + primaryExplanation: $overrides['primaryExplanation'] ?? 'The artifact can be used for the intended operator task.', + diagnosticLabel: $overrides['diagnosticLabel'] ?? null, + nextActionLabel: $overrides['nextActionLabel'] ?? null, + nextActionUrl: $overrides['nextActionUrl'] ?? null, + relatedRunId: $overrides['relatedRunId'] ?? null, + relatedArtifactUrl: $overrides['relatedArtifactUrl'] ?? null, + dimensions: [], + reason: $reason instanceof ReasonResolutionEnvelope + ? ArtifactTruthCause::fromReasonResolutionEnvelope($reason, 'test_artifact') + : null, + ); + } +} diff --git a/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php b/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php index ab0ea314..b6a09d8c 100644 --- a/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php +++ b/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php @@ -168,7 +168,7 @@ function seedEvidenceDomain(Tenant $tenant): void ->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant)) ->assertOk() ->assertSee('Artifact truth') - ->assertSee('Partial') + ->assertSee('Partially complete') ->assertSee('Refresh evidence before using this snapshot'); }); diff --git a/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php b/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php new file mode 100644 index 00000000..418d2c34 --- /dev/null +++ b/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php @@ -0,0 +1,44 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $snapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot->fresh()); + $explanation = $truth->operatorExplanation; + + $this->actingAs($user) + ->get(BaselineSnapshotResource::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee($explanation?->headline ?? '') + ->assertSee($explanation?->nextActionText ?? ''); + + $this->actingAs($user) + ->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) + ->assertOk() + ->assertSee('Result meaning') + ->assertSee($explanation?->evaluationResultLabel() ?? '') + ->assertSee('Result trust') + ->assertSee($explanation?->trustworthinessLabel() ?? '') + ->assertSee($explanation?->nextActionText ?? ''); +}); diff --git a/tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php b/tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php new file mode 100644 index 00000000..4bd5df2b --- /dev/null +++ b/tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php @@ -0,0 +1,86 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'completed_at' => now(), + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'errors_recorded' => 2, + ], + 'context' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'baseline_compare' => [ + 'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value, + 'coverage' => [ + 'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'], + 'covered_types' => ['deviceConfiguration'], + 'uncovered_types' => ['deviceCompliancePolicy'], + 'proof' => false, + ], + 'evidence_gaps' => [ + 'count' => 2, + 'by_reason' => [ + BaselineCompareReasonCode::CoverageUnproven->value => 2, + ], + ], + 'fidelity' => 'meta', + ], + ], + ]); + + $stats = BaselineCompareStats::forTenant($tenant); + $explanation = $stats->operatorExplanation(); + + expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput); + + Livewire::actingAs($user) + ->test(BaselineCompareLanding::class) + ->assertSee($explanation->headline) + ->assertSee($explanation->trustworthinessLabel()) + ->assertSee($explanation->nextActionText) + ->assertSee('Findings shown') + ->assertSee('Evidence gaps'); +}); diff --git a/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php b/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php index 4b21a3a9..b2cfe7c6 100644 --- a/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php +++ b/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php @@ -7,6 +7,7 @@ use App\Models\BaselineSnapshot; use App\Models\OperationRun; use App\Support\Baselines\BaselineReasonCodes; +use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Livewire\Livewire; @@ -44,6 +45,9 @@ 'completed_at' => now(), ]); + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh()); + $explanation = $truth->operatorExplanation; + Filament::setTenant(null, true); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); @@ -53,7 +57,59 @@ ->assertSee('Outcome') ->assertSee('Artifact truth') ->assertSee('Execution failed') + ->assertSee($explanation?->headline ?? '') + ->assertSee($explanation?->evaluationResultLabel() ?? '') + ->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee('Artifact not usable') ->assertSee('Artifact next step') ->assertSee('Inspect the related capture diagnostics before using this snapshot'); }); + +it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'context' => [ + 'baseline_compare' => [ + 'reason_code' => 'evidence_capture_incomplete', + 'coverage' => [ + 'proof' => false, + ], + 'evidence_gaps' => [ + 'count' => 4, + ], + ], + ], + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'errors_recorded' => 0, + ], + 'completed_at' => now(), + ]); + + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh()); + $explanation = $truth->operatorExplanation; + + Filament::setTenant(null, true); + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertSee('Artifact truth') + ->assertSee('Result meaning') + ->assertSee('Result trust') + ->assertSee('Artifact next step') + ->assertSee($explanation?->headline ?? '') + ->assertSee($explanation?->evaluationResultLabel() ?? '') + ->assertSee($explanation?->trustworthinessLabel() ?? '') + ->assertSee($explanation?->nextActionText ?? '') + ->assertSee('The run completed, but normal output was intentionally suppressed.') + ->assertSee('Resume or rerun evidence capture before relying on this compare result.'); +}); diff --git a/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php b/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php index 748cd1bf..7072319d 100644 --- a/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php +++ b/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php @@ -6,6 +6,7 @@ use App\Models\Tenant; use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceSnapshotStatus; +use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -32,6 +33,9 @@ ], ); + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh()); + $explanation = $truth->operatorExplanation; + Filament::setTenant(null, true); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); @@ -39,7 +43,10 @@ Livewire::actingAs($user) ->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->assertSee('Artifact truth') - ->assertSee('Partial') + ->assertSee($explanation?->headline ?? '') + ->assertSee($explanation?->evaluationResultLabel() ?? '') + ->assertSee($explanation?->trustworthinessLabel() ?? '') + ->assertSee('Partially complete') ->assertSee('Refresh evidence before using this snapshot'); }); @@ -62,6 +69,9 @@ ], ); + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh()); + $explanation = $truth->operatorExplanation; + Filament::setTenant(null, true); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); @@ -69,6 +79,7 @@ Livewire::actingAs($user) ->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->assertSee('Artifact truth') + ->assertSee($explanation?->headline ?? '') ->assertSee('Artifact not usable') ->assertSee('Inspect the blocked run details before retrying'); }); diff --git a/tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php b/tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php new file mode 100644 index 00000000..959529f7 --- /dev/null +++ b/tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php @@ -0,0 +1,47 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = $this->makeArtifactTruthRun( + tenant: $tenant, + type: 'tenant.review.compose', + context: [ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'reason_code' => 'review_missing_sections', + ], + attributes: [ + 'outcome' => 'blocked', + 'failure_summary' => [ + ['reason_code' => 'review_missing_sections', 'message' => 'The review basis is incomplete.'], + ], + ], + ); + + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run); + $explanation = $truth->operatorExplanation; + + Filament::setTenant(null, true); + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertSee($explanation?->headline ?? '') + ->assertSee($explanation?->nextActionText ?? '') + ->assertSee('Artifact truth'); +}); diff --git a/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/tests/Feature/Monitoring/OperationsTenantScopeTest.php index 045f8be0..3ed10919 100644 --- a/tests/Feature/Monitoring/OperationsTenantScopeTest.php +++ b/tests/Feature/Monitoring/OperationsTenantScopeTest.php @@ -214,9 +214,9 @@ ->get('/admin/operations') ->assertOk() ->assertSee('Blocked by prerequisite') - ->assertSee('Completed successfully') - ->assertSee('Needs follow-up') - ->assertSee('Execution failed'); + ->assertSee('Succeeded') + ->assertSee('Partial') + ->assertSee('Failed'); }); it('prevents cross-workspace access to Monitoring → Operations detail', function () { diff --git a/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php b/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php index 979c4e69..a49ce045 100644 --- a/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php +++ b/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php @@ -54,7 +54,7 @@ expect($notification)->not->toBeNull() ->and($notification->data['body'] ?? null)->toContain('Permission required') - ->and($notification->data['body'] ?? null)->toContain('capability required for this queued run') + ->and($notification->data['body'] ?? null)->toContain('initiating actor no longer has the required capability') ->and($notification->data['body'] ?? null)->toContain('Review workspace or tenant access before retrying.') ->and($notification->data['body'] ?? null)->toContain('Total: 2'); }); diff --git a/tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php b/tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php new file mode 100644 index 00000000..8666eeb8 --- /dev/null +++ b/tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php @@ -0,0 +1,37 @@ +forArtifactTruth($reasonCode, 'artifact_truth'); + + expect($envelope)->not->toBeNull() + ->and($envelope?->trustImpact)->toBe($expectedTrustImpact) + ->and($envelope?->absencePattern)->toBe($expectedAbsencePattern) + ->and(app(ReasonPresenter::class)->dominantCauseExplanation($envelope))->not->toBe(''); +})->with([ + 'suppressed compare result' => [ + BaselineCompareReasonCode::CoverageUnproven->value, + TrustworthinessLevel::LimitedConfidence->value, + 'suppressed_output', + ], + 'missing baseline input' => [ + BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED, + TrustworthinessLevel::LimitedConfidence->value, + 'unavailable', + ], + 'fallback review blocker' => [ + 'review_missing_sections', + TrustworthinessLevel::Unusable->value, + 'missing_input', + ], +]); diff --git a/tests/Feature/ReviewPack/ReviewPackResourceTest.php b/tests/Feature/ReviewPack/ReviewPackResourceTest.php index a74519e5..e4253a4d 100644 --- a/tests/Feature/ReviewPack/ReviewPackResourceTest.php +++ b/tests/Feature/ReviewPack/ReviewPackResourceTest.php @@ -362,7 +362,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot $this->actingAs($user) ->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant)) ->assertOk() - ->assertSee('Blocked') + ->assertSee('Publication blocked') ->assertSee('Open the source review before sharing this pack'); }); diff --git a/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php b/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php new file mode 100644 index 00000000..96b98d3e --- /dev/null +++ b/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php @@ -0,0 +1,52 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant); + $review = $this->makeArtifactTruthReview( + tenant: $tenant, + user: $user, + snapshot: $snapshot, + reviewOverrides: [ + 'status' => 'draft', + 'completeness_state' => 'complete', + ], + summaryOverrides: [ + 'publish_blockers' => ['Review the missing approval note before publication.'], + ], + ); + + $truth = app(ArtifactTruthPresenter::class)->forTenantReview($review); + $explanation = $truth->operatorExplanation; + + setTenantPanelContext($tenant); + + $this->actingAs($user) + ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) + ->assertOk() + ->assertSee($explanation?->headline ?? '') + ->assertSee($explanation?->nextActionText ?? ''); + + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(ReviewRegister::class) + ->assertCanSeeTableRecords([$review]) + ->assertSee($explanation?->headline ?? '') + ->assertSee($explanation?->nextActionText ?? ''); +}); diff --git a/tests/Feature/TenantReview/TenantReviewLifecycleTest.php b/tests/Feature/TenantReview/TenantReviewLifecycleTest.php index 6ae01e55..23ec2808 100644 --- a/tests/Feature/TenantReview/TenantReviewLifecycleTest.php +++ b/tests/Feature/TenantReview/TenantReviewLifecycleTest.php @@ -26,7 +26,7 @@ expect($truth->artifactExistence)->toBe('created') ->and($truth->publicationReadiness)->toBe('blocked') - ->and($truth->primaryLabel)->toBe('Blocked') + ->and($truth->primaryLabel)->toBe('Publication blocked') ->and($truth->nextStepText())->toBe('Resolve the review blockers before publication'); expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user)) diff --git a/tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php b/tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php index a511c598..436eae3e 100644 --- a/tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php +++ b/tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php @@ -43,7 +43,7 @@ ->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey()) ->assertCanSeeTableRecords([$reviewB]) ->assertCanNotSeeTableRecords([$reviewA]) - ->assertSee('Blocked') + ->assertSee('Publication blocked') ->assertSee('Resolve the review blockers before publication') ->assertDontSee('Publishable'); }); @@ -81,7 +81,7 @@ ->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey()) ->assertCanSeeTableRecords([$reviewA]) ->assertCanNotSeeTableRecords([$reviewB]) - ->assertSee('Blocked') + ->assertSee('Publication blocked') ->assertSee('Resolve the review blockers before publication') ->assertDontSee('Publishable'); }); diff --git a/tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php b/tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php index 0b581d30..d3e36692 100644 --- a/tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php +++ b/tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php @@ -79,7 +79,7 @@ ->test(ReviewRegister::class) ->assertCanSeeTableRecords([$allowedReview]) ->assertCanNotSeeTableRecords([$deniedReview]) - ->assertSee('Blocked') + ->assertSee('Publication blocked') ->assertSee('Resolve the review blockers before publication') ->assertDontSee('Denied Tenant'); }); diff --git a/tests/Feature/TenantReview/TenantReviewUiContractTest.php b/tests/Feature/TenantReview/TenantReviewUiContractTest.php index baf79182..0e26639b 100644 --- a/tests/Feature/TenantReview/TenantReviewUiContractTest.php +++ b/tests/Feature/TenantReview/TenantReviewUiContractTest.php @@ -104,6 +104,6 @@ ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) ->assertOk() ->assertSee('Artifact truth') - ->assertSee('Blocked') + ->assertSee('Publication blocked') ->assertSee('Resolve the review blockers before publication'); }); diff --git a/tests/Unit/Badges/GovernanceArtifactTruthTest.php b/tests/Unit/Badges/GovernanceArtifactTruthTest.php index 4837b1ab..021a89b7 100644 --- a/tests/Unit/Badges/GovernanceArtifactTruthTest.php +++ b/tests/Unit/Badges/GovernanceArtifactTruthTest.php @@ -56,7 +56,7 @@ expect($truth->artifactExistence)->toBe('created') ->and($truth->publicationReadiness)->toBe('blocked') - ->and($truth->primaryLabel)->toBe('Blocked') + ->and($truth->primaryLabel)->toBe('Publication blocked') ->and($truth->nextStepText())->toContain('Resolve'); }); @@ -113,3 +113,43 @@ ->and($incompleteTruth->diagnosticLabel)->toBe('Incomplete') ->and($incompleteTruth->reason?->reasonCode)->toBe(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED); }); + +it('maps shared operator explanations onto blocked tenant-review and incomplete baseline-snapshot truth envelopes', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + $snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant); + $review = $this->makeArtifactTruthReview( + tenant: $tenant, + user: $user, + snapshot: $snapshot, + reviewOverrides: [ + 'status' => 'draft', + 'completeness_state' => 'complete', + ], + summaryOverrides: [ + 'publish_blockers' => ['Review the missing approval note before publication.'], + ], + ); + + $reviewTruth = app(ArtifactTruthPresenter::class)->forTenantReview($review); + + expect($reviewTruth->operatorExplanation)->not->toBeNull() + ->and($reviewTruth->operatorExplanation?->nextActionText)->toBe('Resolve the review blockers before publication') + ->and($reviewTruth->operatorExplanation?->trustworthinessLabel())->toBe('Not usable yet'); + + $workspace = Workspace::factory()->create(); + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $workspace->getKey(), + ]); + + $incompleteSnapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $snapshotTruth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($incompleteSnapshot->fresh()); + + expect($snapshotTruth->operatorExplanation)->not->toBeNull() + ->and($snapshotTruth->operatorExplanation?->headline)->toBe('The result exists, but missing inputs keep it from being decision-grade.') + ->and($snapshotTruth->operatorExplanation?->nextActionText)->toContain('Inspect the related capture diagnostics'); +}); diff --git a/tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php b/tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php index d417ea42..8e64a7b9 100644 --- a/tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php +++ b/tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php @@ -39,6 +39,13 @@ ->and(BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'warning')->label)->toBe('Review before running'); }); +it('maps operator explanation evaluation and trust badges through centralized taxonomy', function (): void { + expect(BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, 'suppressed_result')->label)->toBe('Suppressed result') + ->and(BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, 'unavailable')->label)->toBe('Result unavailable') + ->and(BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, 'limited_confidence')->label)->toBe('Limited confidence') + ->and(BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, 'unusable')->label)->toBe('Not usable yet'); +}); + it('rejects diagnostic warning or danger taxonomy combinations', function (): void { expect(fn (): BadgeSpec => new BadgeSpec( label: 'Invalid diagnostic warning', diff --git a/tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php b/tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php index 12eb4cda..51a69f4e 100644 --- a/tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php +++ b/tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php @@ -32,7 +32,7 @@ }); it('exposes shared governance-artifact truth badges for evidence semantics', function (): void { - expect(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, 'partial')->label)->toBe('Partial') - ->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, 'stale')->label)->toBe('Stale') + expect(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, 'partial')->label)->toBe('Partially complete') + ->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, 'stale')->label)->toBe('Refresh recommended') ->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactActionability, 'required')->label)->toBe('Action required'); }); diff --git a/tests/Unit/Support/OperatorExplanation/OperationRunExplanationTest.php b/tests/Unit/Support/OperatorExplanation/OperationRunExplanationTest.php new file mode 100644 index 00000000..ce0d353b --- /dev/null +++ b/tests/Unit/Support/OperatorExplanation/OperationRunExplanationTest.php @@ -0,0 +1,39 @@ +create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = $this->makeArtifactTruthRun( + tenant: $tenant, + type: 'tenant.review.compose', + context: [ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'reason_code' => 'review_missing_sections', + ], + attributes: [ + 'outcome' => 'blocked', + 'failure_summary' => [ + ['reason_code' => 'review_missing_sections', 'message' => 'The review basis is incomplete.'], + ], + ], + ); + + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run); + $explanation = $truth->operatorExplanation; + + expect($explanation)->not->toBeNull() + ->and(OperationUxPresenter::surfaceFailureDetail($run))->toBe($explanation?->dominantCauseExplanation) + ->and(OperationUxPresenter::surfaceGuidance($run))->toContain((string) $explanation?->nextActionText); +}); diff --git a/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php b/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php new file mode 100644 index 00000000..38556c89 --- /dev/null +++ b/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php @@ -0,0 +1,66 @@ +makeExplanationReasonEnvelope([ + 'internalCode' => 'review_publish_blocked', + 'operatorLabel' => 'Publication blocked', + 'shortExplanation' => 'A required approval or prerequisite is missing for this review.', + 'trustImpact' => TrustworthinessLevel::Unusable->value, + 'absencePattern' => 'blocked_prerequisite', + 'nextSteps' => [\App\Support\ReasonTranslation\NextStepOption::instruction('Resolve review blockers before publication.')], + ]); + + $truth = $this->makeArtifactTruthEnvelope([ + 'executionOutcome' => 'blocked', + 'artifactExistence' => 'created_but_not_usable', + 'contentState' => 'missing_input', + 'actionability' => 'required', + 'primaryLabel' => 'Artifact not usable', + 'primaryExplanation' => 'The review exists, but it is blocked from publication.', + 'nextActionLabel' => 'Resolve review blockers before publication', + ], $reason); + + $explanation = app(OperatorExplanationBuilder::class)->fromArtifactTruthEnvelope($truth, [ + new CountDescriptor('Publish blockers', 2, CountDescriptor::ROLE_RELIABILITY_SIGNAL, 'resolve before publish'), + ]); + + expect($explanation->family)->toBe(ExplanationFamily::BlockedPrerequisite) + ->and($explanation->evaluationResult)->toBe('unavailable') + ->and($explanation->trustworthinessLevel)->toBe(TrustworthinessLevel::Unusable) + ->and($explanation->dominantCauseLabel)->toBe('Publication blocked') + ->and($explanation->dominantCauseExplanation)->toContain('missing for this review') + ->and($explanation->nextActionText)->toBe('Resolve review blockers before publication') + ->and($explanation->countDescriptors)->toHaveCount(1); +}); + +it('keeps trustworthy artifact truth separate from no-action guidance', function (): void { + $truth = $this->makeArtifactTruthEnvelope([ + 'executionOutcome' => 'succeeded', + 'artifactExistence' => 'created', + 'contentState' => 'trusted', + 'freshnessState' => 'current', + 'actionability' => 'none', + 'primaryLabel' => 'Trustworthy artifact', + 'primaryExplanation' => 'The artifact is ready for the intended operator task.', + ]); + + $explanation = app(OperatorExplanationBuilder::class)->fromArtifactTruthEnvelope($truth, [ + new CountDescriptor('Findings', 3, CountDescriptor::ROLE_EVALUATION_OUTPUT), + ]); + + expect($explanation->family)->toBe(ExplanationFamily::TrustworthyResult) + ->and($explanation->evaluationResult)->toBe('full_result') + ->and($explanation->trustworthinessLevel)->toBe(TrustworthinessLevel::Trustworthy) + ->and($explanation->nextActionText)->toBe('No action needed') + ->and($explanation->coverageStatement)->toContain('sufficient'); +}); diff --git a/tests/Unit/TenantReview/TenantReviewBadgeTest.php b/tests/Unit/TenantReview/TenantReviewBadgeTest.php index 7a8b6071..3e3219f2 100644 --- a/tests/Unit/TenantReview/TenantReviewBadgeTest.php +++ b/tests/Unit/TenantReview/TenantReviewBadgeTest.php @@ -23,6 +23,6 @@ it('maps publication-readiness truth badges for tenant reviews', function (): void { expect(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, 'internal_only')->label)->toBe('Internal only') - ->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked')->label)->toBe('Blocked') + ->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked')->label)->toBe('Publication blocked') ->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, 'publishable')->label)->toBe('Publishable'); });