feat: implement operator explanation layer
This commit is contained in:
parent
845d21db6d
commit
ebf88cd05b
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -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
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -89,6 +89,9 @@ class BaselineCompareLanding extends Page
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $rbacRoleDefinitionSummary = null;
|
||||
|
||||
/** @var array<string, mixed>|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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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'] : [],
|
||||
|
||||
@ -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<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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".
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<string, mixed>}
|
||||
*/
|
||||
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<string, mixed>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class OperatorExplanationEvaluationResultBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'full_result' => 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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class OperatorExplanationTrustworthinessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'trustworthy' => 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();
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
203
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
203
app/Support/Baselines/BaselineCompareExplanationRegistry.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
final class BaselineCompareExplanationRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperatorExplanationBuilder $builder,
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
) {}
|
||||
|
||||
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
|
||||
{
|
||||
$reason = $stats->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<int, CountDescriptor>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<int, array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }>
|
||||
*/
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, mixed> $context
|
||||
* @return array<string, mixed>|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<string, mixed> $context
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
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<string, mixed> $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();
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<int, string>
|
||||
* }
|
||||
*/
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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<int, string>
|
||||
* }
|
||||
* },
|
||||
* operatorExplanation: ?array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -132,6 +137,7 @@ public function toArray(): array
|
||||
),
|
||||
)),
|
||||
'reason' => $this->reason?->toArray(),
|
||||
'operatorExplanation' => $this->operatorExplanation?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<int, CountDescriptor>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
67
app/Support/Ui/OperatorExplanation/CountDescriptor.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class CountDescriptor
|
||||
{
|
||||
public const string ROLE_EXECUTION = 'execution';
|
||||
|
||||
public const string ROLE_EVALUATION_OUTPUT = 'evaluation_output';
|
||||
|
||||
public const string ROLE_COVERAGE = 'coverage';
|
||||
|
||||
public const string ROLE_RELIABILITY_SIGNAL = 'reliability_signal';
|
||||
|
||||
public const string VISIBILITY_PRIMARY = 'primary';
|
||||
|
||||
public const string VISIBILITY_DIAGNOSTIC = 'diagnostic';
|
||||
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public int $value,
|
||||
public string $role,
|
||||
public ?string $qualifier = null,
|
||||
public string $visibilityTier = self::VISIBILITY_PRIMARY,
|
||||
) {
|
||||
if (trim($this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
17
app/Support/Ui/OperatorExplanation/ExplanationFamily.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
enum ExplanationFamily: string
|
||||
{
|
||||
case TrustworthyResult = 'trustworthy_result';
|
||||
case NoIssuesDetected = 'no_issues_detected';
|
||||
case CompletedButLimited = 'completed_but_limited';
|
||||
case SuppressedOutput = 'suppressed_output';
|
||||
case MissingInput = 'missing_input';
|
||||
case BlockedPrerequisite = 'blocked_prerequisite';
|
||||
case Unavailable = 'unavailable';
|
||||
case InProgress = 'in_progress';
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
|
||||
final class OperatorExplanationBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $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<int, CountDescriptor> $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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class OperatorExplanationPattern
|
||||
{
|
||||
/**
|
||||
* @param array<int, CountDescriptor> $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<int, array{
|
||||
* label: string,
|
||||
* value: int,
|
||||
* role: string,
|
||||
* qualifier: ?string,
|
||||
* visibilityTier: string
|
||||
* }>,
|
||||
* 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
13
app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\OperatorExplanation;
|
||||
|
||||
enum TrustworthinessLevel: string
|
||||
{
|
||||
case Trustworthy = 'trustworthy';
|
||||
case LimitedConfidence = 'limited_confidence';
|
||||
case DiagnosticOnly = 'diagnostic_only';
|
||||
case Unusable = 'unusable';
|
||||
}
|
||||
@ -35,6 +35,7 @@ public static function firstSlice(): array
|
||||
'evidence_snapshots',
|
||||
'inventory_items',
|
||||
'entra_groups',
|
||||
'tenant_reviews',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **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
|
||||
|
||||
@ -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
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
||||
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
||||
{{ $evaluationSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
||||
{{ $trustSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($primarySpec)
|
||||
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
||||
{{ $primarySpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($actionabilitySpec)
|
||||
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
|
||||
@ -51,15 +71,31 @@
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
|
||||
{{ $state['primaryLabel'] ?? 'Artifact truth' }}
|
||||
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
|
||||
</div>
|
||||
|
||||
@if (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
||||
@if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '')
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $operatorExplanation['reliabilityStatement'] }}
|
||||
</p>
|
||||
@elseif (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $state['primaryExplanation'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (is_string(data_get($operatorExplanation, 'dominantCause.explanation')) && trim(data_get($operatorExplanation, 'dominantCause.explanation')) !== '')
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ data_get($operatorExplanation, 'dominantCause.explanation') }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Coverage: {{ $operatorExplanation['coverageStatement'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Diagnostic: {{ $state['diagnosticLabel'] }}
|
||||
@ -102,14 +138,47 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
|
||||
<dd class="mt-1">
|
||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
||||
{{ $trustSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $state['nextActionLabel'] ?? 'No action needed' }}
|
||||
{{ data_get($operatorExplanation, 'nextAction.text') ?? ($state['nextActionLabel'] ?? 'No action needed') }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if ($operatorCounts->isNotEmpty())
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($operatorCounts as $count)
|
||||
@continue(! is_array($count))
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $count['label'] ?? 'Count' }}
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ (int) ($count['value'] ?? 0) }}
|
||||
</div>
|
||||
@if (filled($count['qualifier'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $count['qualifier'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
||||
|
||||
@ -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
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($operatorExplanation !== [])
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $operatorExplanation['headline'] ?? 'Review explanation' }}
|
||||
</div>
|
||||
|
||||
@if (filled($operatorExplanation['reliabilityStatement'] ?? null))
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $operatorExplanation['reliabilityStatement'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled(data_get($operatorExplanation, 'nextAction.text')))
|
||||
<div class="mt-3 rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-sm text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
|
||||
{{ data_get($operatorExplanation, 'nextAction.text') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
@foreach ($metrics as $metric)
|
||||
@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 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($explanation !== null)
|
||||
<x-filament::section>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
|
||||
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
|
||||
{{ $evaluationSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($trustSpec && $trustSpec->label !== 'Unknown')
|
||||
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
|
||||
{{ $trustSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||
{{ $explanation['headline'] ?? 'Compare explanation' }}
|
||||
</div>
|
||||
|
||||
@if (filled($explanation['reliabilityStatement'] ?? null))
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200">
|
||||
{{ $explanation['reliabilityStatement'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (filled(data_get($explanation, 'dominantCause.explanation')))
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ data_get($explanation, 'dominantCause.explanation') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $explanation['executionOutcomeLabel'] ?? 'Completed' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $explanation['trustworthinessLabel'] ?? 'Needs review' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50 md:col-span-2">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">What to do next</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ data_get($explanation, 'nextAction.text', 'Review the latest compare run.') }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (filled($explanation['coverageStatement'] ?? null))
|
||||
<div class="rounded-lg border border-primary-200 bg-primary-50/70 px-4 py-3 text-sm text-primary-950 dark:border-primary-900/40 dark:bg-primary-950/20 dark:text-primary-100">
|
||||
<span class="font-semibold">Coverage:</span>
|
||||
{{ $explanation['coverageStatement'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($explanationCounts->isNotEmpty())
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($explanationCounts as $count)
|
||||
@continue(! is_array($count))
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $count['label'] ?? 'Count' }}
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||
{{ (int) ($count['value'] ?? 0) }}
|
||||
</div>
|
||||
@if (filled($count['qualifier'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $count['qualifier'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Row 1: Stats Overview --}}
|
||||
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<x-filament-panels::page>
|
||||
@php($selectedAudit = $this->selectedAuditRecord())
|
||||
@php($selectedAuditLink = $this->selectedAuditTargetLink())
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
@ -15,5 +18,14 @@
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($selectedAudit)
|
||||
<x-filament::section>
|
||||
@include('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
||||
'selectedAudit' => $selectedAudit,
|
||||
'selectedAuditLink' => $selectedAuditLink,
|
||||
])
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -24,19 +24,19 @@
|
||||
:active="$this->activeTab === 'succeeded'"
|
||||
wire:click="$set('activeTab', 'succeeded')"
|
||||
>
|
||||
Completed successfully
|
||||
Succeeded
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'partial'"
|
||||
wire:click="$set('activeTab', 'partial')"
|
||||
>
|
||||
Needs follow-up
|
||||
Partial
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'failed'"
|
||||
wire:click="$set('activeTab', 'failed')"
|
||||
>
|
||||
Execution failed
|
||||
Failed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
|
||||
@ -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 --}}
|
||||
<div
|
||||
x-data="opsUxProgressWidgetPoller()"
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Operator Explanation Layer for Degraded, Partial, and Suppressed Results
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-23
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/161-operator-explanation-layer/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on 2026-03-23 after first-pass authoring.
|
||||
- Spec numbering was corrected manually to `161-operator-explanation-layer` after the initial scaffold was created with `001`.
|
||||
- Scope is intentionally bounded to the shared explanation layer and its reference adoption surfaces, not a full governance-surface redesign.
|
||||
235
specs/161-operator-explanation-layer/contracts/openapi.yaml
Normal file
235
specs/161-operator-explanation-layer/contracts/openapi.yaml
Normal file
@ -0,0 +1,235 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Operator Explanation Layer Contract
|
||||
version: 1.0.0
|
||||
summary: Logical operator-facing read contract for explanation-first governance surfaces
|
||||
description: |
|
||||
This contract captures the intended read-model semantics for Spec 161.
|
||||
These are logical contracts for existing Filament and Livewire-backed surfaces,
|
||||
not a commitment to public REST endpoints.
|
||||
servers:
|
||||
- url: https://tenantpilot.local
|
||||
tags:
|
||||
- name: BaselineCompare
|
||||
- name: GovernanceRuns
|
||||
- name: GovernanceArtifacts
|
||||
paths:
|
||||
/tenants/{tenantId}/baseline-compare/explanation:
|
||||
get:
|
||||
tags: [BaselineCompare]
|
||||
summary: Read baseline compare explanation model
|
||||
description: Returns the operator explanation pattern for the current baseline compare state, including trustworthiness, count semantics, and diagnostics availability.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Explanation model returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceResultSurfaceModel'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/operation-runs/{operationRunId}/explanation:
|
||||
get:
|
||||
tags: [GovernanceRuns]
|
||||
summary: Read governance run explanation model
|
||||
description: Returns the operator explanation pattern and secondary diagnostics for a governance-oriented operation run.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OperationRunId'
|
||||
responses:
|
||||
'200':
|
||||
description: Run explanation model returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceResultSurfaceModel'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/governance-artifacts/{artifactType}/{artifactId}/explanation:
|
||||
get:
|
||||
tags: [GovernanceArtifacts]
|
||||
summary: Read governance artifact explanation model
|
||||
description: Returns the primary explanation block, secondary truth dimensions, and diagnostics visibility for a governance artifact detail surface.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ArtifactType'
|
||||
- $ref: '#/components/parameters/ArtifactId'
|
||||
responses:
|
||||
'200':
|
||||
description: Artifact explanation model returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceResultSurfaceModel'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
OperationRunId:
|
||||
name: operationRunId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
ArtifactType:
|
||||
name: artifactType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [baseline_snapshot, evidence_snapshot, tenant_review, review_pack]
|
||||
ArtifactId:
|
||||
name: artifactId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
responses:
|
||||
Forbidden:
|
||||
description: Member lacks the required capability in the already established scope
|
||||
NotFound:
|
||||
description: Workspace or tenant scope is not entitled, or the requested record is not visible in that scope
|
||||
|
||||
schemas:
|
||||
GovernanceResultSurfaceModel:
|
||||
type: object
|
||||
required:
|
||||
- primaryPattern
|
||||
- secondaryTruth
|
||||
- diagnosticsAvailable
|
||||
properties:
|
||||
primaryPattern:
|
||||
$ref: '#/components/schemas/OperatorExplanationPattern'
|
||||
secondaryTruth:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TruthDimension'
|
||||
diagnosticsAvailable:
|
||||
type: boolean
|
||||
diagnosticsSummary:
|
||||
type: string
|
||||
nullable: true
|
||||
actions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [label, mutationScope]
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
mutationScope:
|
||||
type: string
|
||||
enum: [tenantpilot_only, microsoft_tenant, simulation_only, none]
|
||||
enabled:
|
||||
type: boolean
|
||||
|
||||
OperatorExplanationPattern:
|
||||
type: object
|
||||
required:
|
||||
- headline
|
||||
- executionOutcome
|
||||
- evaluationResult
|
||||
- reliabilityLevel
|
||||
- nextAction
|
||||
- countDescriptors
|
||||
properties:
|
||||
headline:
|
||||
type: string
|
||||
executionOutcome:
|
||||
type: string
|
||||
examples: [completed, completed_with_follow_up, failed, blocked]
|
||||
evaluationResult:
|
||||
type: string
|
||||
examples: [full_result, incomplete_result, suppressed_result, no_result, unavailable]
|
||||
reliabilityLevel:
|
||||
type: string
|
||||
enum: [trustworthy, limited_confidence, diagnostic_only, unusable]
|
||||
reliabilityStatement:
|
||||
type: string
|
||||
coverageStatement:
|
||||
type: string
|
||||
nullable: true
|
||||
dominantCause:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
nullable: true
|
||||
label:
|
||||
type: string
|
||||
explanation:
|
||||
type: string
|
||||
nextAction:
|
||||
type: object
|
||||
required: [category, text]
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- observe
|
||||
- retry_later
|
||||
- fix_prerequisite
|
||||
- refresh_or_sync
|
||||
- review_evidence_gaps
|
||||
- manual_validate
|
||||
- escalate
|
||||
text:
|
||||
type: string
|
||||
countDescriptors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CountDescriptor'
|
||||
diagnosticsAvailable:
|
||||
type: boolean
|
||||
|
||||
CountDescriptor:
|
||||
type: object
|
||||
required: [label, value, role, visibilityTier]
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
value:
|
||||
type: integer
|
||||
role:
|
||||
type: string
|
||||
enum: [execution, evaluation_output, coverage, reliability_signal]
|
||||
qualifier:
|
||||
type: string
|
||||
nullable: true
|
||||
visibilityTier:
|
||||
type: string
|
||||
enum: [primary, diagnostic]
|
||||
|
||||
TruthDimension:
|
||||
type: object
|
||||
required: [dimension, value, visibilityTier]
|
||||
properties:
|
||||
dimension:
|
||||
type: string
|
||||
examples: [artifact_existence, content, freshness, support_level, publication_readiness, actionability]
|
||||
value:
|
||||
type: string
|
||||
visibilityTier:
|
||||
type: string
|
||||
enum: [primary, secondary, diagnostic]
|
||||
explanation:
|
||||
type: string
|
||||
nullable: true
|
||||
163
specs/161-operator-explanation-layer/data-model.md
Normal file
163
specs/161-operator-explanation-layer/data-model.md
Normal file
@ -0,0 +1,163 @@
|
||||
# Data Model: Operator Explanation Layer
|
||||
|
||||
## Entity: OperatorExplanationPattern
|
||||
|
||||
Purpose:
|
||||
- The reusable view-model contract that turns an internal governance outcome into one operator-readable explanation block.
|
||||
|
||||
Core fields:
|
||||
- `headline`
|
||||
- Primary operator statement answering what happened
|
||||
- `executionOutcome`
|
||||
- Technical run or workflow completion state
|
||||
- `evaluationResult`
|
||||
- Business-level result statement such as complete, incomplete, suppressed, or unavailable
|
||||
- `reliabilityStatement`
|
||||
- Whether the result is trustworthy, limited-confidence, diagnostically useful, or unusable
|
||||
- `coverageStatement`
|
||||
- What is known about completeness, evidence coverage, or missing-input boundaries
|
||||
- `dominantCause`
|
||||
- Primary explanation for why the result looks the way it does
|
||||
- `nextAction`
|
||||
- Recommended operator follow-up category and text
|
||||
- `countDescriptors`
|
||||
- Structured list of counts with semantic roles and labels
|
||||
- `diagnosticsAvailable`
|
||||
- Boolean or summary indicating that deeper technical detail exists
|
||||
|
||||
Relationships:
|
||||
- Composed from `ReasonResolutionEnvelope`, `ArtifactTruthEnvelope`, `OperationRun`, and surface-specific stats providers such as baseline compare stats.
|
||||
- Rendered by Filament pages, resources, and Blade views on affected operator surfaces.
|
||||
|
||||
Validation / invariants:
|
||||
- Every pattern must provide a headline, reliability statement, and next action when the result is degraded, suppressed, blocked, or incomplete.
|
||||
- Diagnostics may enrich the pattern but may not replace the primary explanation fields.
|
||||
- The pattern must never collapse execution success into result trustworthiness when those truths diverge.
|
||||
|
||||
## Entity: CountDescriptor
|
||||
|
||||
Purpose:
|
||||
- Describes one visible numeric count together with its semantic meaning so counts cannot be misread.
|
||||
|
||||
Core fields:
|
||||
- `label`
|
||||
- `value`
|
||||
- `role`
|
||||
- Allowed values in V1:
|
||||
- `execution`
|
||||
- `evaluation_output`
|
||||
- `coverage`
|
||||
- `reliability_signal`
|
||||
- `qualifier`
|
||||
- Optional operator-safe context such as `partial`, `suppressed`, or `not complete`
|
||||
- `visibilityTier`
|
||||
- `primary` or `diagnostic`
|
||||
|
||||
Validation / invariants:
|
||||
- A count with role `evaluation_output` must not imply full completeness on its own.
|
||||
- A count with role `coverage` or `reliability_signal` must not be visually indistinguishable from an outcome count.
|
||||
|
||||
## Entity: ReasonResolutionEnvelope (extended use)
|
||||
|
||||
Purpose:
|
||||
- Existing translated reason container that now also feeds the shared explanation layer.
|
||||
|
||||
Relevant fields used by this feature:
|
||||
- `operatorLabel`
|
||||
- `operatorExplanation`
|
||||
- `actionability`
|
||||
- `nextSteps`
|
||||
|
||||
New or clarified semantic outputs for this feature:
|
||||
- `trustImpact`
|
||||
- How the reason affects decision confidence
|
||||
- `absencePattern`
|
||||
- Whether the reason represents true no-result, suppressed result, missing input, blocked prerequisite, or unavailable evaluation
|
||||
|
||||
Validation / invariants:
|
||||
- Domain reason codes remain technical identifiers.
|
||||
- Operator explanation fields must remain safe to show without diagnostics.
|
||||
|
||||
## Entity: ArtifactTruthEnvelope (composed input)
|
||||
|
||||
Purpose:
|
||||
- Existing multi-dimensional truth model for governance artifacts used as one semantic source for the explanation pattern.
|
||||
|
||||
Relevant dimensions for this feature:
|
||||
- artifact existence
|
||||
- content or usability
|
||||
- freshness
|
||||
- support level
|
||||
- actionability
|
||||
- publication readiness
|
||||
- execution outcome where applicable
|
||||
|
||||
Feature constraints:
|
||||
- Artifact truth remains the semantic substrate for artifact detail surfaces.
|
||||
- The explanation pattern may compress or prioritize dimensions, but must not lose the underlying truth distinctions.
|
||||
|
||||
## Entity: GovernanceResultSurfaceModel
|
||||
|
||||
Purpose:
|
||||
- Surface-specific read model delivered to a Filament page or resource section.
|
||||
|
||||
Core fields:
|
||||
- `primaryPattern`
|
||||
- The `OperatorExplanationPattern` for the page
|
||||
- `secondaryTruth`
|
||||
- Structured truth dimensions still shown by default but not as the headline
|
||||
- `diagnostics`
|
||||
- Raw or technical detail rendered secondarily
|
||||
- `actions`
|
||||
- Existing allowed actions for the surface
|
||||
|
||||
Relationships:
|
||||
- Used by Baseline Compare, Monitoring run detail, and selected governance artifact surfaces.
|
||||
|
||||
Validation / invariants:
|
||||
- The first visible screenful must answer: what happened, how reliable is it, why, and what next.
|
||||
- Diagnostics must remain available but not dominate primary reading order.
|
||||
|
||||
## Derived Concepts
|
||||
|
||||
### Explanation Family
|
||||
|
||||
Definition:
|
||||
- A reusable state family applied across domains, such as:
|
||||
- completed but degraded
|
||||
- completed but incomplete
|
||||
- no output because suppressed
|
||||
- no output because missing input
|
||||
- output exists but is not decision-grade
|
||||
|
||||
Rule:
|
||||
- The same family must map to the same primary reading direction and next-action category across reference surfaces.
|
||||
|
||||
### Trustworthiness Level
|
||||
|
||||
Definition:
|
||||
- The operator-facing confidence level for a produced result.
|
||||
|
||||
Allowed values in V1:
|
||||
- `trustworthy`
|
||||
- `limited_confidence`
|
||||
- `diagnostic_only`
|
||||
- `unusable`
|
||||
|
||||
Rule:
|
||||
- Trustworthiness must be visibly separate from execution outcome.
|
||||
|
||||
### Absent-Output Pattern
|
||||
|
||||
Definition:
|
||||
- The operator explanation class used when no normal result output exists.
|
||||
|
||||
Required distinctions in V1:
|
||||
- true no-issues result
|
||||
- missing input
|
||||
- blocked prerequisite
|
||||
- suppressed output
|
||||
- evaluation not yet available
|
||||
|
||||
Rule:
|
||||
- These states must not collapse into one generic `no results` message.
|
||||
247
specs/161-operator-explanation-layer/plan.md
Normal file
247
specs/161-operator-explanation-layer/plan.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Implementation Plan: 161 — Operator Explanation Layer
|
||||
|
||||
**Branch**: `161-operator-explanation-layer` | **Date**: 2026-03-23 | **Spec**: `specs/161-operator-explanation-layer/spec.md`
|
||||
**Input**: Feature specification from `specs/161-operator-explanation-layer/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce a shared operator explanation layer that turns degraded, partial, suppressed, and missing-input governance outcomes into one reusable reading model: what happened, how trustworthy the result is, why it looks this way, and what to do next. The implementation will extend the existing reason-translation, operator-outcome taxonomy, artifact-truth presentation, and baseline-compare stats infrastructure instead of inventing a parallel system, then apply that shared pattern first to Baseline Compare, governance-oriented Operation Run detail, Baseline Snapshot list/detail as baseline-capture result presentation, and the explicit reuse proof surfaces Tenant Review detail and Review Register.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (via Sail) plus existing read models persisted in application tables
|
||||
**Testing**: Pest v4 on PHPUnit 12
|
||||
**Target Platform**: Dockerized Laravel web application (Sail locally, Dokploy in deployment)
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: Preserve DB-only render behavior for Monitoring surfaces, add no render-time remote calls, and keep explanation rendering lightweight enough for existing list/detail surfaces
|
||||
**Constraints**:
|
||||
- No new Microsoft Graph contracts or render-time remote work
|
||||
- No change to `OperationRun` lifecycle ownership or feedback channels
|
||||
- No change to workspace or tenant authorization boundaries
|
||||
- Badge/state semantics must remain centralized under existing support layers
|
||||
- Diagnostics remain available but must become visually secondary on the affected surfaces
|
||||
**Scale/Scope**: Cross-cutting read-surface work touching shared support layers, one baseline reference page, canonical run-detail presentation, and at least one additional governance artifact surface, with focused updates to unit and feature tests rather than a platform-wide rollout in one slice
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS — this feature changes explanation of already-observed and already-produced governance data; it does not alter inventory as the last-observed source of truth.
|
||||
- Read/write separation: PASS — the feature is read-surface and presentation oriented. Existing mutating flows and confirmations remain unchanged.
|
||||
- Graph contract path: PASS — no new Graph calls or contract-registry additions are introduced.
|
||||
- Deterministic capabilities: PASS — no capability-derivation changes are introduced; existing registries remain canonical.
|
||||
- RBAC-UX: PASS — workspace-admin, tenant, and canonical Monitoring surfaces keep current 404/403 behavior and server-side checks.
|
||||
- Workspace isolation: PASS — no new workspace-context leakage is introduced; canonical surfaces remain tenant-safe.
|
||||
- RBAC confirmations: PASS — no new destructive actions are introduced.
|
||||
- Global search: PASS — no changes to global-search scope or visibility.
|
||||
- Tenant isolation: PASS — tenant-owned run and governance data remain entitlement-checked before disclosure.
|
||||
- Run observability: PASS — existing governance runs continue to use `OperationRun`; this feature layers interpretation on top rather than altering execution semantics.
|
||||
- Ops-UX 3-surface feedback: PASS — no new toasts, progress surfaces, or terminal notifications are introduced.
|
||||
- Ops-UX lifecycle: PASS — `OperationRun.status` and `OperationRun.outcome` remain service-owned.
|
||||
- Ops-UX summary counts: PASS — counts remain numeric execution metrics; this feature adds interpretation rules above them.
|
||||
- Ops-UX guards: PASS — focused regression tests can protect explanation behavior without relaxing existing CI guards.
|
||||
- Ops-UX system runs: PASS — unchanged.
|
||||
- Automation: PASS — no queue/idempotency behavior changes required in this slice.
|
||||
- Data minimization: PASS — diagnostics stay secondary and no new secret-bearing payload fields are introduced.
|
||||
- Badge semantics (BADGE-001): PASS — explanation states will consume `BadgeCatalog`, `BadgeRenderer`, and `OperatorOutcomeTaxonomy` rather than ad-hoc mappings.
|
||||
- UI naming (UI-NAMING-001): PASS — operator wording becomes more domain-first, not more implementation-first.
|
||||
- Operator surfaces (OPSURF-001): PASS — the entire feature exists to reinforce operator-first defaults and explicit diagnostics layering.
|
||||
- Filament UI Action Surface Contract: PASS — action topology remains largely unchanged; the primary refactor is read-surface explanation hierarchy.
|
||||
- Filament UI UX-001 (Layout & IA): PASS — affected pages continue to use structured sections; the feature adds deliberate explanation blocks and diagnostics grouping.
|
||||
- UI-STD-001 list-surface review: PASS — Review Register and any touched governance list surfaces are explicitly in scope for `docs/product/standards/list-surface-review-checklist.md` review.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/161-operator-explanation-layer/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── BaselineCompareLanding.php
|
||||
│ ├── Resources/
|
||||
│ │ ├── OperationRunResource.php
|
||||
│ │ ├── BaselineSnapshotResource.php
|
||||
│ │ ├── EvidenceSnapshotResource.php
|
||||
│ │ ├── TenantReviewResource.php
|
||||
│ │ └── ReviewPackResource.php
|
||||
│ └── System/
|
||||
├── Jobs/
|
||||
│ └── CompareBaselineToTenantJob.php
|
||||
├── Support/
|
||||
│ ├── Badges/
|
||||
│ ├── Baselines/
|
||||
│ ├── OpsUx/
|
||||
│ ├── ReasonTranslation/
|
||||
│ └── Ui/
|
||||
│ └── GovernanceArtifactTruth/
|
||||
├── Services/
|
||||
│ └── Baselines/
|
||||
resources/
|
||||
└── views/
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Baselines/
|
||||
│ ├── Filament/
|
||||
│ ├── Monitoring/
|
||||
│ └── ReasonTranslation/
|
||||
└── Unit/
|
||||
├── Badges/
|
||||
└── Support/
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (Laravel 12). The work stays within existing Filament pages/resources, support-layer presenters and translators, baseline compare services and jobs, Blade views where already used, and focused Pest coverage. No new top-level architectural area is needed.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this feature.
|
||||
|
||||
## Phase 0 — Outline & Research (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/161-operator-explanation-layer/research.md`
|
||||
|
||||
Key decisions captured:
|
||||
- Build on the existing `ReasonPresenter`, `ReasonTranslator`, `OperatorOutcomeTaxonomy`, `BadgeCatalog`, and `ArtifactTruthPresenter` layers rather than creating an isolated explanation subsystem.
|
||||
- Introduce a shared operator explanation pattern that explicitly separates execution outcome, evaluation meaning, reliability, coverage, and next action.
|
||||
- Formalize count-role semantics so empty-looking output counts cannot be misread as complete evaluation.
|
||||
- Make Baseline Compare the golden-path reference implementation, then apply the same pattern to Monitoring run detail, Baseline Snapshot result presentation, and the secondary proof surfaces Tenant Review detail plus Review Register.
|
||||
- Keep diagnostics available through the existing reason and artifact-truth infrastructure, but demote them behind primary explanation blocks.
|
||||
|
||||
## Phase 1 — Design & Contracts (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/161-operator-explanation-layer/data-model.md`
|
||||
- `specs/161-operator-explanation-layer/contracts/openapi.yaml`
|
||||
- `specs/161-operator-explanation-layer/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- The explanation layer is modeled as a reusable view-model contract rather than as a new persistence model.
|
||||
- `ReasonResolutionEnvelope` and `ArtifactTruthEnvelope` remain the core semantic inputs, but a new explanation pattern composes them into operator-facing sections and count semantics.
|
||||
- Count presentation is explicitly typed into execution counts, evaluation-output counts, and coverage or reliability counts so surfaces can explain why `0 findings` is not always healthy.
|
||||
- Governance run-detail and baseline compare read models are aligned on one reading order: outcome, trust statement, cause summary, next action, then diagnostics.
|
||||
- Adoption is intentionally phased: baseline compare first, then governance run detail plus baseline-capture result presentation, then Tenant Review detail and Review Register as the secondary reuse proof.
|
||||
|
||||
## Phase 1 — Agent Context Update (REQUIRED)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Constitution Check — Post-Design Re-evaluation
|
||||
|
||||
- PASS — the design remains read-surface focused and does not introduce new write paths, Graph calls, or authorization changes.
|
||||
- PASS — explanation and badge semantics stay centralized in existing support layers.
|
||||
- PASS — canonical Monitoring and tenant-scoped surfaces keep existing entitlement and run-observability rules.
|
||||
- PASS — no new action-surface or UX-001 exemptions are needed; the work fits the current structured layouts.
|
||||
|
||||
## Phase 2 — Implementation Plan
|
||||
|
||||
### Step 1 — Shared explanation pattern and semantic taxonomy extension
|
||||
|
||||
Goal: implement FR-001 through FR-009 and establish the reusable operator explanation model.
|
||||
|
||||
Changes:
|
||||
- Add a shared explanation-pattern view model or builder in the support layer that composes:
|
||||
- execution outcome
|
||||
- evaluation result
|
||||
- reliability or trust statement
|
||||
- coverage or completeness statement
|
||||
- recommended action category
|
||||
- Extend existing reason-translation outputs so reference surfaces can consume operator label, operator explanation, trustworthiness impact, and next-action guidance separately.
|
||||
- Define the count-role taxonomy for execution counts, evaluation-output counts, and coverage or reliability counts.
|
||||
- Define the shared absent-output and degraded-output explanation families required by the spec.
|
||||
|
||||
Tests:
|
||||
- Add or update unit coverage for explanation-pattern composition and count-role classification.
|
||||
- Extend reason-translation tests for operator-facing explanation outputs in degraded, missing-input, and suppressed cases.
|
||||
- Extend badge or taxonomy tests if any new centralized values are required.
|
||||
|
||||
### Step 2 — Baseline Compare golden-path adoption
|
||||
|
||||
Goal: implement FR-010, FR-014, FR-016, and FR-017 on the main motivating surface.
|
||||
|
||||
Changes:
|
||||
- Update baseline-compare stats or presenter outputs so the page receives a composed explanation pattern instead of raw reason-message-first content.
|
||||
- Refactor baseline compare surface rendering so the first visible block answers:
|
||||
- what happened
|
||||
- how trustworthy the result is
|
||||
- why it looks this way
|
||||
- what the operator should do next
|
||||
- Re-label or group counts according to the new count-role taxonomy.
|
||||
- Keep low-level evidence-gap payloads and reason-code detail in secondary diagnostics.
|
||||
|
||||
Tests:
|
||||
- Extend `BaselineCompareWhyNoFindingsReasonCodeTest` with operator-explanation assertions.
|
||||
- Add or update Filament or page-level tests verifying that `0 findings` with evidence gaps does not render as all-clear.
|
||||
- Add at least one suppressed-output and one true-no-findings comparison case.
|
||||
|
||||
### Step 3 — Governance run-detail and baseline-capture result alignment
|
||||
|
||||
Goal: implement FR-010 through FR-012 on canonical Monitoring run detail and Baseline Snapshot result presentation.
|
||||
|
||||
Changes:
|
||||
- Route governance-oriented `OperationRun` result presentation through the new explanation-pattern layer.
|
||||
- Align Monitoring run-detail sections with the same reading order used on baseline compare.
|
||||
- Ensure run outcome and result trust remain separate, non-contradictory visible statements.
|
||||
- Keep raw JSON, raw reason codes, and low-level counters clearly secondary.
|
||||
- Apply the same explanation pattern to Baseline Snapshot list/detail so baseline-capture result presentation exposes trustworthiness, dominant cause, and next action before low-level truth details.
|
||||
|
||||
Tests:
|
||||
- Extend `ArtifactTruthRunDetailTest` and `OperationRunBaselineTruthSurfaceTest` for explanation hierarchy and count semantics.
|
||||
- Add a regression asserting that a technically successful but limited-confidence run cannot render as plain success without caveat.
|
||||
- Add baseline-capture result surface coverage proving snapshot result presentation follows the same default-visible order.
|
||||
|
||||
### Step 4 — Secondary governance artifact adoption
|
||||
|
||||
Goal: satisfy FR-009 and SC-005 by proving reuse beyond baseline compare.
|
||||
|
||||
Changes:
|
||||
- Apply the same explanation pattern to Tenant Review detail and Review Register as the explicit secondary governance reuse proof for this slice.
|
||||
- Normalize visible wording so the same cause class uses the same explanation structure and next-action category.
|
||||
- Keep existing artifact-truth detail available, but demoted behind the new primary explanation section.
|
||||
- Review the touched list surface against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
Tests:
|
||||
- Add or update tenant-review and Review Register feature tests proving reuse of the same explanation pattern.
|
||||
- Extend governance artifact truth unit coverage if new envelope-to-explanation mapping rules are introduced.
|
||||
|
||||
### Step 5 — Focused regression and review safety
|
||||
|
||||
Goal: keep the rollout bounded and protect the reading model from future regressions.
|
||||
|
||||
Changes:
|
||||
- Add focused tests around count semantics, absent-output patterns, and degraded-result explanations.
|
||||
- Add focused RBAC regression tests for Baseline Compare, canonical Monitoring run detail, Baseline Snapshot result presentation, and Tenant Review detail so non-members remain 404 and members lacking capability remain 403.
|
||||
- Review list/detail surfaces touched by the feature against centralized badge and operator-surface rules.
|
||||
- Keep implementation localized to shared presenters and targeted surfaces rather than broad page-by-page copy edits.
|
||||
|
||||
Tests:
|
||||
- Run the focused baseline, Monitoring, reason-translation, and badge suites documented in `quickstart.md`.
|
||||
- Add one targeted regression proving diagnostics remain available but not primary on a reference surface.
|
||||
|
||||
## List Surface Review Notes
|
||||
|
||||
Reviewed against `docs/product/standards/list-surface-review-checklist.md`:
|
||||
|
||||
- Review Register: kept 7 visible columns, persisted filters/search/sort, clickable rows, and the existing two visible row actions while moving the operator explanation headline into the row description and keeping next-step wording wrapped and tenant-safe.
|
||||
- Baseline Snapshots list: preserved resource-table persistence, clickable rows, badge-backed truth/lifecycle columns, and efficient default sorting while shifting explanation-first copy into the artifact-truth description plus next-step column without adding new relation-heavy query work.
|
||||
- Tenant Reviews list: preserved tenant-scoped clickable rows, existing header/empty-state/action topology, and badge-backed truth/publication columns while reusing the shared explanation headline and next-action wording in row descriptions and next-step cells.
|
||||
58
specs/161-operator-explanation-layer/quickstart.md
Normal file
58
specs/161-operator-explanation-layer/quickstart.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Quickstart: Operator Explanation Layer
|
||||
|
||||
## Goal
|
||||
|
||||
Implement and verify a shared operator explanation layer so degraded, partial, suppressed, and missing-input governance results become understandable without opening diagnostics.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
1. Add the shared explanation-pattern builder in the support layer.
|
||||
2. Extend reason translation outputs with trust-impact and next-action semantics where missing.
|
||||
3. Define count-role taxonomy and wire it into baseline compare stats or presenters.
|
||||
4. Refactor Baseline Compare to render the explanation pattern before diagnostics.
|
||||
5. Refactor governance-oriented Operation Run detail and Baseline Snapshot result presentation to render the same reading order.
|
||||
6. Apply the pattern to Tenant Review detail and Review Register to prove reuse.
|
||||
7. Add focused regression coverage for degraded, suppressed, absent-output, and authorization-preservation cases.
|
||||
8. Run focused tests and format changed files.
|
||||
|
||||
## Focused verification commands
|
||||
|
||||
Start services if needed:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
Run the most relevant focused suites:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Badges/GovernanceArtifactTruthTest.php
|
||||
```
|
||||
|
||||
Run any new focused explanation or reason-translation tests added during implementation:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=Explanation
|
||||
vendor/bin/sail artisan test --compact --filter=ReasonTranslation
|
||||
```
|
||||
|
||||
Format changed files:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
1. Open Baseline Compare with a case that has `0 findings` and evidence gaps, and verify the page reads as incomplete or limited rather than healthy.
|
||||
2. Open a Baseline Snapshot result surface after capture and verify the primary explanation, trustworthiness statement, and next action appear before low-level truth details.
|
||||
3. Open a governance Operation Run detail page for a technically successful but limited-confidence result, and verify run completion and result trust are clearly separated.
|
||||
4. Open a case with missing input or suppressed output and verify the primary explanation says why no normal result exists before diagnostics.
|
||||
5. Confirm raw JSON, raw reason codes, and low-level counters are still reachable but visually secondary.
|
||||
6. Confirm Tenant Review detail and Review Register reuse the same explanation family and next-action wording.
|
||||
57
specs/161-operator-explanation-layer/research.md
Normal file
57
specs/161-operator-explanation-layer/research.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Research: Operator Explanation Layer
|
||||
|
||||
## Decision 1: Reuse the existing reason-translation and artifact-truth stack as the substrate
|
||||
|
||||
- Decision: Build the explanation layer on top of `ReasonPresenter`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `OperatorOutcomeTaxonomy`, `BadgeCatalog`, and `ArtifactTruthPresenter` instead of introducing a parallel explanation subsystem.
|
||||
- Rationale: The repo already contains the core pieces needed for domain-safe wording, centralized badge semantics, and multi-dimensional artifact truth. The missing layer is composition and reading order, not a lack of semantic primitives.
|
||||
- Alternatives considered:
|
||||
- New standalone explanation subsystem disconnected from artifact truth. Rejected because it would duplicate semantics and drift from the existing taxonomy.
|
||||
- Page-local explanation logic only. Rejected because the spec explicitly targets a shared cross-domain pattern.
|
||||
|
||||
## Decision 2: Model explanation as a reusable view-model contract, not a persistence model
|
||||
|
||||
- Decision: Introduce a shared operator explanation pattern as a composed read model that separates execution outcome, evaluation result, reliability, coverage, and next action.
|
||||
- Rationale: The feature changes interpretation of already-produced outcomes. No new persistence model is required because the necessary data already exists in `OperationRun`, artifact-truth envelopes, reason translation, and compare stats.
|
||||
- Alternatives considered:
|
||||
- Add new database columns to store explanation states. Rejected because the problem is presentation and composition, not missing canonical storage.
|
||||
- Encode explanation purely in Blade or Filament page code. Rejected because the same pattern must be reused across multiple domains.
|
||||
|
||||
## Decision 3: Formalize count-role semantics so empty-looking results cannot imply health
|
||||
|
||||
- Decision: Define three count roles for reference surfaces: execution counts, evaluation-output counts, and coverage or reliability counts.
|
||||
- Rationale: The motivating failure case is not that counts are absent, but that counts with different meanings are shown side by side without explanation. Explicit count roles prevent `0 findings` from being interpreted as complete evaluation when evidence or coverage was limited.
|
||||
- Alternatives considered:
|
||||
- Hide counts in degraded cases. Rejected because operators still need the numbers, just with the right explanation.
|
||||
- Keep current counts and add only warning badges. Rejected because this preserves the same ambiguity under a different visual wrapper.
|
||||
|
||||
## Decision 4: Make Baseline Compare the golden-path reference implementation
|
||||
|
||||
- Decision: Use Baseline Compare as the first implementation surface for the shared explanation layer, then align Monitoring run detail and one additional governance artifact family.
|
||||
- Rationale: The spec and existing candidate text both identify Baseline Compare as the clearest motivating case. The current `why no findings` path, evidence-gap counts, and coverage status already expose the problem vividly and provide a bounded proving ground.
|
||||
- Alternatives considered:
|
||||
- Start with a generic governance artifact detail page only. Rejected because the main trust problem is easiest to verify on baseline compare.
|
||||
- Start platform-wide on every governance surface. Rejected because the spec is intentionally a reference-surface rollout, not a monolithic redesign.
|
||||
|
||||
## Decision 5: Keep diagnostics available but always secondary
|
||||
|
||||
- Decision: Preserve raw JSON, raw reason codes, low-level counters, and support metadata, but move them behind primary explanation blocks on the affected surfaces.
|
||||
- Rationale: The constitution and existing product direction still require rich diagnostics for support, audit, and advanced troubleshooting. The operator problem is not that diagnostics exist; it is that diagnostics currently dominate the default reading path.
|
||||
- Alternatives considered:
|
||||
- Remove raw reason codes from the UI entirely. Rejected because support and audit workflows still need them.
|
||||
- Leave diagnostics in place and only add a short summary above them. Rejected because that often leaves the surface visually dominated by technical details.
|
||||
|
||||
## Decision 6: Extend reason translation with trustworthiness and next-action semantics instead of relying on message strings
|
||||
|
||||
- Decision: Treat domain reason codes as inputs to a richer explanation contract that includes operator label, operator explanation, trustworthiness impact, and next-action category.
|
||||
- Rationale: Baseline compare currently exposes reason-code messages that are diagnostically useful but still too implementation-first. The explanation layer needs semantically structured outputs rather than a single message string.
|
||||
- Alternatives considered:
|
||||
- Keep using enum `.message()` methods as the primary explanation. Rejected because this is the current limitation.
|
||||
- Hardcode next actions in each page. Rejected because the same cause class must read consistently across surfaces.
|
||||
|
||||
## Decision 7: Route governance run detail through the same explanation reading order as artifact surfaces
|
||||
|
||||
- Decision: Apply the shared explanation pattern to governance-oriented Monitoring run detail so run pages and artifact pages answer the same operator questions in the same order.
|
||||
- Rationale: The spec is explicitly cross-surface. If run detail keeps one reading model and baseline compare or artifact detail another, the same truth divergence problem reappears during drilldown.
|
||||
- Alternatives considered:
|
||||
- Limit the feature to baseline compare only. Rejected because the spec requires run-detail adoption as part of the first slice.
|
||||
- Let run detail depend only on status and outcome badges. Rejected because that is insufficient for trust and absent-output interpretation.
|
||||
152
specs/161-operator-explanation-layer/spec.md
Normal file
152
specs/161-operator-explanation-layer/spec.md
Normal file
@ -0,0 +1,152 @@
|
||||
# Feature Specification: Operator Explanation Layer for Degraded, Partial, and Suppressed Results
|
||||
|
||||
**Feature Branch**: `161-operator-explanation-layer`
|
||||
**Created**: 2026-03-23
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Operator Explanation Layer for Degraded / Partial / Suppressed Results"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace, tenant, canonical-view
|
||||
- **Primary Routes**: `/admin/t/{tenant}/baseline-compare`, governance artifact detail pages under `/admin`, governance list surfaces under `/admin`, and Monitoring → Operations → Run Detail for governance-oriented runs
|
||||
- **Data Ownership**: Workspace-owned records keep their existing ownership, including baseline snapshots, evidence artifacts, tenant reviews, and review-pack outputs. Tenant-owned `OperationRun` records and tenant-scoped governance results remain tenant-owned. This feature changes how those records are explained, not who owns them.
|
||||
- **RBAC**: Existing workspace membership, tenant membership, and capability checks remain authoritative. This spec changes operator-facing explanation and information hierarchy, not membership boundaries.
|
||||
- **Reference rollout surfaces for this slice**: Baseline Compare, Monitoring → Run Detail for governance operations, Baseline Snapshot list/detail as baseline-capture result presentation, Tenant Review detail, and Review Register list rows
|
||||
- **List Surface Review Standard**: Because this feature changes Review Register and baseline- or governance-oriented list surfaces, implementation and review MUST follow `docs/product/standards/list-surface-review-checklist.md`.
|
||||
- **Default filter behavior when tenant-context is active**: Canonical Monitoring and governance read surfaces that already support tenant context MUST continue to prefilter to the active tenant when tenant context is selected.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Any canonical or workspace-context surface that reveals tenant-owned run or governance results MUST continue to enforce workspace entitlement first and tenant entitlement second, with deny-as-not-found behavior for non-members.
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline Compare | Tenant operator | Tenant-scoped action page | Did the compare produce a trustworthy result, and if not, why not? | Primary explanation, result reliability, coverage/completeness signal, next action, clearly scoped counts | Raw reason codes, evidence-gap payloads, low-level context, internal suppression details | execution outcome, evaluation result, reliability, coverage, recommended action | Simulation only | Compare now, View findings, View run | None |
|
||||
| Monitoring → Run Detail for governance operations | Workspace manager or entitled tenant operator | Canonical detail | What happened, how trustworthy is the result, and what should I do next? | Outcome summary, explanation summary, result trust statement, next action, operator-safe count meaning | Raw JSON, low-level context, internal reason-code detail, implementation-first counters | execution outcome, evaluation result, reliability, coverage, readiness | TenantPilot only or simulation only depending on run type | View related artifact, View related surface | None |
|
||||
| Baseline capture result presentation | Workspace manager | List/detail | Did baseline capture produce a trustworthy baseline artifact, and if not, why not? | Primary state, trustworthiness statement, capture-result explanation, next action | Full truth envelope, renderer details, raw cause detail, low-level support metadata | lifecycle, usability, completeness, recommended action | TenantPilot only | View related run, inspect snapshot | None |
|
||||
| Tenant Review detail | Workspace manager | Detail | Is this review usable, why or why not, and what follow-up is needed? | Primary state, short explanation, trustworthiness or publishability statement, next action | Full truth envelope, support metadata, source diagnostics | lifecycle or readiness, usability, completeness, publication status, recommended action | TenantPilot only | View related run, continue workflow action when already allowed | Existing destructive actions remain unchanged and separately governed |
|
||||
| Review Register list rows | Workspace manager | List | Which reviews need attention, and which are genuinely ready? | One primary operator statement, brief reason, next-step hint, semantically safe counts | Raw badges for every semantic axis, low-level reason codes, detailed fidelity sub-axes | primary outcome, trustworthiness, actionability | None | Filter, inspect, open detail | None |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Understand degraded results without diagnostics (Priority: P1)
|
||||
|
||||
An operator reviewing a governance result needs the product to explain a degraded, partial, or suppressed outcome in plain operator language without requiring JSON, internal codes, or product-specific background knowledge.
|
||||
|
||||
**Why this priority**: This is the central product problem. If operators still need diagnostics to understand whether a result is trustworthy, the feature has not delivered its value.
|
||||
|
||||
**Independent Test**: Present a governance result where execution completed but evaluation was limited, then confirm the operator can determine what happened, how trustworthy the result is, and the next action from the default-visible content alone.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline compare run that finished technically but could not fully evaluate due to evidence gaps, **When** an operator opens the compare surface or run detail, **Then** the page states that evaluation was incomplete, explains the dominant cause in operator language, and does not let `0 findings` read as an all-clear.
|
||||
2. **Given** a governance result that produced no output because inputs were missing or suppressed, **When** an operator opens the affected surface, **Then** the page explains why no result was produced and what follow-up is appropriate before showing diagnostics.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Separate execution success from result trust (Priority: P2)
|
||||
|
||||
An operator needs the product to distinguish a technically finished run from the trustworthiness and completeness of the result it produced.
|
||||
|
||||
**Why this priority**: The most damaging false-green cases come from execution success being misread as trustworthy outcome.
|
||||
|
||||
**Independent Test**: Review multiple reference cases where execution and result trust diverge, then verify the surface keeps those dimensions separate and non-contradictory.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a run that completed successfully but produced a limited-confidence artifact, **When** an operator views the run or related artifact, **Then** execution success and result trust are shown as separate statements with no conflicting headline.
|
||||
2. **Given** a run that failed after producing partial intermediate data, **When** an operator views the result, **Then** the surface makes clear that the run failed and the partial data is not decision-grade.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Reuse one explanation pattern across domains (Priority: P3)
|
||||
|
||||
A workspace manager needs degraded, suppressed, and incomplete states to read consistently across baseline, evidence, review, and governance monitoring surfaces.
|
||||
|
||||
**Why this priority**: Local one-off explanations create a new inconsistency problem even if each surface improves individually.
|
||||
|
||||
**Independent Test**: Compare the same cause category on the baseline reference surfaces and on a second governance domain surface, then confirm the primary explanation pattern and next-step language stay aligned.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** two different governance surfaces that both represent missing-input or insufficient-evidence states, **When** an operator reads them, **Then** both use the same explanation tiering and the same reading direction for reliability and next action.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A result shows `0 findings`, but evidence coverage is incomplete. The surface must not read as healthy by default.
|
||||
- A run is technically successful, but the produced artifact is not trustworthy or not publishable. The headline and follow-up guidance must not conflict.
|
||||
- Multiple causes contribute to one degraded result. The surface must identify the dominant cause without hiding that other causes exist.
|
||||
- No result exists because the system intentionally suppressed output. The surface must distinguish suppression from failure and from true absence of issues.
|
||||
- Diagnostics are unavailable or delayed. The primary explanation still needs to remain understandable.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls, new mutation flows, or new long-running jobs. It changes how existing governance and monitoring surfaces explain already produced outcomes and artifacts. Existing contract-registry, safety-gate, audit, and tenant-isolation rules remain unchanged.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing `OperationRun` creation and lifecycle rules remain unchanged. Governance runs continue to use the existing three feedback surfaces only. `OperationRun.status` and `OperationRun.outcome` remain service-owned. Summary counts remain numeric and execution-oriented; this feature adds meaning and interpretation layers on top of them rather than redefining run lifecycle.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature touches workspace-admin, tenant, and canonical Monitoring surfaces, but does not change authorization semantics. Non-members still receive 404. Members lacking capability still receive 403. All existing server-side authorization remains required for any action that starts an operation or reveals tenant-owned governance data.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that no auth-handshake behavior is introduced.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Any new or changed badges, labels, or severity treatments for degraded, partial, suppressed, incomplete, trustworthy, limited-confidence, or unusable states MUST remain centralized. No surface may invent local color or label mappings for the same semantic state.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing language MUST prioritize domain meaning over implementation terms. Primary wording must explain what happened, how reliable the result is, and what to do next. Internal reason codes and implementation-first terms may remain available only in diagnostics.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content on affected operator surfaces MUST remain operator-first and diagnostics-second. The default reading path must be: what happened, how trustworthy the result is, why it looks this way, what to do next. Raw JSON, internal codes, and low-level payload details remain secondary.
|
||||
|
||||
**Constitution alignment (UI-STD-001):** Because this feature changes Review Register and other governance-oriented list surfaces, implementation and review MUST use `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature materially refactors several Filament-facing read surfaces but does not introduce new destructive actions. The Action Surface Contract remains satisfied because the main change is explanation hierarchy, count semantics, and status presentation. Existing action topology remains in place unless a follow-up spec changes it explicitly.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Affected screens MUST preserve structured, sectioned layouts. New explanation blocks, trust statements, and next-step summaries must appear as deliberate information sections rather than scattered helper text.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST define a shared operator explanation model that separates at minimum these semantic axes wherever relevant: execution outcome, evaluation result, reliability or trustworthiness, coverage or completeness, and recommended action.
|
||||
- **FR-002**: The system MUST provide a primary operator explanation for degraded, partial, suppressed, missing-input, and incomplete-result cases that can be understood without opening diagnostics.
|
||||
- **FR-003**: The system MUST ensure technical reason codes and raw diagnostics remain available for troubleshooting but are not the primary headline or default explanation on affected operator surfaces.
|
||||
- **FR-004**: The system MUST define semantically safe count rules so output counts, evaluation counts, and completeness or reliability signals cannot be misread as the same thing.
|
||||
- **FR-005**: The system MUST prevent `0 findings`, `0 issues`, `no results`, or similarly empty-looking result summaries from reading as implicit all-clear when evaluation was limited, suppressed, or incomplete.
|
||||
- **FR-006**: The system MUST define a reusable explanation pattern for absent-output cases that distinguishes at minimum: true no-issues results, missing required input, suppressed output, blocked prerequisite state, and not-yet-available evaluation.
|
||||
- **FR-007**: The system MUST define a reusable explanation pattern for technically finished but decision-limited cases where output exists or execution completed, but the produced result is only partially trustworthy, incomplete, or diagnostically useful rather than decision-grade.
|
||||
- **FR-008**: The system MUST define next-step guidance categories that are semantically derived from the cause class, including at minimum: no action needed, observe, retry later, fix prerequisite, refresh or sync data, review evidence gaps, manually validate, and escalate.
|
||||
- **FR-009**: The system MUST ensure the same underlying cause class is rendered with the same primary reading direction across all reference surfaces in scope, even when the surrounding domain differs.
|
||||
- **FR-010**: The system MUST implement the explanation layer first on these reference surfaces for this slice: Baseline Compare, Monitoring → Operation Run Detail for governance runs, and Baseline Snapshot list/detail as baseline-capture result presentation.
|
||||
- **FR-011**: The system MUST preserve diagnostics as a clearly secondary layer on reference surfaces, with the primary operator explanation visible before raw JSON, raw reason codes, or implementation-first counters.
|
||||
- **FR-012**: The system MUST ensure the top-level state presented on a reference surface never contradicts the explanation shown beneath it. A technically successful run with a limited-confidence result must read as a consistent composite rather than as a success headline plus buried caveat.
|
||||
- **FR-013**: The system MUST define one shared explanation-pattern library or registry that implements FR-006 and FR-007 and additionally covers repeated state families such as completed but degraded, completed but incomplete, no output because suppressed, no output because missing input, and output exists but is not yet publishable or decision-grade.
|
||||
- **FR-014**: The system MUST ensure baseline compare is the reference proof point for this model, including the motivating case where no findings are shown because evidence coverage was incomplete.
|
||||
- **FR-015**: The system MUST preserve existing RBAC boundaries, context scoping, and action gating while changing explanation language and information hierarchy.
|
||||
- **FR-016**: The system MUST provide regression coverage for at least one reference case in which execution success, result trustworthiness, and output counts intentionally diverge.
|
||||
- **FR-017**: The system MUST provide regression coverage for at least one absent-output case and one suppressed-output case so those states cannot silently fall back to generic empty or all-clear language.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- This spec defines the shared explanation layer and reference implementations, not the final rollout to every governance surface in one shipment.
|
||||
- Existing outcome taxonomy, reason-code translation, and artifact-truth foundations remain the semantic source of truth that this feature consumes.
|
||||
- Diagnostics remain important for support and audit, but normal operators should not need them for first-pass interpretation.
|
||||
- This feature may reuse existing surfaces and components, but it does not require a full redesign of every governance page.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline Compare | Existing tenant-scoped baseline compare surface | Existing `Compare now` remains | Not applicable | None | None | Existing empty or blocked CTA remains, but explanation must distinguish true no-data from blocked or incomplete states | Existing run or related-record actions remain | Not applicable | Yes, via existing run flow | No new dangerous action is introduced. The change is explanation hierarchy and count meaning. |
|
||||
| Governance run detail | Existing Monitoring run detail surface | Existing related-record actions remain | Not applicable | Not applicable | Not applicable | Not applicable | Existing related navigation remains | Not applicable | Yes, via existing run and audit semantics | No new mutations. This spec changes what is shown first and how diagnostics are demoted. |
|
||||
| Governance artifact detail surfaces | Existing baseline, evidence, review, and related detail pages | Existing domain-specific actions remain | Existing inspect affordances remain | Existing row actions remain unchanged | Existing bulk behavior unchanged | Existing empty-state CTA remains where already defined | Existing detail-header actions remain | Existing save and cancel unchanged where edit forms already exist | Existing audit behavior unchanged | Explanation sections are added or re-ordered; action topology is unchanged in this spec. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Operator Explanation Pattern**: A reusable interpretation pattern that turns an internal state family into operator-readable meaning, trust guidance, and next action.
|
||||
- **Governance Result**: Any governance-facing outcome whose execution state, evaluation meaning, and trustworthiness can diverge, including compare results, capture results, review outputs, and evidence-derived outputs.
|
||||
- **Diagnostic Context**: The secondary technical detail layer containing raw reason codes, JSON payloads, low-level counters, or support facts that remain available but not dominant.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: On Baseline Compare, Monitoring → Run Detail for governance operations, Baseline Snapshot result surfaces, and Tenant Review detail, an operator can determine from default-visible content whether the result is trustworthy, limited, or unusable without opening diagnostics.
|
||||
- **SC-002**: On Baseline Compare and Baseline Snapshot result surfaces, cases with incomplete evaluation, suppressed output, or missing-input blocks no longer allow empty-looking counts or `0 findings` summaries to read as implicit all-clear.
|
||||
- **SC-003**: The same cause class renders the same primary explanation structure and same next-step category across Baseline Compare, Baseline Snapshot result presentation, Tenant Review detail, and Review Register list rows.
|
||||
- **SC-004**: On Baseline Compare, Monitoring → Run Detail for governance operations, and Baseline Snapshot detail, the default-visible section order is: primary explanation, trustworthiness statement, dominant cause summary, and next action, before any diagnostics panels, raw JSON blocks, or low-level metadata sections.
|
||||
- **SC-005**: The explanation layer is reusable enough that Tenant Review detail and Review Register can adopt the same pattern without inventing new primary terminology for degraded or suppressed states.
|
||||
217
specs/161-operator-explanation-layer/tasks.md
Normal file
217
specs/161-operator-explanation-layer/tasks.md
Normal file
@ -0,0 +1,217 @@
|
||||
# Tasks: Operator Explanation Layer
|
||||
|
||||
**Input**: Design documents from `/specs/161-operator-explanation-layer/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/openapi.yaml, quickstart.md
|
||||
|
||||
**Tests**: Tests are REQUIRED because this feature changes runtime behavior on governance read surfaces, reason translation, artifact-truth presentation, and operator-facing interpretation.
|
||||
**Operations**: This feature does not introduce new long-running operations. Existing governance `OperationRun` flows remain canonical, and tasks below preserve queued-only toast behavior, progress-only active surfaces, terminal `OperationRunCompleted`, service-owned run transitions, and numeric-only `summary_counts`.
|
||||
**RBAC**: Existing workspace, tenant, and canonical Monitoring authorization behavior is preserved. Non-members remain 404, members without capability remain 403, and no new destructive actions are introduced.
|
||||
**Badges**: Any changed status-like semantics must stay centralized in `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/OperatorOutcomeTaxonomy.php`, and related support classes; no ad-hoc Filament mappings are allowed.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Create the reusable explanation-layer scaffolding before story-specific adoption begins.
|
||||
|
||||
- [X] T001 Create the core operator explanation value object in `app/Support/Ui/OperatorExplanation/OperatorExplanationPattern.php`
|
||||
- [X] T002 [P] Create the count descriptor value object in `app/Support/Ui/OperatorExplanation/CountDescriptor.php`
|
||||
- [X] T003 [P] Add reusable explanation test fixtures in `tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Add the shared semantic and translation infrastructure required by all user stories.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start until this phase is complete.
|
||||
|
||||
- [X] T004 Create shared explanation-family and trustworthiness enums in `app/Support/Ui/OperatorExplanation/ExplanationFamily.php` and `app/Support/Ui/OperatorExplanation/TrustworthinessLevel.php`
|
||||
- [X] T005 [P] Extend translated reason envelopes with trust-impact and absence-pattern fields in `app/Support/ReasonTranslation/ReasonResolutionEnvelope.php`
|
||||
- [X] T006 [P] Add the shared explanation builder in `app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php`
|
||||
- [X] T007 [P] Create the baseline compare explanation registry in `app/Support/Baselines/BaselineCompareExplanationRegistry.php`
|
||||
- [X] T008 [P] Extend baseline compare reason semantics in `app/Support/Baselines/BaselineCompareReasonCode.php` and `app/Support/Baselines/BaselineReasonCodes.php`
|
||||
- [X] T009 [P] Register centralized outcome and badge semantics for explanation-layer states in `app/Support/Badges/OperatorOutcomeTaxonomy.php` and `app/Support/Badges/BadgeCatalog.php`
|
||||
- [X] T010 Add foundational unit and translation coverage in `tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php` and `tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`
|
||||
|
||||
**Checkpoint**: Shared explanation composition, reason enrichment, and badge semantics are ready for story-level adoption.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Understand Degraded Results Without Diagnostics (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Baseline Compare explains degraded, partial, suppressed, and no-result states in operator language without requiring diagnostics.
|
||||
|
||||
**Independent Test**: Open Baseline Compare for a case with `0 findings` and evidence gaps, then verify the page explains incomplete evaluation, result trust, and next action from default-visible content alone.
|
||||
|
||||
### Tests for User Story 1 ⚠️
|
||||
|
||||
> **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
|
||||
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('returns 404 for non-members on the baseline compare explanation surface', function (): void {
|
||||
[$member, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$nonMember = User::factory()->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();
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows an unavailable explanation before any baseline compare result exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->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 ?? '');
|
||||
});
|
||||
@ -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',
|
||||
],
|
||||
]);
|
||||
|
||||
67
tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php
Normal file
67
tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Concerns;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthCause;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
trait BuildsOperatorExplanationFixtures
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('renders baseline-capture result surfaces with explanation-first artifact truth', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->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 ?? '');
|
||||
});
|
||||
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders suppressed baseline-compare results as explanation-first output instead of an implicit all-clear', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->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');
|
||||
});
|
||||
@ -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.');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
it('renders blocked governance runs with explanation-first fallback copy when no artifact record exists', function (): void {
|
||||
$tenant = Tenant::factory()->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');
|
||||
});
|
||||
@ -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 () {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
it('exposes trust-impact and absence-pattern semantics for governance reasons', function (
|
||||
string $reasonCode,
|
||||
string $expectedTrustImpact,
|
||||
?string $expectedAbsencePattern,
|
||||
): void {
|
||||
$envelope = app(ReasonPresenter::class)->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',
|
||||
],
|
||||
]);
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
it('reuses the same operator explanation on tenant review detail and review register surfaces', 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.'],
|
||||
],
|
||||
);
|
||||
|
||||
$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 ?? '');
|
||||
});
|
||||
@ -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))
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
it('reuses governance operator explanations for run failure detail and next-step guidance', function (): void {
|
||||
$tenant = Tenant::factory()->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);
|
||||
});
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
use Tests\Feature\Concerns\BuildsOperatorExplanationFixtures;
|
||||
|
||||
uses(BuildsOperatorExplanationFixtures::class);
|
||||
|
||||
it('maps blocked artifact truth into an explanation-first pattern', function (): void {
|
||||
$reason = $this->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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user