Compare commits
1 Commits
dev
...
156-operat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f11b28e04 |
7
.github/agents/copilot-instructions.md
vendored
7
.github/agents/copilot-instructions.md
vendored
@ -97,9 +97,6 @@ ## Active Technologies
|
|||||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||||
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||||
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
|
||||||
- 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 (158-artifact-truth-semantics)
|
|
||||||
- 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -119,8 +116,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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
|
|
||||||
- 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
|
||||||
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||||
|
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||||
|
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -8,13 +8,10 @@
|
|||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -90,9 +87,6 @@ public function mount(): void
|
|||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
|
||||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
'tenant_id' => (int) $snapshot->tenant_id,
|
||||||
@ -101,21 +95,7 @@ public function mount(): void
|
|||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||||
'artifact_truth' => [
|
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
||||||
'label' => $truth->primaryLabel,
|
|
||||||
'color' => $truth->primaryBadgeSpec()->color,
|
|
||||||
'icon' => $truth->primaryBadgeSpec()->icon,
|
|
||||||
'explanation' => $truth->primaryExplanation,
|
|
||||||
],
|
|
||||||
'freshness' => [
|
|
||||||
'label' => $freshnessSpec->label,
|
|
||||||
'color' => $freshnessSpec->color,
|
|
||||||
'icon' => $freshnessSpec->icon,
|
|
||||||
],
|
|
||||||
'next_step' => $truth->nextStepText(),
|
|
||||||
'view_url' => $snapshot->tenant
|
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
|
||||||
: null,
|
|
||||||
];
|
];
|
||||||
})->all();
|
})->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
@ -170,16 +169,21 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||||
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
$reasonCode = data_get($context, 'reason_code');
|
||||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
|
||||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
];
|
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||||
|
$message = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.';
|
||||||
|
$guidance = OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Blocked by prerequisite',
|
'title' => 'Blocked by prerequisite',
|
||||||
'body' => implode(' ', $lines),
|
'body' => sprintf('%s Reason code: %s. %s', $message, $reasonCode, $guidance),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -115,15 +114,6 @@ public function table(Table $table): Table
|
|||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||||
TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
|
||||||
->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)
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('completeness_state')
|
TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -133,29 +123,15 @@ public function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('publication_truth')
|
TextColumn::make('summary.publish_blockers')
|
||||||
->label('Publication')
|
->label('Publish blockers')
|
||||||
->badge()
|
->formatStateUsing(static function (mixed $state): string {
|
||||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
if (! is_array($state) || $state === []) {
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
return '0';
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
}
|
||||||
)->label)
|
|
||||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
return (string) count($state);
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
}),
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->color)
|
|
||||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->icon)
|
|
||||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->iconColor),
|
|
||||||
TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('tenant_id')
|
SelectFilter::make('tenant_id')
|
||||||
|
|||||||
@ -2882,12 +2882,9 @@ public function startVerification(): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -21,8 +21,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -170,23 +168,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Captured')
|
->label('Captured')
|
||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
|
|
||||||
->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)
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('fidelity_summary')
|
TextColumn::make('fidelity_summary')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('snapshot_state')
|
TextColumn::make('snapshot_state')
|
||||||
->label('State')
|
->label('State')
|
||||||
->badge()
|
->badge()
|
||||||
@ -379,9 +364,4 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
|
|||||||
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,8 +28,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -135,15 +133,6 @@ public static function form(Schema $schema): Schema
|
|||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->schema([
|
return $schema->schema([
|
||||||
Section::make('Artifact truth')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('artifact_truth')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.governance-artifact-truth')
|
|
||||||
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Snapshot')
|
Section::make('Snapshot')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -225,15 +214,6 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
|
|
||||||
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
|
||||||
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
|
||||||
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
|
||||||
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -245,10 +225,6 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
@ -612,11 +588,6 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
|||||||
return $label === 'Unknown' ? null : $label;
|
return $label === 'Unknown' ? null : $label;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function stringifySummaryValue(mixed $value): string
|
private static function stringifySummaryValue(mixed $value): string
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
|
|||||||
@ -25,14 +25,12 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -289,15 +287,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
$factory->viewSection(
|
|
||||||
id: 'artifact_truth',
|
|
||||||
kind: 'current_status',
|
|
||||||
title: 'Artifact truth',
|
|
||||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
|
||||||
viewData: ['artifactTruthState' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
|
|
||||||
visible: $record->isGovernanceArtifactOperation(),
|
|
||||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
|
||||||
),
|
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'related_context',
|
id: 'related_context',
|
||||||
kind: 'related_context',
|
kind: 'related_context',
|
||||||
@ -480,13 +469,8 @@ private static function blockedExecutionReasonCode(OperationRun $record): ?strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
return $reasonEnvelope->operatorLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||||
?? data_get($context, 'reason_code')
|
?? data_get($context, 'reason_code')
|
||||||
?? data_get($record->failure_summary, '0.reason_code');
|
?? data_get($record->failure_summary, '0.reason_code');
|
||||||
@ -500,12 +484,6 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
return $reasonEnvelope->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = data_get($record->failure_summary, '0.message');
|
$message = data_get($record->failure_summary, '0.message');
|
||||||
|
|
||||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
||||||
|
|||||||
@ -824,12 +824,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -924,12 +921,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -1021,12 +1015,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
|
|||||||
@ -278,12 +278,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -650,12 +647,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -764,12 +758,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
|
|||||||
@ -10,7 +10,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -20,14 +19,11 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -115,15 +111,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Artifact truth')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('artifact_truth')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.governance-artifact-truth')
|
|
||||||
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Status')
|
Section::make('Status')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -251,15 +238,6 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
|
|
||||||
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
|
||||||
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
|
||||||
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
|
||||||
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('tenant.name')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -279,29 +257,6 @@ public static function table(Table $table): Table
|
|||||||
->label('Size')
|
->label('Size')
|
||||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('publication_truth')
|
|
||||||
->label('Publication')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->label)
|
|
||||||
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->color)
|
|
||||||
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->icon)
|
|
||||||
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->iconColor),
|
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Created')
|
->label('Created')
|
||||||
->since()
|
->since()
|
||||||
@ -397,11 +352,6 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -608,12 +608,9 @@ public static function table(Table $table): Table
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
@ -911,20 +908,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
||||||
Section::make('RBAC Details')
|
Section::make('RBAC Details')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason_label')
|
|
||||||
->label('Reason')
|
|
||||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
|
||||||
->primaryLabel(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
|
||||||
->visible(fn (?string $state): bool => filled($state)),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason_explanation')
|
|
||||||
->label('Explanation')
|
|
||||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
|
||||||
->shortExplanation(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
|
||||||
->visible(fn (?string $state): bool => filled($state))
|
|
||||||
->columnSpanFull(),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason')
|
Infolists\Components\TextEntry::make('rbac_status_reason')
|
||||||
->label('Diagnostic code')
|
->label('Reason'),
|
||||||
->copyable(),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
||||||
->label('Role definition ID')
|
->label('Role definition ID')
|
||||||
->copyable(),
|
->copyable(),
|
||||||
|
|||||||
@ -178,12 +178,9 @@ protected function getHeaderActions(): array
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -28,8 +28,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -145,15 +143,6 @@ public static function form(Schema $schema): Schema
|
|||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->schema([
|
return $schema->schema([
|
||||||
Section::make('Artifact truth')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('artifact_truth')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.governance-artifact-truth')
|
|
||||||
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Review')
|
Section::make('Review')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -250,15 +239,6 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
|
|
||||||
->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)
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
Tables\Columns\TextColumn::make('completeness_state')
|
||||||
->label('Completeness')
|
->label('Completeness')
|
||||||
->badge()
|
->badge()
|
||||||
@ -271,32 +251,9 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
||||||
Tables\Columns\TextColumn::make('publication_truth')
|
|
||||||
->label('Publication')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->label)
|
|
||||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->color)
|
|
||||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->icon)
|
|
||||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->iconColor),
|
|
||||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||||
->label('Export')
|
->label('Export')
|
||||||
->boolean(),
|
->boolean(),
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
Tables\Columns\TextColumn::make('fingerprint')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -604,9 +561,4 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
|||||||
'links' => [],
|
'links' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,12 +134,9 @@ public function startVerification(StartVerification $verification): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||||
use App\Services\Baselines\InventoryMetaContract;
|
use App\Services\Baselines\InventoryMetaContract;
|
||||||
@ -60,12 +59,10 @@ public function handle(
|
|||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
): void {
|
): void {
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
|
|
||||||
@ -186,12 +183,7 @@ public function handle(
|
|||||||
gaps: $captureGaps,
|
gaps: $captureGaps,
|
||||||
);
|
);
|
||||||
|
|
||||||
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
$items = $snapshotItems['items'] ?? [];
|
||||||
$items = $normalizedItems['items'];
|
|
||||||
|
|
||||||
if (($normalizedItems['duplicates'] ?? 0) > 0) {
|
|
||||||
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$identityHash = $identity->computeIdentity($items);
|
$identityHash = $identity->computeIdentity($items);
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -128,35 +127,4 @@ public function setFinishedAtAttribute(mixed $value): void
|
|||||||
{
|
{
|
||||||
$this->completed_at = $value;
|
$this->completed_at = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isGovernanceArtifactOperation(): bool
|
|
||||||
{
|
|
||||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function governanceArtifactFamily(): ?string
|
|
||||||
{
|
|
||||||
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function artifactResultContext(): array
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
|
|
||||||
|
|
||||||
return array_merge($context, ['result' => $result]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function relatedArtifactId(): ?int
|
|
||||||
{
|
|
||||||
return match ($this->governanceArtifactFamily()) {
|
|
||||||
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
|
|
||||||
? (int) data_get($this->context, 'result.snapshot_id')
|
|
||||||
: null,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
@ -45,14 +44,6 @@ public function toDatabase(object $notifiable): array
|
|||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $notification->getDatabaseMessage();
|
return $notification->getDatabaseMessage();
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
$message['reason_translation'] = $reasonEnvelope->toArray();
|
|
||||||
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Baselines;
|
|
||||||
|
|
||||||
final class BaselineSnapshotItemNormalizer
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $items
|
|
||||||
* @return array{items: list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>, duplicates: int}
|
|
||||||
*/
|
|
||||||
public function deduplicate(array $items): array
|
|
||||||
{
|
|
||||||
$uniqueItems = [];
|
|
||||||
$duplicates = 0;
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$key = trim((string) ($item['subject_type'] ?? '')).'|'.trim((string) ($item['subject_external_id'] ?? ''));
|
|
||||||
|
|
||||||
if ($key === '|') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! array_key_exists($key, $uniqueItems)) {
|
|
||||||
$uniqueItems[$key] = $item;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$duplicates++;
|
|
||||||
|
|
||||||
if ($this->shouldReplace($uniqueItems[$key], $item)) {
|
|
||||||
$uniqueItems[$key] = $item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'items' => array_values($uniqueItems),
|
|
||||||
'duplicates' => $duplicates,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $current
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $candidate
|
|
||||||
*/
|
|
||||||
private function shouldReplace(array $current, array $candidate): bool
|
|
||||||
{
|
|
||||||
$currentFidelity = $this->fidelityRank($current);
|
|
||||||
$candidateFidelity = $this->fidelityRank($candidate);
|
|
||||||
|
|
||||||
if ($candidateFidelity !== $currentFidelity) {
|
|
||||||
return $candidateFidelity > $currentFidelity;
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentObservedAt = $this->observedAt($current);
|
|
||||||
$candidateObservedAt = $this->observedAt($candidate);
|
|
||||||
|
|
||||||
if ($candidateObservedAt !== $currentObservedAt) {
|
|
||||||
return $candidateObservedAt > $currentObservedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strcmp((string) ($candidate['baseline_hash'] ?? ''), (string) ($current['baseline_hash'] ?? '')) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
|
||||||
*/
|
|
||||||
private function fidelityRank(array $item): int
|
|
||||||
{
|
|
||||||
$fidelity = data_get($item, 'meta_jsonb.evidence.fidelity');
|
|
||||||
|
|
||||||
return match ($fidelity) {
|
|
||||||
'content' => 2,
|
|
||||||
'meta' => 1,
|
|
||||||
default => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
|
||||||
*/
|
|
||||||
private function observedAt(array $item): string
|
|
||||||
{
|
|
||||||
$observedAt = data_get($item, 'meta_jsonb.evidence.observed_at');
|
|
||||||
|
|
||||||
return is_string($observedAt) ? $observedAt : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,7 +14,6 @@
|
|||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -120,7 +119,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||||
$rendered->summaryRows,
|
$rendered->summaryRows,
|
||||||
));
|
));
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
|
||||||
|
|
||||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||||
->header(new SummaryHeaderData(
|
->header(new SummaryHeaderData(
|
||||||
@ -136,14 +134,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||||
))
|
))
|
||||||
->addSection(
|
->addSection(
|
||||||
$factory->viewSection(
|
|
||||||
id: 'artifact_truth',
|
|
||||||
kind: 'current_status',
|
|
||||||
title: 'Artifact truth',
|
|
||||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
|
||||||
viewData: ['state' => $truth->toArray()],
|
|
||||||
description: 'Trustworthy artifact state stays separate from historical trace and support diagnostics.',
|
|
||||||
),
|
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'coverage_summary',
|
id: 'coverage_summary',
|
||||||
kind: 'current_status',
|
kind: 'current_status',
|
||||||
|
|||||||
@ -23,12 +23,7 @@ public function __construct(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{status:string,reason:?string,used_artifacts:bool}
|
||||||
* status: string,
|
|
||||||
* reason: ?string,
|
|
||||||
* used_artifacts: bool,
|
|
||||||
* reason_translation: array<string, mixed>|null
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
public function check(Tenant $tenant): array
|
public function check(Tenant $tenant): array
|
||||||
{
|
{
|
||||||
@ -110,19 +105,10 @@ public function check(Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{status:string,reason:?string,used_artifacts:bool}
|
||||||
* status: string,
|
|
||||||
* reason: ?string,
|
|
||||||
* used_artifacts: bool,
|
|
||||||
* reason_translation: array<string, mixed>|null
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
|
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
|
||||||
{
|
{
|
||||||
$reasonTranslation = is_string($reason) && $reason !== ''
|
|
||||||
? RbacReason::tryFrom($reason)?->toReasonResolutionEnvelope('detail')->toArray()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$tenant->update([
|
$tenant->update([
|
||||||
'rbac_status' => $status,
|
'rbac_status' => $status,
|
||||||
'rbac_status_reason' => $reason,
|
'rbac_status_reason' => $reason,
|
||||||
@ -133,7 +119,6 @@ private function record(Tenant $tenant, string $status, ?string $reason, bool $u
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'used_artifacts' => $usedArtifacts,
|
'used_artifacts' => $usedArtifacts,
|
||||||
'reason_translation' => $reasonTranslation,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,12 +22,6 @@
|
|||||||
use App\Support\OpsUx\BulkRunContext;
|
use App\Support\OpsUx\BulkRunContext;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\RbacReason;
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@ -40,7 +34,6 @@ class OperationRunService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AuditRecorder $auditRecorder,
|
private readonly AuditRecorder $auditRecorder,
|
||||||
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
|
||||||
private readonly ReasonTranslator $reasonTranslator,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
|
||||||
@ -494,16 +487,6 @@ public function updateRun(
|
|||||||
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
|
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
|
||||||
}
|
}
|
||||||
|
|
||||||
$updatedContext = $this->withReasonTranslationContext(
|
|
||||||
run: $run,
|
|
||||||
context: is_array($run->context) ? $run->context : [],
|
|
||||||
failures: is_array($updateData['failure_summary'] ?? null) ? $updateData['failure_summary'] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($updatedContext !== null) {
|
|
||||||
$updateData['context'] = $updatedContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
|
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
|
||||||
$updateData['started_at'] = now();
|
$updateData['started_at'] = now();
|
||||||
}
|
}
|
||||||
@ -738,13 +721,6 @@ public function finalizeBlockedRun(
|
|||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$context['reason_code'] = $reasonCode;
|
$context['reason_code'] = $reasonCode;
|
||||||
$context['next_steps'] = $nextSteps;
|
$context['next_steps'] = $nextSteps;
|
||||||
$context = $this->withReasonTranslationContext(
|
|
||||||
run: $run,
|
|
||||||
context: $context,
|
|
||||||
failures: [[
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
]],
|
|
||||||
) ?? $context;
|
|
||||||
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
|
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
|
||||||
|
|
||||||
$run->update([
|
$run->update([
|
||||||
@ -967,76 +943,6 @@ protected function sanitizeNextSteps(array $nextSteps): array
|
|||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private function withReasonTranslationContext(OperationRun $run, array $context, array $failures): ?array
|
|
||||||
{
|
|
||||||
$reasonCode = $this->resolveReasonCode($context, $failures);
|
|
||||||
|
|
||||||
if ($reasonCode === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasExplicitContextReason = is_string(data_get($context, 'execution_legitimacy.reason_code'))
|
|
||||||
|| is_string(data_get($context, 'reason_code'));
|
|
||||||
|
|
||||||
if (! $hasExplicitContextReason && ! $this->isDirectlyTranslatableReason($reasonCode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$translation = $this->reasonTranslator->translate($reasonCode, surface: 'notification', context: $context);
|
|
||||||
|
|
||||||
if (! $translation instanceof ReasonResolutionEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
|
||||||
|
|
||||||
if ($translation->nextSteps === [] && $legacyNextSteps !== []) {
|
|
||||||
$translation = $translation->withNextSteps($legacyNextSteps);
|
|
||||||
}
|
|
||||||
|
|
||||||
$context['reason_translation'] = $translation->toArray();
|
|
||||||
|
|
||||||
if ($translation->toLegacyNextSteps() !== [] && empty($context['next_steps'])) {
|
|
||||||
$context['next_steps'] = $translation->toLegacyNextSteps();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
|
|
||||||
*/
|
|
||||||
private function resolveReasonCode(array $context, array $failures): ?string
|
|
||||||
{
|
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
|
||||||
?? data_get($context, 'reason_code')
|
|
||||||
?? data_get($failures, '0.reason_code');
|
|
||||||
|
|
||||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim($reasonCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isDirectlyTranslatableReason(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
if ($reasonCode === ProviderReasonCodes::UnknownError) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderReasonCodes::isKnown($reasonCode)
|
|
||||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
|
||||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
|
||||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function writeTerminalAudit(OperationRun $run): void
|
private function writeTerminalAudit(OperationRun $run): void
|
||||||
{
|
{
|
||||||
$tenant = $run->tenant;
|
$tenant = $run->tenant;
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantLifecycle;
|
use App\Support\Tenants\TenantLifecycle;
|
||||||
use App\Support\Tenants\TenantOperabilityContext;
|
use App\Support\Tenants\TenantOperabilityContext;
|
||||||
@ -218,11 +217,6 @@ public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
|
|||||||
)->allowed;
|
)->allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function presentReason(TenantOperabilityOutcome $outcome): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return $outcome->reasonCode?->toReasonResolutionEnvelope('detail');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Tenant> $tenants
|
* @param Collection<int, Tenant> $tenants
|
||||||
* @return Collection<int, Tenant>
|
* @return Collection<int, Tenant>
|
||||||
|
|||||||
@ -15,11 +15,6 @@ final class BadgeCatalog
|
|||||||
private const DOMAIN_MAPPERS = [
|
private const DOMAIN_MAPPERS = [
|
||||||
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
||||||
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
|
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
|
||||||
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
|
|
||||||
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
|
|
||||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
|
||||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
|
||||||
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||||
|
|||||||
@ -6,11 +6,6 @@ enum BadgeDomain: string
|
|||||||
{
|
{
|
||||||
case AuditOutcome = 'audit_outcome';
|
case AuditOutcome = 'audit_outcome';
|
||||||
case AuditActorType = 'audit_actor_type';
|
case AuditActorType = 'audit_actor_type';
|
||||||
case GovernanceArtifactExistence = 'governance_artifact_existence';
|
|
||||||
case GovernanceArtifactContent = 'governance_artifact_content';
|
|
||||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
|
||||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
|
||||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
|
||||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||||
case OperationRunStatus = 'operation_run_status';
|
case OperationRunStatus = 'operation_run_status';
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
<?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 GovernanceArtifactActionabilityBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'none' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-check'),
|
|
||||||
'optional' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-information-circle'),
|
|
||||||
'required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-exclamation-triangle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
} ?? BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?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 GovernanceArtifactContentBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'trusted' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-check-badge'),
|
|
||||||
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-triangle'),
|
|
||||||
'missing_input' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-circle'),
|
|
||||||
'metadata_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-document-text'),
|
|
||||||
'reference_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-link'),
|
|
||||||
'empty' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-no-symbol'),
|
|
||||||
'unsupported' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-question-mark-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
} ?? BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?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 GovernanceArtifactExistenceBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'not_created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-clock'),
|
|
||||||
'historical_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-archive-box'),
|
|
||||||
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-check-circle'),
|
|
||||||
'created_but_not_usable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-exclamation-triangle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
} ?? BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?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 GovernanceArtifactFreshnessBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'current' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-check-circle'),
|
|
||||||
'stale' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-arrow-path'),
|
|
||||||
'unknown' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-question-mark-circle'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
} ?? BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?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 GovernanceArtifactPublicationReadinessBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
'not_applicable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-minus-circle'),
|
|
||||||
'internal_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-document-duplicate'),
|
|
||||||
'publishable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-check-badge'),
|
|
||||||
'blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-no-symbol'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
} ?? BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,205 +21,6 @@ final class OperatorOutcomeTaxonomy
|
|||||||
* }>>
|
* }>>
|
||||||
*/
|
*/
|
||||||
private const ENTRIES = [
|
private const ENTRIES = [
|
||||||
'governance_artifact_existence' => [
|
|
||||||
'not_created' => [
|
|
||||||
'axis' => 'artifact_existence',
|
|
||||||
'label' => 'Not created yet',
|
|
||||||
'color' => 'info',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'optional',
|
|
||||||
'legacy_aliases' => ['No artifact'],
|
|
||||||
'notes' => 'The intended artifact has not been produced yet.',
|
|
||||||
],
|
|
||||||
'historical_only' => [
|
|
||||||
'axis' => 'artifact_existence',
|
|
||||||
'label' => 'Historical artifact',
|
|
||||||
'color' => 'gray',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Historical only'],
|
|
||||||
'notes' => 'The artifact remains readable for history but is no longer the current working artifact.',
|
|
||||||
],
|
|
||||||
'created' => [
|
|
||||||
'axis' => 'artifact_existence',
|
|
||||||
'label' => 'Artifact available',
|
|
||||||
'color' => 'success',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Created'],
|
|
||||||
'notes' => 'The intended artifact exists and can be inspected.',
|
|
||||||
],
|
|
||||||
'created_but_not_usable' => [
|
|
||||||
'axis' => 'artifact_existence',
|
|
||||||
'label' => 'Artifact not usable',
|
|
||||||
'color' => 'warning',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'required',
|
|
||||||
'legacy_aliases' => ['Created but not usable'],
|
|
||||||
'notes' => 'The artifact record exists, but the operator cannot safely rely on it for the primary task.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'governance_artifact_content' => [
|
|
||||||
'trusted' => [
|
|
||||||
'axis' => 'data_coverage',
|
|
||||||
'label' => 'Trustworthy artifact',
|
|
||||||
'color' => 'success',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Trusted'],
|
|
||||||
'notes' => 'The artifact content is fit for the primary operator workflow.',
|
|
||||||
],
|
|
||||||
'partial' => [
|
|
||||||
'axis' => 'data_coverage',
|
|
||||||
'label' => 'Partial',
|
|
||||||
'color' => 'warning',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'required',
|
|
||||||
'legacy_aliases' => ['Partially complete'],
|
|
||||||
'notes' => 'The artifact exists but key content is incomplete.',
|
|
||||||
],
|
|
||||||
'missing_input' => [
|
|
||||||
'axis' => 'data_coverage',
|
|
||||||
'label' => 'Missing input',
|
|
||||||
'color' => 'warning',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'required',
|
|
||||||
'legacy_aliases' => ['Missing'],
|
|
||||||
'notes' => 'The artifact is blocked by missing upstream inputs.',
|
|
||||||
],
|
|
||||||
'metadata_only' => [
|
|
||||||
'axis' => 'evidence_depth',
|
|
||||||
'label' => 'Metadata only',
|
|
||||||
'color' => 'info',
|
|
||||||
'classification' => 'diagnostic',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Metadata-only'],
|
|
||||||
'notes' => 'Only metadata is available. This is diagnostic context and should not replace the primary truth state.',
|
|
||||||
],
|
|
||||||
'reference_only' => [
|
|
||||||
'axis' => 'evidence_depth',
|
|
||||||
'label' => 'Reference only',
|
|
||||||
'color' => 'info',
|
|
||||||
'classification' => 'diagnostic',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Reference-only'],
|
|
||||||
'notes' => 'Only reference placeholders are available. This is diagnostic context and should not replace the primary truth state.',
|
|
||||||
],
|
|
||||||
'empty' => [
|
|
||||||
'axis' => 'data_coverage',
|
|
||||||
'label' => 'Empty snapshot',
|
|
||||||
'color' => 'warning',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'required',
|
|
||||||
'legacy_aliases' => ['Empty'],
|
|
||||||
'notes' => 'The artifact exists but captured no usable content.',
|
|
||||||
],
|
|
||||||
'unsupported' => [
|
|
||||||
'axis' => 'product_support_maturity',
|
|
||||||
'label' => 'Support limited',
|
|
||||||
'color' => 'gray',
|
|
||||||
'classification' => 'diagnostic',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Unsupported'],
|
|
||||||
'notes' => 'The product is representing the source with limited fidelity. This remains diagnostic unless a stronger truth dimension applies.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'governance_artifact_freshness' => [
|
|
||||||
'current' => [
|
|
||||||
'axis' => 'data_freshness',
|
|
||||||
'label' => 'Current',
|
|
||||||
'color' => 'success',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Fresh'],
|
|
||||||
'notes' => 'The available artifact is current enough for the primary task.',
|
|
||||||
],
|
|
||||||
'stale' => [
|
|
||||||
'axis' => 'data_freshness',
|
|
||||||
'label' => 'Stale',
|
|
||||||
'color' => 'warning',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'optional',
|
|
||||||
'legacy_aliases' => ['Refresh recommended'],
|
|
||||||
'notes' => 'The artifact exists but should be refreshed before relying on it.',
|
|
||||||
],
|
|
||||||
'unknown' => [
|
|
||||||
'axis' => 'data_freshness',
|
|
||||||
'label' => 'Freshness unknown',
|
|
||||||
'color' => 'gray',
|
|
||||||
'classification' => 'diagnostic',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Unknown'],
|
|
||||||
'notes' => 'The system cannot determine freshness from the available payload.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'governance_artifact_publication_readiness' => [
|
|
||||||
'not_applicable' => [
|
|
||||||
'axis' => 'publication_readiness',
|
|
||||||
'label' => 'Not applicable',
|
|
||||||
'color' => 'gray',
|
|
||||||
'classification' => 'diagnostic',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['N/A'],
|
|
||||||
'notes' => 'Publication readiness does not apply to this artifact family.',
|
|
||||||
],
|
|
||||||
'internal_only' => [
|
|
||||||
'axis' => 'publication_readiness',
|
|
||||||
'label' => 'Internal only',
|
|
||||||
'color' => 'info',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'optional',
|
|
||||||
'legacy_aliases' => ['Draft'],
|
|
||||||
'notes' => 'The artifact is useful internally but not ready for stakeholder delivery.',
|
|
||||||
],
|
|
||||||
'publishable' => [
|
|
||||||
'axis' => 'publication_readiness',
|
|
||||||
'label' => 'Publishable',
|
|
||||||
'color' => 'success',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['Ready'],
|
|
||||||
'notes' => 'The artifact is ready for stakeholder publication or export.',
|
|
||||||
],
|
|
||||||
'blocked' => [
|
|
||||||
'axis' => 'publication_readiness',
|
|
||||||
'label' => 'Blocked',
|
|
||||||
'color' => 'warning',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'required',
|
|
||||||
'legacy_aliases' => ['Not publishable'],
|
|
||||||
'notes' => 'The artifact exists but is blocked from publication or export.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'governance_artifact_actionability' => [
|
|
||||||
'none' => [
|
|
||||||
'axis' => 'operator_actionability',
|
|
||||||
'label' => 'No action needed',
|
|
||||||
'color' => 'gray',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'none',
|
|
||||||
'legacy_aliases' => ['No follow-up'],
|
|
||||||
'notes' => 'The current non-green state is informational only and does not require action.',
|
|
||||||
],
|
|
||||||
'optional' => [
|
|
||||||
'axis' => 'operator_actionability',
|
|
||||||
'label' => 'Review recommended',
|
|
||||||
'color' => 'info',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'optional',
|
|
||||||
'legacy_aliases' => ['Optional follow-up'],
|
|
||||||
'notes' => 'The artifact can be used, but the operator should review the follow-up guidance.',
|
|
||||||
],
|
|
||||||
'required' => [
|
|
||||||
'axis' => 'operator_actionability',
|
|
||||||
'label' => 'Action required',
|
|
||||||
'color' => 'danger',
|
|
||||||
'classification' => 'primary',
|
|
||||||
'next_action_policy' => 'required',
|
|
||||||
'legacy_aliases' => ['Required follow-up'],
|
|
||||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'operation_run_status' => [
|
'operation_run_status' => [
|
||||||
'queued' => [
|
'queued' => [
|
||||||
'axis' => 'execution_lifecycle',
|
'axis' => 'execution_lifecycle',
|
||||||
@ -785,11 +586,6 @@ public static function curatedExamples(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
|
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
|
||||||
['name' => 'Artifact exists but is not usable', 'domain' => BadgeDomain::GovernanceArtifactExistence, 'raw_value' => 'created_but_not_usable'],
|
|
||||||
['name' => 'Artifact is trustworthy', 'domain' => BadgeDomain::GovernanceArtifactContent, 'raw_value' => 'trusted'],
|
|
||||||
['name' => 'Artifact is stale', 'domain' => BadgeDomain::GovernanceArtifactFreshness, 'raw_value' => 'stale'],
|
|
||||||
['name' => 'Artifact is publishable', 'domain' => BadgeDomain::GovernanceArtifactPublicationReadiness, 'raw_value' => 'publishable'],
|
|
||||||
['name' => 'Artifact requires action', 'domain' => BadgeDomain::GovernanceArtifactActionability, 'raw_value' => 'required'],
|
|
||||||
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
|
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
|
||||||
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
|
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
|
||||||
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
|
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
enum OperatorSemanticAxis: string
|
enum OperatorSemanticAxis: string
|
||||||
{
|
{
|
||||||
case ArtifactExistence = 'artifact_existence';
|
|
||||||
case ExecutionLifecycle = 'execution_lifecycle';
|
case ExecutionLifecycle = 'execution_lifecycle';
|
||||||
case ExecutionOutcome = 'execution_outcome';
|
case ExecutionOutcome = 'execution_outcome';
|
||||||
case ItemResult = 'item_result';
|
case ItemResult = 'item_result';
|
||||||
@ -21,7 +20,6 @@ enum OperatorSemanticAxis: string
|
|||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::ArtifactExistence => 'Artifact existence',
|
|
||||||
self::ExecutionLifecycle => 'Execution lifecycle',
|
self::ExecutionLifecycle => 'Execution lifecycle',
|
||||||
self::ExecutionOutcome => 'Execution outcome',
|
self::ExecutionOutcome => 'Execution outcome',
|
||||||
self::ItemResult => 'Item result',
|
self::ItemResult => 'Item result',
|
||||||
@ -38,7 +36,6 @@ public function label(): string
|
|||||||
public function definition(): string
|
public function definition(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::ArtifactExistence => 'Whether the intended governance artifact actually exists and can be located.',
|
|
||||||
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
|
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
|
||||||
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
|
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
|
||||||
self::ItemResult => 'How one restore or preview item resolved.',
|
self::ItemResult => 'How one restore or preview item resolved.',
|
||||||
|
|||||||
@ -105,20 +105,4 @@ public static function allowedSummaryKeys(): array
|
|||||||
{
|
{
|
||||||
return OperationSummaryKeys::all();
|
return OperationSummaryKeys::all();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function governanceArtifactFamily(string $operationType): ?string
|
|
||||||
{
|
|
||||||
return match (trim($operationType)) {
|
|
||||||
'baseline_capture' => 'baseline_snapshot',
|
|
||||||
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
|
|
||||||
'tenant.review.compose' => 'tenant_review',
|
|
||||||
'tenant.review_pack.generate' => 'review_pack',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isGovernanceArtifactOperation(string $operationType): bool
|
|
||||||
{
|
|
||||||
return self::governanceArtifactFamily($operationType) !== null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,20 +5,13 @@
|
|||||||
use App\Filament\Pages\BaselineCompareLanding;
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Filament\Resources\BaselineSnapshotResource;
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
|
||||||
final class OperationRunLinks
|
final class OperationRunLinks
|
||||||
@ -86,14 +79,6 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'baseline_capture') {
|
|
||||||
$snapshotId = data_get($context, 'result.snapshot_id');
|
|
||||||
|
|
||||||
if (is_numeric($snapshotId)) {
|
|
||||||
$links['Baseline Snapshot'] = BaselineSnapshotResource::getUrl('view', ['record' => (int) $snapshotId], panel: 'admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
@ -116,39 +101,6 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($run->type === 'tenant.evidence.snapshot.generate') {
|
|
||||||
$snapshot = EvidenceSnapshot::query()
|
|
||||||
->where('operation_run_id', (int) $run->getKey())
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($snapshot instanceof EvidenceSnapshot) {
|
|
||||||
$links['Evidence Snapshot'] = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'tenant.review.compose') {
|
|
||||||
$review = TenantReview::query()
|
|
||||||
->where('operation_run_id', (int) $run->getKey())
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($review instanceof TenantReview) {
|
|
||||||
$links['Tenant Review'] = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->type === 'tenant.review_pack.generate') {
|
|
||||||
$pack = ReviewPack::query()
|
|
||||||
->where('operation_run_id', (int) $run->getKey())
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($pack instanceof ReviewPack) {
|
|
||||||
$links['Review Pack'] = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\Operations;
|
namespace App\Support\Operations;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
enum ExecutionDenialReasonCode: string
|
enum ExecutionDenialReasonCode: string
|
||||||
{
|
{
|
||||||
case WorkspaceMismatch = 'workspace_mismatch';
|
case WorkspaceMismatch = 'workspace_mismatch';
|
||||||
@ -46,85 +43,4 @@ public function message(): string
|
|||||||
self::ExecutionPrerequisiteInvalid => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
self::ExecutionPrerequisiteInvalid => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function operatorLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'Workspace context changed',
|
|
||||||
self::TenantNotEntitled => 'Tenant access removed',
|
|
||||||
self::MissingCapability => 'Permission required',
|
|
||||||
self::TenantNotOperable => 'Tenant not ready',
|
|
||||||
self::TenantMissing => 'Tenant record unavailable',
|
|
||||||
self::InitiatorMissing => 'Initiator no longer available',
|
|
||||||
self::InitiatorNotEntitled => 'Initiator lost tenant access',
|
|
||||||
self::ProviderConnectionInvalid => 'Provider connection needs review',
|
|
||||||
self::WriteGateBlocked => 'Write protection blocked execution',
|
|
||||||
self::ExecutionPrerequisiteInvalid => 'Execution prerequisite changed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'The queued run no longer matches the current workspace scope.',
|
|
||||||
self::TenantNotEntitled => 'The queued tenant is no longer entitled for this run.',
|
|
||||||
self::MissingCapability => 'The initiating actor no longer has the capability required for this queued run.',
|
|
||||||
self::TenantNotOperable => 'The target tenant is not currently operable for this action.',
|
|
||||||
self::TenantMissing => 'The target tenant could not be resolved when execution resumed.',
|
|
||||||
self::InitiatorMissing => 'The initiating actor could not be resolved when execution resumed.',
|
|
||||||
self::InitiatorNotEntitled => 'The initiating actor is no longer entitled to the target tenant.',
|
|
||||||
self::ProviderConnectionInvalid => 'The queued provider connection is no longer valid for this scope.',
|
|
||||||
self::WriteGateBlocked => 'Current write hardening refuses execution for this tenant until the gate is satisfied.',
|
|
||||||
self::ExecutionPrerequisiteInvalid => 'The queued execution prerequisites are no longer satisfied.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actionability(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::TenantNotOperable => 'retryable_transient',
|
|
||||||
self::ProviderConnectionInvalid, self::WriteGateBlocked, self::ExecutionPrerequisiteInvalid => 'prerequisite_missing',
|
|
||||||
default => 'permanent_configuration',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
public function nextSteps(): array
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::MissingCapability, self::TenantNotEntitled, self::InitiatorNotEntitled, self::WorkspaceMismatch => [
|
|
||||||
NextStepOption::instruction('Review workspace or tenant access before retrying.', scope: 'workspace'),
|
|
||||||
],
|
|
||||||
self::TenantNotOperable, self::ExecutionPrerequisiteInvalid => [
|
|
||||||
NextStepOption::instruction('Review tenant readiness before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::ProviderConnectionInvalid => [
|
|
||||||
NextStepOption::instruction('Review the provider connection before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::WriteGateBlocked => [
|
|
||||||
NextStepOption::instruction('Review the write gate state before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::TenantMissing, self::InitiatorMissing => [
|
|
||||||
NextStepOption::instruction('Requeue the operation from a current tenant context.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $this->value,
|
|
||||||
operatorLabel: $this->operatorLabel(),
|
|
||||||
shortExplanation: $this->shortExplanation(),
|
|
||||||
actionability: $this->actionability(),
|
|
||||||
nextSteps: $this->nextSteps(),
|
|
||||||
showNoActionNeeded: false,
|
|
||||||
diagnosticCodeLabel: $this->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
|
||||||
@ -96,14 +95,8 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
|
|||||||
public static function surfaceGuidance(OperationRun $run): ?string
|
public static function surfaceGuidance(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
|
||||||
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
|
||||||
$nextStepLabel = self::firstNextStepLabel($run);
|
$nextStepLabel = self::firstNextStepLabel($run);
|
||||||
|
|
||||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
|
||||||
return $reasonGuidance;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($uxStatus) {
|
return match ($uxStatus) {
|
||||||
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
||||||
'running' => 'No action needed yet. The run is currently in progress.',
|
'running' => 'No action needed yet. The run is currently in progress.',
|
||||||
@ -124,12 +117,6 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
|||||||
|
|
||||||
public static function surfaceFailureDetail(OperationRun $run): ?string
|
public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||||
{
|
{
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
return $reasonEnvelope->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||||
|
|
||||||
return self::sanitizeFailureMessage($failureMessage);
|
return self::sanitizeFailureMessage($failureMessage);
|
||||||
@ -141,7 +128,6 @@ public static function surfaceFailureDetail(OperationRun $run): ?string
|
|||||||
private static function terminalPresentation(OperationRun $run): array
|
private static function terminalPresentation(OperationRun $run): array
|
||||||
{
|
{
|
||||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||||
$reasonEnvelope = self::reasonEnvelope($run);
|
|
||||||
|
|
||||||
return match ($uxStatus) {
|
return match ($uxStatus) {
|
||||||
'succeeded' => [
|
'succeeded' => [
|
||||||
@ -156,12 +142,12 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
],
|
],
|
||||||
'blocked' => [
|
'blocked' => [
|
||||||
'titleSuffix' => 'blocked by prerequisite',
|
'titleSuffix' => 'blocked by prerequisite',
|
||||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
'body' => 'Blocked by prerequisite.',
|
||||||
'status' => 'warning',
|
'status' => 'warning',
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
'titleSuffix' => 'execution failed',
|
'titleSuffix' => 'execution failed',
|
||||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
'body' => 'Execution failed.',
|
||||||
'status' => 'danger',
|
'status' => 'danger',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -218,9 +204,4 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
|
|||||||
|
|
||||||
return $failureMessage !== '' ? $failureMessage : null;
|
return $failureMessage !== '' ? $failureMessage : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonTranslation\ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,12 +38,16 @@ public static function sanitizeCode(string $code): string
|
|||||||
public static function normalizeReasonCode(string $candidate): string
|
public static function normalizeReasonCode(string $candidate): string
|
||||||
{
|
{
|
||||||
$candidate = strtolower(trim($candidate));
|
$candidate = strtolower(trim($candidate));
|
||||||
|
$executionDenialReasonCodes = array_map(
|
||||||
|
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
|
||||||
|
ExecutionDenialReasonCode::cases(),
|
||||||
|
);
|
||||||
|
|
||||||
if ($candidate === '') {
|
if ($candidate === '') {
|
||||||
return ProviderReasonCodes::UnknownError;
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,11 +85,11 @@ public static function normalizeReasonCode(string $candidate): string
|
|||||||
default => $candidate,
|
default => $candidate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heuristic normalization for ad-hoc inputs is bounded fallback behavior only.
|
// Heuristic normalization for ad-hoc codes used across jobs/services.
|
||||||
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
||||||
return ProviderReasonCodes::RateLimited;
|
return ProviderReasonCodes::RateLimited;
|
||||||
}
|
}
|
||||||
@ -117,22 +121,6 @@ public static function normalizeReasonCode(string $candidate): string
|
|||||||
return ProviderReasonCodes::UnknownError;
|
return ProviderReasonCodes::UnknownError;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function isStructuredOperatorReasonCode(string $candidate): bool
|
|
||||||
{
|
|
||||||
$candidate = strtolower(trim($candidate));
|
|
||||||
|
|
||||||
if ($candidate === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$executionDenialReasonCodes = array_map(
|
|
||||||
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
|
|
||||||
ExecutionDenialReasonCode::cases(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function sanitizeMessage(string $message): string
|
public static function sanitizeMessage(string $message): string
|
||||||
{
|
{
|
||||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||||
|
|||||||
@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
|
||||||
|
|
||||||
final class SummaryCountsNormalizer
|
final class SummaryCountsNormalizer
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@ -86,22 +84,6 @@ public static function renderSummaryLine(array $summaryCounts): ?string
|
|||||||
*/
|
*/
|
||||||
public static function label(string $key): string
|
public static function label(string $key): string
|
||||||
{
|
{
|
||||||
$reasonCode = null;
|
|
||||||
|
|
||||||
if (str_starts_with($key, 'reason_')) {
|
|
||||||
$reasonCode = substr($key, strlen('reason_'));
|
|
||||||
} elseif (str_starts_with($key, 'blocked_reason_')) {
|
|
||||||
$reasonCode = substr($key, strlen('blocked_reason_'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
|
||||||
$translation = app(ReasonTranslator::class)->translate($reasonCode, surface: 'summary_line');
|
|
||||||
|
|
||||||
if ($translation !== null) {
|
|
||||||
return 'Reason: '.$translation->operatorLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($key) {
|
return match ($key) {
|
||||||
'total' => 'Total',
|
'total' => 'Total',
|
||||||
'processed' => 'Processed',
|
'processed' => 'Processed',
|
||||||
|
|||||||
@ -2,23 +2,91 @@
|
|||||||
|
|
||||||
namespace App\Support\Providers;
|
namespace App\Support\Providers;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
|
|
||||||
final class ProviderNextStepsRegistry
|
final class ProviderNextStepsRegistry
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly ReasonPresenter $reasonPresenter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{label: string, url: string}>
|
* @return array<int, array{label: string, url: string}>
|
||||||
*/
|
*/
|
||||||
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
|
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
|
||||||
{
|
{
|
||||||
$envelope = $this->reasonPresenter->forProviderReason($tenant, $reasonCode, $connection, 'helper_copy');
|
return match ($reasonCode) {
|
||||||
|
ProviderReasonCodes::ProviderConnectionMissing,
|
||||||
return $envelope?->toLegacyNextSteps() ?? [];
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
ProviderReasonCodes::TenantTargetMismatch,
|
||||||
|
ProviderReasonCodes::PlatformIdentityMissing,
|
||||||
|
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||||
|
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
||||||
|
[
|
||||||
|
'label' => $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Review effective app details',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
|
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
||||||
|
[
|
||||||
|
'label' => $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::ProviderCredentialMissing,
|
||||||
|
ProviderReasonCodes::ProviderCredentialInvalid,
|
||||||
|
ProviderReasonCodes::ProviderConsentFailed,
|
||||||
|
ProviderReasonCodes::ProviderConsentRevoked,
|
||||||
|
ProviderReasonCodes::ProviderAuthFailed,
|
||||||
|
ProviderReasonCodes::ProviderConsentMissing => [
|
||||||
|
[
|
||||||
|
'label' => 'Grant admin consent',
|
||||||
|
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => $connection instanceof ProviderConnection
|
||||||
|
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
||||||
|
: 'Manage Provider Connections',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
ProviderReasonCodes::ProviderPermissionDenied,
|
||||||
|
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
||||||
|
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
||||||
|
[
|
||||||
|
'label' => 'Open Required Permissions',
|
||||||
|
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
ProviderReasonCodes::NetworkUnreachable,
|
||||||
|
ProviderReasonCodes::RateLimited,
|
||||||
|
ProviderReasonCodes::UnknownError => [
|
||||||
|
[
|
||||||
|
'label' => 'Review Provider Connection',
|
||||||
|
'url' => $connection instanceof ProviderConnection
|
||||||
|
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
||||||
|
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
[
|
||||||
|
'label' => 'Manage Provider Connections',
|
||||||
|
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,364 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Providers;
|
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
|
||||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
final class ProviderReasonTranslator implements TranslatesReasonCode
|
|
||||||
{
|
|
||||||
public const string ARTIFACT_KEY = 'provider_reason_codes';
|
|
||||||
|
|
||||||
public function artifactKey(): string
|
|
||||||
{
|
|
||||||
return self::ARTIFACT_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canTranslate(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
return ProviderReasonCodes::isKnown(trim($reasonCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$reasonCode = trim($reasonCode);
|
|
||||||
|
|
||||||
if ($reasonCode === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedCode = ProviderReasonCodes::isKnown($reasonCode)
|
|
||||||
? $reasonCode
|
|
||||||
: ProviderReasonCodes::UnknownError;
|
|
||||||
$tenant = $context['tenant'] ?? null;
|
|
||||||
$connection = $context['connection'] ?? null;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
$nextSteps = $this->fallbackNextSteps($normalizedCode);
|
|
||||||
} else {
|
|
||||||
$nextSteps = $this->nextStepsFor($tenant, $normalizedCode, $connection instanceof ProviderConnection ? $connection : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($normalizedCode) {
|
|
||||||
ProviderReasonCodes::ProviderConnectionMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Provider connection required',
|
|
||||||
shortExplanation: 'This tenant does not have a usable provider connection for Microsoft operations.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Provider connection needs review',
|
|
||||||
shortExplanation: 'The selected provider connection is incomplete or no longer valid for this workflow.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderCredentialMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Credentials missing',
|
|
||||||
shortExplanation: 'The provider connection is missing the credentials required to authenticate.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderCredentialInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Credentials need review',
|
|
||||||
shortExplanation: 'Stored provider credentials are no longer valid for the selected provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConnectionTypeInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Connection type unsupported',
|
|
||||||
shortExplanation: 'The selected provider connection type cannot be used for this workflow.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::PlatformIdentityMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Platform identity missing',
|
|
||||||
shortExplanation: 'The platform provider connection is missing the app identity details required to continue.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::PlatformIdentityIncomplete => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Platform identity incomplete',
|
|
||||||
shortExplanation: 'The platform provider connection needs more app identity details before it can continue.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::DedicatedCredentialMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Dedicated credentials required',
|
|
||||||
shortExplanation: 'This dedicated provider connection cannot continue until dedicated credentials are configured.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::DedicatedCredentialInvalid => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Dedicated credentials need review',
|
|
||||||
shortExplanation: 'The dedicated credentials are no longer valid for this provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Admin consent required',
|
|
||||||
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConsentFailed => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Admin consent check failed',
|
|
||||||
shortExplanation: 'TenantPilot could not confirm admin consent for this provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConsentRevoked => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Admin consent revoked',
|
|
||||||
shortExplanation: 'Previously granted admin consent is no longer valid for this provider connection.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderConnectionReviewRequired => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Connection classification needs review',
|
|
||||||
shortExplanation: 'TenantPilot needs you to confirm how this provider connection should be used.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Provider authentication failed',
|
|
||||||
shortExplanation: 'The provider connection could not authenticate with the stored credentials.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderPermissionMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Permissions missing',
|
|
||||||
shortExplanation: 'The provider app is missing required Microsoft Graph permissions.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderPermissionDenied => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Permission denied',
|
|
||||||
shortExplanation: 'Microsoft Graph denied the requested permission for this provider connection.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::ProviderPermissionRefreshFailed => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Permission refresh failed',
|
|
||||||
shortExplanation: 'TenantPilot could not refresh the provider permission snapshot.',
|
|
||||||
actionability: 'retryable_transient',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacPermissionMissing => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC permission missing',
|
|
||||||
shortExplanation: 'The provider app lacks the Intune RBAC permission needed for this workflow.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::TenantTargetMismatch => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Connection targets a different tenant',
|
|
||||||
shortExplanation: 'The selected provider connection points to a different Microsoft tenant than the current scope.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::NetworkUnreachable => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Microsoft Graph temporarily unreachable',
|
|
||||||
shortExplanation: 'TenantPilot could not reach Microsoft Graph or the provider dependency.',
|
|
||||||
actionability: 'retryable_transient',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::RateLimited => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Request rate limited',
|
|
||||||
shortExplanation: 'Microsoft Graph asked TenantPilot to slow down before retrying.',
|
|
||||||
actionability: 'retryable_transient',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacNotConfigured => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC not configured',
|
|
||||||
shortExplanation: 'Intune RBAC has not been configured for this tenant yet.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacUnhealthy => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC health degraded',
|
|
||||||
shortExplanation: 'The latest Intune RBAC health check found a blocking issue.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
ProviderReasonCodes::IntuneRbacStale => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: 'Intune RBAC check is stale',
|
|
||||||
shortExplanation: 'The latest Intune RBAC health check is too old to trust for write operations.',
|
|
||||||
actionability: 'prerequisite_missing',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
default => $this->envelope(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
operatorLabel: str_starts_with($normalizedCode, 'ext.')
|
|
||||||
? 'Provider configuration needs review'
|
|
||||||
: 'Provider check needs review',
|
|
||||||
shortExplanation: 'TenantPilot recorded a provider error that does not yet have a domain-specific translation.',
|
|
||||||
actionability: 'permanent_configuration',
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, NextStepOption> $nextSteps
|
|
||||||
*/
|
|
||||||
private function envelope(
|
|
||||||
string $reasonCode,
|
|
||||||
string $operatorLabel,
|
|
||||||
string $shortExplanation,
|
|
||||||
string $actionability,
|
|
||||||
array $nextSteps,
|
|
||||||
): ReasonResolutionEnvelope {
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $reasonCode,
|
|
||||||
operatorLabel: $operatorLabel,
|
|
||||||
shortExplanation: $shortExplanation,
|
|
||||||
actionability: $actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: false,
|
|
||||||
diagnosticCodeLabel: $reasonCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
private function fallbackNextSteps(string $reasonCode): array
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
ProviderReasonCodes::NetworkUnreachable, ProviderReasonCodes::RateLimited => [
|
|
||||||
NextStepOption::instruction('Retry after the provider dependency recovers.'),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::UnknownError => [
|
|
||||||
NextStepOption::instruction('Review the provider connection and retry once the cause is understood.'),
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
NextStepOption::instruction('Review the provider connection before retrying.'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
private function nextStepsFor(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $reasonCode,
|
|
||||||
?ProviderConnection $connection = null,
|
|
||||||
): array {
|
|
||||||
return match ($reasonCode) {
|
|
||||||
ProviderReasonCodes::ProviderConnectionMissing,
|
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
|
||||||
ProviderReasonCodes::TenantTargetMismatch,
|
|
||||||
ProviderReasonCodes::PlatformIdentityMissing,
|
|
||||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
|
||||||
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Review effective app details',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::DedicatedCredentialMissing,
|
|
||||||
ProviderReasonCodes::DedicatedCredentialInvalid => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::ProviderCredentialMissing,
|
|
||||||
ProviderReasonCodes::ProviderCredentialInvalid,
|
|
||||||
ProviderReasonCodes::ProviderConsentFailed,
|
|
||||||
ProviderReasonCodes::ProviderConsentRevoked,
|
|
||||||
ProviderReasonCodes::ProviderAuthFailed,
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Grant admin consent',
|
|
||||||
destination: RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
|
|
||||||
),
|
|
||||||
NextStepOption::link(
|
|
||||||
label: $connection instanceof ProviderConnection
|
|
||||||
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
|
|
||||||
: 'Manage Provider Connections',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::ProviderPermissionMissing,
|
|
||||||
ProviderReasonCodes::ProviderPermissionDenied,
|
|
||||||
ProviderReasonCodes::ProviderPermissionRefreshFailed,
|
|
||||||
ProviderReasonCodes::IntuneRbacPermissionMissing => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Open Required Permissions',
|
|
||||||
destination: RequiredPermissionsLinks::requiredPermissions($tenant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::IntuneRbacNotConfigured,
|
|
||||||
ProviderReasonCodes::IntuneRbacUnhealthy,
|
|
||||||
ProviderReasonCodes::IntuneRbacStale => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Review provider connections',
|
|
||||||
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
ProviderReasonCodes::NetworkUnreachable,
|
|
||||||
ProviderReasonCodes::RateLimited => [
|
|
||||||
NextStepOption::instruction('Retry after the provider dependency recovers.', scope: 'tenant'),
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Review provider connection',
|
|
||||||
destination: $connection instanceof ProviderConnection
|
|
||||||
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
|
|
||||||
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
NextStepOption::link(
|
|
||||||
label: 'Manage Provider Connections',
|
|
||||||
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
enum RbacReason: string
|
enum RbacReason: string
|
||||||
{
|
{
|
||||||
case MissingArtifacts = 'missing_artifacts';
|
case MissingArtifacts = 'missing_artifacts';
|
||||||
@ -17,81 +14,4 @@ enum RbacReason: string
|
|||||||
case CanaryFailed = 'canary_failed';
|
case CanaryFailed = 'canary_failed';
|
||||||
case ManualAssignmentRequired = 'manual_assignment_required';
|
case ManualAssignmentRequired = 'manual_assignment_required';
|
||||||
case UnsupportedApi = 'unsupported_api';
|
case UnsupportedApi = 'unsupported_api';
|
||||||
|
|
||||||
public function operatorLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::MissingArtifacts => 'RBAC setup incomplete',
|
|
||||||
self::ServicePrincipalMissing => 'Service principal missing',
|
|
||||||
self::GroupMissing => 'RBAC group missing',
|
|
||||||
self::ServicePrincipalNotMember => 'Service principal not in RBAC group',
|
|
||||||
self::AssignmentMissing => 'RBAC assignment missing',
|
|
||||||
self::RoleMismatch => 'RBAC role mismatch',
|
|
||||||
self::ScopeMismatch => 'RBAC scope mismatch',
|
|
||||||
self::CanaryFailed => 'RBAC validation needs review',
|
|
||||||
self::ManualAssignmentRequired => 'Manual role assignment required',
|
|
||||||
self::UnsupportedApi => 'RBAC API unsupported',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::MissingArtifacts => 'TenantPilot could not find the RBAC artifacts required for this tenant.',
|
|
||||||
self::ServicePrincipalMissing => 'The provider app service principal could not be resolved in Microsoft Graph.',
|
|
||||||
self::GroupMissing => 'The configured Intune RBAC group could not be found.',
|
|
||||||
self::ServicePrincipalNotMember => 'The provider app service principal is not currently a member of the configured RBAC group.',
|
|
||||||
self::AssignmentMissing => 'No matching Intune RBAC assignment could be confirmed for this tenant.',
|
|
||||||
self::RoleMismatch => 'The existing Intune RBAC assignment uses a different role than expected.',
|
|
||||||
self::ScopeMismatch => 'The existing Intune RBAC assignment targets a different scope than expected.',
|
|
||||||
self::CanaryFailed => 'The RBAC canary checks reported a mismatch after setup completed.',
|
|
||||||
self::ManualAssignmentRequired => 'This tenant requires a manual Intune RBAC role assignment outside the automated API path.',
|
|
||||||
self::UnsupportedApi => 'This account type does not support the required Intune RBAC API path.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actionability(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::CanaryFailed => 'retryable_transient',
|
|
||||||
self::ManualAssignmentRequired => 'prerequisite_missing',
|
|
||||||
self::UnsupportedApi => 'non_actionable',
|
|
||||||
default => 'prerequisite_missing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
public function nextSteps(): array
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::UnsupportedApi => [],
|
|
||||||
self::ManualAssignmentRequired => [
|
|
||||||
NextStepOption::instruction('Complete the Intune role assignment manually, then refresh RBAC status.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::CanaryFailed => [
|
|
||||||
NextStepOption::instruction('Review the RBAC canary checks and rerun the health check.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
NextStepOption::instruction('Review the RBAC setup and refresh the tenant RBAC status.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $this->value,
|
|
||||||
operatorLabel: $this->operatorLabel(),
|
|
||||||
shortExplanation: $this->shortExplanation(),
|
|
||||||
actionability: $this->actionability(),
|
|
||||||
nextSteps: $this->nextSteps(),
|
|
||||||
showNoActionNeeded: $this->actionability() === 'non_actionable',
|
|
||||||
diagnosticCodeLabel: $this->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation\Contracts;
|
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
interface TranslatesReasonCode
|
|
||||||
{
|
|
||||||
public function artifactKey(): string;
|
|
||||||
|
|
||||||
public function canTranslate(string $reasonCode): bool;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope;
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
final class FallbackReasonTranslator implements TranslatesReasonCode
|
|
||||||
{
|
|
||||||
public const string ARTIFACT_KEY = 'fallback_reason_code';
|
|
||||||
|
|
||||||
public function artifactKey(): string
|
|
||||||
{
|
|
||||||
return self::ARTIFACT_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canTranslate(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
return trim($reasonCode) !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$normalizedCode = trim($reasonCode);
|
|
||||||
|
|
||||||
if ($normalizedCode === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actionability = $this->actionabilityFor($normalizedCode);
|
|
||||||
$nextSteps = $this->fallbackNextStepsFor($actionability);
|
|
||||||
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $normalizedCode,
|
|
||||||
operatorLabel: $this->operatorLabelFor($normalizedCode),
|
|
||||||
shortExplanation: $this->shortExplanationFor($actionability),
|
|
||||||
actionability: $actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: $actionability === 'non_actionable',
|
|
||||||
diagnosticCodeLabel: $normalizedCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function operatorLabelFor(string $reasonCode): string
|
|
||||||
{
|
|
||||||
return Str::headline(str_replace(['.', '-'], '_', $reasonCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function actionabilityFor(string $reasonCode): string
|
|
||||||
{
|
|
||||||
$reasonCode = strtolower($reasonCode);
|
|
||||||
|
|
||||||
if (str_contains($reasonCode, 'timeout')
|
|
||||||
|| str_contains($reasonCode, 'throttle')
|
|
||||||
|| str_contains($reasonCode, 'rate')
|
|
||||||
|| str_contains($reasonCode, 'network')
|
|
||||||
|| str_contains($reasonCode, 'unreachable')
|
|
||||||
|| str_contains($reasonCode, 'transient')
|
|
||||||
|| str_contains($reasonCode, 'retry')
|
|
||||||
) {
|
|
||||||
return 'retryable_transient';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($reasonCode, 'missing')
|
|
||||||
|| str_contains($reasonCode, 'required')
|
|
||||||
|| str_contains($reasonCode, 'consent')
|
|
||||||
|| str_contains($reasonCode, 'stale')
|
|
||||||
|| str_contains($reasonCode, 'prerequisite')
|
|
||||||
|| str_contains($reasonCode, 'invalid')
|
|
||||||
) {
|
|
||||||
return 'prerequisite_missing';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($reasonCode, 'already_')
|
|
||||||
|| str_contains($reasonCode, 'not_applicable')
|
|
||||||
|| str_contains($reasonCode, 'no_action')
|
|
||||||
|| str_contains($reasonCode, 'info')
|
|
||||||
) {
|
|
||||||
return 'non_actionable';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'permanent_configuration';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function shortExplanationFor(string $actionability): string
|
|
||||||
{
|
|
||||||
return match ($actionability) {
|
|
||||||
'retryable_transient' => 'TenantPilot recorded a transient dependency issue. Retry after the dependency recovers.',
|
|
||||||
'prerequisite_missing' => 'TenantPilot recorded a missing or invalid prerequisite for this workflow.',
|
|
||||||
'non_actionable' => 'TenantPilot recorded this state for visibility only. No operator action is required.',
|
|
||||||
default => 'TenantPilot recorded an access, scope, or configuration issue that needs review before retrying.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
private function fallbackNextStepsFor(string $actionability): array
|
|
||||||
{
|
|
||||||
return match ($actionability) {
|
|
||||||
'retryable_transient' => [NextStepOption::instruction('Retry after the dependency recovers.')],
|
|
||||||
'prerequisite_missing' => [NextStepOption::instruction('Review the recorded prerequisite before retrying.')],
|
|
||||||
'non_actionable' => [],
|
|
||||||
default => [NextStepOption::instruction('Review access and configuration before retrying.')],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final readonly class NextStepOption
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $label,
|
|
||||||
public string $kind,
|
|
||||||
public ?string $destination = null,
|
|
||||||
public bool $authorizationRequired = false,
|
|
||||||
public string $scope = 'none',
|
|
||||||
) {
|
|
||||||
$label = trim($this->label);
|
|
||||||
$kind = trim($this->kind);
|
|
||||||
$scope = trim($this->scope);
|
|
||||||
|
|
||||||
if ($label === '') {
|
|
||||||
throw new InvalidArgumentException('Next-step labels must not be empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($kind, ['link', 'instruction', 'diagnostic_only'], true)) {
|
|
||||||
throw new InvalidArgumentException('Unsupported next-step kind: '.$kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($scope, ['tenant', 'workspace', 'system', 'none'], true)) {
|
|
||||||
throw new InvalidArgumentException('Unsupported next-step scope: '.$scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($kind === 'link' && trim((string) $this->destination) === '') {
|
|
||||||
throw new InvalidArgumentException('Link next steps require a destination.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function link(
|
|
||||||
string $label,
|
|
||||||
string $destination,
|
|
||||||
bool $authorizationRequired = true,
|
|
||||||
string $scope = 'tenant',
|
|
||||||
): self {
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: 'link',
|
|
||||||
destination: $destination,
|
|
||||||
authorizationRequired: $authorizationRequired,
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function instruction(string $label, string $scope = 'none'): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: 'instruction',
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function diagnosticOnly(string $label): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: 'diagnostic_only',
|
|
||||||
scope: 'none',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function fromArray(array $data): ?self
|
|
||||||
{
|
|
||||||
$label = is_string($data['label'] ?? null) ? trim((string) $data['label']) : '';
|
|
||||||
$kind = is_string($data['kind'] ?? null)
|
|
||||||
? trim((string) $data['kind'])
|
|
||||||
: ((is_string($data['url'] ?? null) || is_string($data['destination'] ?? null)) ? 'link' : 'instruction');
|
|
||||||
$destination = is_string($data['destination'] ?? null)
|
|
||||||
? trim((string) $data['destination'])
|
|
||||||
: (is_string($data['url'] ?? null) ? trim((string) $data['url']) : null);
|
|
||||||
$authorizationRequired = (bool) ($data['authorization_required'] ?? $data['authorizationRequired'] ?? false);
|
|
||||||
$scope = is_string($data['scope'] ?? null) ? trim((string) $data['scope']) : 'none';
|
|
||||||
|
|
||||||
if ($label === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
label: $label,
|
|
||||||
kind: $kind,
|
|
||||||
destination: $destination !== '' ? $destination : null,
|
|
||||||
authorizationRequired: $authorizationRequired,
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, array<string, mixed>> $items
|
|
||||||
* @return array<int, self>
|
|
||||||
*/
|
|
||||||
public static function collect(array $items): array
|
|
||||||
{
|
|
||||||
$options = [];
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if (! is_array($item)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$option = self::fromArray($item);
|
|
||||||
|
|
||||||
if ($option instanceof self) {
|
|
||||||
$options[] = $option;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* destination: ?string,
|
|
||||||
* authorization_required: bool,
|
|
||||||
* scope: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'label' => $this->label,
|
|
||||||
'kind' => $this->kind,
|
|
||||||
'destination' => $this->destination,
|
|
||||||
'authorization_required' => $this->authorizationRequired,
|
|
||||||
'scope' => $this->scope,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, url: string}
|
|
||||||
*/
|
|
||||||
public function toLegacyArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'label' => $this->label,
|
|
||||||
'url' => (string) $this->destination,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Providers\ProviderReasonTranslator;
|
|
||||||
use App\Support\RbacReason;
|
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
|
||||||
|
|
||||||
final class ReasonPresenter
|
|
||||||
{
|
|
||||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ReasonTranslator $reasonTranslator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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']));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $storedEnvelope;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
|
||||||
?? data_get($context, 'reason_code');
|
|
||||||
|
|
||||||
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
|
|
||||||
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureReasonCode = data_get($run->failure_summary, '0.reason_code');
|
|
||||||
|
|
||||||
if (! is_string($failureReasonCode) || trim($failureReasonCode) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureReasonCode = trim($failureReasonCode);
|
|
||||||
|
|
||||||
if (! $this->isDirectlyTranslatableOperationReason($failureReasonCode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$envelope = $this->translateOperationRunReason($failureReasonCode, $surface, $context);
|
|
||||||
|
|
||||||
if (! $envelope instanceof ReasonResolutionEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($envelope->nextSteps !== []) {
|
|
||||||
return $envelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
|
|
||||||
|
|
||||||
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function translateOperationRunReason(
|
|
||||||
string $reasonCode,
|
|
||||||
string $surface,
|
|
||||||
array $context,
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
return $this->reasonTranslator->translate($reasonCode, surface: $surface, context: $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
|
|
||||||
{
|
|
||||||
if ($reasonCode === ProviderReasonCodes::UnknownError) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ProviderReasonCodes::isKnown($reasonCode)
|
|
||||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
|
||||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
|
||||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forProviderReason(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $reasonCode,
|
|
||||||
?ProviderConnection $connection = null,
|
|
||||||
string $surface = 'detail',
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
return $this->reasonTranslator->translate(
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
artifactKey: ProviderReasonTranslator::ARTIFACT_KEY,
|
|
||||||
surface: $surface,
|
|
||||||
context: [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'connection' => $connection,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forTenantOperabilityReason(
|
|
||||||
TenantOperabilityReasonCode|string|null $reasonCode,
|
|
||||||
string $surface = 'detail',
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
$normalizedCode = $reasonCode instanceof TenantOperabilityReasonCode ? $reasonCode->value : $reasonCode;
|
|
||||||
|
|
||||||
return $this->reasonTranslator->translate(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
artifactKey: ReasonTranslator::TENANT_OPERABILITY_ARTIFACT,
|
|
||||||
surface: $surface,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forRbacReason(RbacReason|string|null $reasonCode, string $surface = 'detail'): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$normalizedCode = $reasonCode instanceof RbacReason ? $reasonCode->value : $reasonCode;
|
|
||||||
|
|
||||||
return $this->reasonTranslator->translate(
|
|
||||||
reasonCode: $normalizedCode,
|
|
||||||
artifactKey: ReasonTranslator::RBAC_ARTIFACT,
|
|
||||||
surface: $surface,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function forArtifactTruth(
|
|
||||||
?string $reasonCode,
|
|
||||||
string $surface = 'detail',
|
|
||||||
array $context = [],
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
return $this->reasonTranslator->translate(
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
artifactKey: self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT,
|
|
||||||
surface: $surface,
|
|
||||||
context: $context,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function diagnosticCode(?ReasonResolutionEnvelope $envelope): ?string
|
|
||||||
{
|
|
||||||
return $envelope?->diagnosticCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function primaryLabel(?ReasonResolutionEnvelope $envelope): ?string
|
|
||||||
{
|
|
||||||
return $envelope?->operatorLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
|
|
||||||
{
|
|
||||||
return $envelope?->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
|
|
||||||
{
|
|
||||||
return $envelope?->guidanceText();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function bodyLines(?ReasonResolutionEnvelope $envelope, bool $includeGuidance = true): array
|
|
||||||
{
|
|
||||||
return $envelope?->toBodyLines($includeGuidance) ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final readonly class ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<int, NextStepOption> $nextSteps
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public string $internalCode,
|
|
||||||
public string $operatorLabel,
|
|
||||||
public string $shortExplanation,
|
|
||||||
public string $actionability,
|
|
||||||
public array $nextSteps = [],
|
|
||||||
public bool $showNoActionNeeded = false,
|
|
||||||
public ?string $diagnosticCodeLabel = null,
|
|
||||||
) {
|
|
||||||
if (trim($this->internalCode) === '') {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trim($this->operatorLabel) === '') {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes require an operator label.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trim($this->shortExplanation) === '') {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes require a short explanation.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($this->actionability, [
|
|
||||||
'retryable_transient',
|
|
||||||
'permanent_configuration',
|
|
||||||
'prerequisite_missing',
|
|
||||||
'non_actionable',
|
|
||||||
], true)) {
|
|
||||||
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->nextSteps as $nextStep) {
|
|
||||||
if (! $nextStep instanceof NextStepOption) {
|
|
||||||
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function fromArray(array $data): ?self
|
|
||||||
{
|
|
||||||
$internalCode = is_string($data['internal_code'] ?? null)
|
|
||||||
? trim((string) $data['internal_code'])
|
|
||||||
: (is_string($data['internalCode'] ?? null) ? trim((string) $data['internalCode']) : '');
|
|
||||||
$operatorLabel = is_string($data['operator_label'] ?? null)
|
|
||||||
? trim((string) $data['operator_label'])
|
|
||||||
: (is_string($data['operatorLabel'] ?? null) ? trim((string) $data['operatorLabel']) : '');
|
|
||||||
$shortExplanation = is_string($data['short_explanation'] ?? null)
|
|
||||||
? trim((string) $data['short_explanation'])
|
|
||||||
: (is_string($data['shortExplanation'] ?? null) ? trim((string) $data['shortExplanation']) : '');
|
|
||||||
$actionability = is_string($data['actionability'] ?? null) ? trim((string) $data['actionability']) : '';
|
|
||||||
$nextSteps = is_array($data['next_steps'] ?? null)
|
|
||||||
? NextStepOption::collect($data['next_steps'])
|
|
||||||
: (is_array($data['nextSteps'] ?? null) ? NextStepOption::collect($data['nextSteps']) : []);
|
|
||||||
$showNoActionNeeded = (bool) ($data['show_no_action_needed'] ?? $data['showNoActionNeeded'] ?? false);
|
|
||||||
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
|
|
||||||
? trim((string) $data['diagnostic_code_label'])
|
|
||||||
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
|
|
||||||
|
|
||||||
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
internalCode: $internalCode,
|
|
||||||
operatorLabel: $operatorLabel,
|
|
||||||
shortExplanation: $shortExplanation,
|
|
||||||
actionability: $actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: $showNoActionNeeded,
|
|
||||||
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, NextStepOption> $nextSteps
|
|
||||||
*/
|
|
||||||
public function withNextSteps(array $nextSteps): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
internalCode: $this->internalCode,
|
|
||||||
operatorLabel: $this->operatorLabel,
|
|
||||||
shortExplanation: $this->shortExplanation,
|
|
||||||
actionability: $this->actionability,
|
|
||||||
nextSteps: $nextSteps,
|
|
||||||
showNoActionNeeded: $this->showNoActionNeeded,
|
|
||||||
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function firstNextStep(): ?NextStepOption
|
|
||||||
{
|
|
||||||
return $this->nextSteps[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function guidanceText(): ?string
|
|
||||||
{
|
|
||||||
$nextStep = $this->firstNextStep();
|
|
||||||
|
|
||||||
if ($nextStep instanceof NextStepOption) {
|
|
||||||
return 'Next step: '.rtrim($nextStep->label, ". \t\n\r\0\x0B").'.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->showNoActionNeeded) {
|
|
||||||
return 'No action needed.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function toBodyLines(bool $includeGuidance = true): array
|
|
||||||
{
|
|
||||||
$lines = [
|
|
||||||
$this->operatorLabel,
|
|
||||||
$this->shortExplanation,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($includeGuidance) {
|
|
||||||
$guidance = $this->guidanceText();
|
|
||||||
|
|
||||||
if (is_string($guidance) && $guidance !== '') {
|
|
||||||
$lines[] = $guidance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter($lines, static fn (?string $line): bool => is_string($line) && trim($line) !== ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function diagnosticCode(): string
|
|
||||||
{
|
|
||||||
return $this->diagnosticCodeLabel !== null && trim($this->diagnosticCodeLabel) !== ''
|
|
||||||
? $this->diagnosticCodeLabel
|
|
||||||
: $this->internalCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array{label: string, url: string}>
|
|
||||||
*/
|
|
||||||
public function toLegacyNextSteps(): array
|
|
||||||
{
|
|
||||||
return array_values(array_map(
|
|
||||||
static fn (NextStepOption $nextStep): array => $nextStep->toLegacyArray(),
|
|
||||||
array_filter(
|
|
||||||
$this->nextSteps,
|
|
||||||
static fn (NextStepOption $nextStep): bool => $nextStep->kind === 'link' && $nextStep->destination !== null,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* internal_code: string,
|
|
||||||
* operator_label: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* actionability: string,
|
|
||||||
* next_steps: array<int, array{
|
|
||||||
* label: string,
|
|
||||||
* kind: string,
|
|
||||||
* destination: ?string,
|
|
||||||
* authorization_required: bool,
|
|
||||||
* scope: string
|
|
||||||
* }>,
|
|
||||||
* show_no_action_needed: bool,
|
|
||||||
* diagnostic_code_label: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'internal_code' => $this->internalCode,
|
|
||||||
'operator_label' => $this->operatorLabel,
|
|
||||||
'short_explanation' => $this->shortExplanation,
|
|
||||||
'actionability' => $this->actionability,
|
|
||||||
'next_steps' => array_map(
|
|
||||||
static fn (NextStepOption $nextStep): array => $nextStep->toArray(),
|
|
||||||
$this->nextSteps,
|
|
||||||
),
|
|
||||||
'show_no_action_needed' => $this->showNoActionNeeded,
|
|
||||||
'diagnostic_code_label' => $this->diagnosticCode(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\ReasonTranslation;
|
|
||||||
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\Providers\ProviderReasonTranslator;
|
|
||||||
use App\Support\RbacReason;
|
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
|
||||||
|
|
||||||
final class ReasonTranslator
|
|
||||||
{
|
|
||||||
public const string EXECUTION_DENIAL_ARTIFACT = 'execution_denial_reason_code';
|
|
||||||
|
|
||||||
public const string TENANT_OPERABILITY_ARTIFACT = 'tenant_operability_reason_code';
|
|
||||||
|
|
||||||
public const string RBAC_ARTIFACT = 'rbac_reason';
|
|
||||||
|
|
||||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = 'governance_artifact_truth_reason';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
|
||||||
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function translate(
|
|
||||||
?string $reasonCode,
|
|
||||||
?string $artifactKey = null,
|
|
||||||
string $surface = 'detail',
|
|
||||||
array $context = [],
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : '';
|
|
||||||
|
|
||||||
if ($reasonCode === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
|
||||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
|
||||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
|
||||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
|
||||||
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
|
|
||||||
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
|
||||||
$artifactKey === self::RBAC_ARTIFACT,
|
|
||||||
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => RbacReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
|
||||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT => $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context),
|
|
||||||
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
|
||||||
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function fallbackTranslate(
|
|
||||||
string $reasonCode,
|
|
||||||
?string $artifactKey,
|
|
||||||
string $surface,
|
|
||||||
array $context,
|
|
||||||
): ?ReasonResolutionEnvelope {
|
|
||||||
if ($artifactKey === null) {
|
|
||||||
$normalizedCode = \App\Support\OpsUx\RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
|
||||||
|
|
||||||
if ($normalizedCode !== $reasonCode) {
|
|
||||||
return $this->translate($normalizedCode, null, $surface, $context + ['source_reason_code' => $reasonCode]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Support\Tenants;
|
namespace App\Support\Tenants;
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
enum TenantOperabilityReasonCode: string
|
enum TenantOperabilityReasonCode: string
|
||||||
{
|
{
|
||||||
case WorkspaceMismatch = 'workspace_mismatch';
|
case WorkspaceMismatch = 'workspace_mismatch';
|
||||||
@ -19,89 +16,4 @@ enum TenantOperabilityReasonCode: string
|
|||||||
case OnboardingNotResumable = 'onboarding_not_resumable';
|
case OnboardingNotResumable = 'onboarding_not_resumable';
|
||||||
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
case CanonicalViewFollowupOnly = 'canonical_view_followup_only';
|
||||||
case RememberedContextStale = 'remembered_context_stale';
|
case RememberedContextStale = 'remembered_context_stale';
|
||||||
|
|
||||||
public function operatorLabel(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'Workspace context changed',
|
|
||||||
self::TenantNotEntitled => 'Tenant access removed',
|
|
||||||
self::MissingCapability => 'Permission required',
|
|
||||||
self::WrongLane => 'Available from a different surface',
|
|
||||||
self::SelectorIneligibleLifecycle => 'Tenant unavailable in the current lifecycle',
|
|
||||||
self::TenantNotArchived => 'Tenant is not archived',
|
|
||||||
self::TenantAlreadyArchived => 'Tenant already archived',
|
|
||||||
self::OnboardingNotResumable => 'Onboarding cannot be resumed',
|
|
||||||
self::CanonicalViewFollowupOnly => 'Follow-up requires tenant context',
|
|
||||||
self::RememberedContextStale => 'Saved tenant context is stale',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function shortExplanation(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::WorkspaceMismatch => 'The current workspace scope no longer matches this tenant interaction.',
|
|
||||||
self::TenantNotEntitled => 'The current actor is no longer entitled to this tenant.',
|
|
||||||
self::MissingCapability => 'The current actor is missing the capability required for this tenant action.',
|
|
||||||
self::WrongLane => 'This question can only be completed from a different tenant interaction lane.',
|
|
||||||
self::SelectorIneligibleLifecycle => 'This tenant lifecycle is not selectable from the current surface.',
|
|
||||||
self::TenantNotArchived => 'This action requires an archived tenant, but the tenant is still active or onboarding.',
|
|
||||||
self::TenantAlreadyArchived => 'The tenant is already archived, so there is nothing else to do for this action.',
|
|
||||||
self::OnboardingNotResumable => 'This onboarding session can no longer be resumed from the current lifecycle state.',
|
|
||||||
self::CanonicalViewFollowupOnly => 'This canonical workspace view is informational only and cannot complete tenant follow-up directly.',
|
|
||||||
self::RememberedContextStale => 'The remembered tenant context is no longer valid for the current tenant selector state.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actionability(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::TenantAlreadyArchived => 'non_actionable',
|
|
||||||
self::SelectorIneligibleLifecycle, self::TenantNotArchived, self::OnboardingNotResumable, self::CanonicalViewFollowupOnly, self::RememberedContextStale => 'prerequisite_missing',
|
|
||||||
default => 'permanent_configuration',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, NextStepOption>
|
|
||||||
*/
|
|
||||||
public function nextSteps(): array
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::TenantAlreadyArchived => [],
|
|
||||||
self::MissingCapability => [
|
|
||||||
NextStepOption::instruction('Ask a tenant Owner to grant the required capability.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::TenantNotEntitled, self::WorkspaceMismatch => [
|
|
||||||
NextStepOption::instruction('Return to an entitled tenant context before retrying.', scope: 'workspace'),
|
|
||||||
],
|
|
||||||
self::WrongLane, self::CanonicalViewFollowupOnly => [
|
|
||||||
NextStepOption::instruction('Open the tenant-specific management surface for follow-up.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::SelectorIneligibleLifecycle, self::RememberedContextStale => [
|
|
||||||
NextStepOption::instruction('Refresh the tenant selector and choose an eligible tenant context.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::TenantNotArchived => [
|
|
||||||
NextStepOption::instruction('Archive the tenant before retrying this action.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
self::OnboardingNotResumable => [
|
|
||||||
NextStepOption::instruction('Review the onboarding record and start a new onboarding flow if needed.', scope: 'tenant'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
return new ReasonResolutionEnvelope(
|
|
||||||
internalCode: $this->value,
|
|
||||||
operatorLabel: $this->operatorLabel(),
|
|
||||||
shortExplanation: $this->shortExplanation(),
|
|
||||||
actionability: $this->actionability(),
|
|
||||||
nextSteps: $this->nextSteps(),
|
|
||||||
showNoActionNeeded: $this->actionability() === 'non_actionable',
|
|
||||||
diagnosticCodeLabel: $this->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
|
||||||
|
|
||||||
use App\Support\ReasonTranslation\NextStepOption;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
|
|
||||||
final readonly class ArtifactTruthCause
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $nextSteps
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public ?string $reasonCode,
|
|
||||||
public ?string $translationArtifact,
|
|
||||||
public ?string $operatorLabel,
|
|
||||||
public ?string $shortExplanation,
|
|
||||||
public ?string $diagnosticCode,
|
|
||||||
public array $nextSteps = [],
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function fromReasonResolutionEnvelope(
|
|
||||||
?ReasonResolutionEnvelope $reason,
|
|
||||||
?string $translationArtifact = null,
|
|
||||||
): ?self {
|
|
||||||
if (! $reason instanceof ReasonResolutionEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
reasonCode: $reason->internalCode,
|
|
||||||
translationArtifact: $translationArtifact,
|
|
||||||
operatorLabel: $reason->operatorLabel,
|
|
||||||
shortExplanation: $reason->shortExplanation,
|
|
||||||
diagnosticCode: $reason->diagnosticCode(),
|
|
||||||
nextSteps: array_values(array_map(
|
|
||||||
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
|
||||||
$reason->nextSteps,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* reasonCode: ?string,
|
|
||||||
* translationArtifact: ?string,
|
|
||||||
* operatorLabel: ?string,
|
|
||||||
* shortExplanation: ?string,
|
|
||||||
* diagnosticCode: ?string,
|
|
||||||
* nextSteps: array<int, string>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'reasonCode' => $this->reasonCode,
|
|
||||||
'translationArtifact' => $this->translationArtifact,
|
|
||||||
'operatorLabel' => $this->operatorLabel,
|
|
||||||
'shortExplanation' => $this->shortExplanation,
|
|
||||||
'diagnosticCode' => $this->diagnosticCode,
|
|
||||||
'nextSteps' => $this->nextSteps,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final readonly class ArtifactTruthDimension
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $axis,
|
|
||||||
public string $state,
|
|
||||||
public string $label,
|
|
||||||
public string $classification,
|
|
||||||
public ?BadgeDomain $badgeDomain = null,
|
|
||||||
public ?string $badgeState = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function isPrimary(): bool
|
|
||||||
{
|
|
||||||
return $this->classification === 'primary';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isDiagnostic(): bool
|
|
||||||
{
|
|
||||||
return $this->classification === 'diagnostic';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function badgeSpec(): ?BadgeSpec
|
|
||||||
{
|
|
||||||
if (! $this->badgeDomain instanceof BadgeDomain || ! is_string($this->badgeState) || trim($this->badgeState) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \App\Support\Badges\BadgeCatalog::spec($this->badgeDomain, $this->badgeState);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* axis: string,
|
|
||||||
* state: string,
|
|
||||||
* label: string,
|
|
||||||
* classification: string,
|
|
||||||
* badgeDomain: ?string,
|
|
||||||
* badgeState: ?string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'axis' => $this->axis,
|
|
||||||
'state' => $this->state,
|
|
||||||
'label' => $this->label,
|
|
||||||
'classification' => $this->classification,
|
|
||||||
'badgeDomain' => $this->badgeDomain?->value,
|
|
||||||
'badgeState' => $this->badgeState,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final readonly class ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<int, ArtifactTruthDimension> $dimensions
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public string $artifactFamily,
|
|
||||||
public string $artifactKey,
|
|
||||||
public int $workspaceId,
|
|
||||||
public ?int $tenantId,
|
|
||||||
public ?string $executionOutcome,
|
|
||||||
public string $artifactExistence,
|
|
||||||
public string $contentState,
|
|
||||||
public string $freshnessState,
|
|
||||||
public ?string $publicationReadiness,
|
|
||||||
public string $supportState,
|
|
||||||
public string $actionability,
|
|
||||||
public string $primaryLabel,
|
|
||||||
public ?string $primaryExplanation,
|
|
||||||
public ?string $diagnosticLabel,
|
|
||||||
public ?string $nextActionLabel,
|
|
||||||
public ?string $nextActionUrl,
|
|
||||||
public ?int $relatedRunId,
|
|
||||||
public ?string $relatedArtifactUrl,
|
|
||||||
public array $dimensions = [],
|
|
||||||
public ?ArtifactTruthCause $reason = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function primaryDimension(): ?ArtifactTruthDimension
|
|
||||||
{
|
|
||||||
foreach ($this->dimensions as $dimension) {
|
|
||||||
if ($dimension instanceof ArtifactTruthDimension && $dimension->isPrimary()) {
|
|
||||||
return $dimension;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function primaryBadgeSpec(): BadgeSpec
|
|
||||||
{
|
|
||||||
$dimension = $this->primaryDimension();
|
|
||||||
|
|
||||||
return $dimension?->badgeSpec() ?? \App\Support\Badges\BadgeSpec::unknown();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function nextStepText(): string
|
|
||||||
{
|
|
||||||
if (is_string($this->nextActionLabel) && trim($this->nextActionLabel) !== '') {
|
|
||||||
return $this->nextActionLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($this->actionability) {
|
|
||||||
'none' => 'No action needed',
|
|
||||||
'optional' => 'Review recommended',
|
|
||||||
default => 'Action required',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* artifactFamily: string,
|
|
||||||
* artifactKey: string,
|
|
||||||
* workspaceId: int,
|
|
||||||
* tenantId: ?int,
|
|
||||||
* executionOutcome: ?string,
|
|
||||||
* artifactExistence: string,
|
|
||||||
* contentState: string,
|
|
||||||
* freshnessState: string,
|
|
||||||
* publicationReadiness: ?string,
|
|
||||||
* supportState: string,
|
|
||||||
* actionability: string,
|
|
||||||
* primaryLabel: string,
|
|
||||||
* primaryExplanation: ?string,
|
|
||||||
* diagnosticLabel: ?string,
|
|
||||||
* nextActionLabel: ?string,
|
|
||||||
* nextActionUrl: ?string,
|
|
||||||
* relatedRunId: ?int,
|
|
||||||
* relatedArtifactUrl: ?string,
|
|
||||||
* dimensions: array<int, array{
|
|
||||||
* axis: string,
|
|
||||||
* state: string,
|
|
||||||
* label: string,
|
|
||||||
* classification: string,
|
|
||||||
* badgeDomain: ?string,
|
|
||||||
* badgeState: ?string
|
|
||||||
* }>,
|
|
||||||
* reason: ?array{
|
|
||||||
* reasonCode: ?string,
|
|
||||||
* translationArtifact: ?string,
|
|
||||||
* operatorLabel: ?string,
|
|
||||||
* shortExplanation: ?string,
|
|
||||||
* diagnosticCode: ?string,
|
|
||||||
* nextSteps: array<int, string>
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'artifactFamily' => $this->artifactFamily,
|
|
||||||
'artifactKey' => $this->artifactKey,
|
|
||||||
'workspaceId' => $this->workspaceId,
|
|
||||||
'tenantId' => $this->tenantId,
|
|
||||||
'executionOutcome' => $this->executionOutcome,
|
|
||||||
'artifactExistence' => $this->artifactExistence,
|
|
||||||
'contentState' => $this->contentState,
|
|
||||||
'freshnessState' => $this->freshnessState,
|
|
||||||
'publicationReadiness' => $this->publicationReadiness,
|
|
||||||
'supportState' => $this->supportState,
|
|
||||||
'actionability' => $this->actionability,
|
|
||||||
'primaryLabel' => $this->primaryLabel,
|
|
||||||
'primaryExplanation' => $this->primaryExplanation,
|
|
||||||
'diagnosticLabel' => $this->diagnosticLabel,
|
|
||||||
'nextActionLabel' => $this->nextActionLabel,
|
|
||||||
'nextActionUrl' => $this->nextActionUrl,
|
|
||||||
'relatedRunId' => $this->relatedRunId,
|
|
||||||
'relatedArtifactUrl' => $this->relatedArtifactUrl,
|
|
||||||
'dimensions' => array_values(array_map(
|
|
||||||
static fn (ArtifactTruthDimension $dimension): array => $dimension->toArray(),
|
|
||||||
array_filter(
|
|
||||||
$this->dimensions,
|
|
||||||
static fn (mixed $dimension): bool => $dimension instanceof ArtifactTruthDimension,
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
'reason' => $this->reason?->toArray(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,773 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineSnapshotResource;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\ReviewPackStatus;
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
final class ArtifactTruthPresenter
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ReasonPresenter $reasonPresenter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
$record instanceof BaselineSnapshot => $this->forBaselineSnapshot($record),
|
|
||||||
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshot($record),
|
|
||||||
$record instanceof TenantReview => $this->forTenantReview($record),
|
|
||||||
$record instanceof ReviewPack => $this->forReviewPack($record),
|
|
||||||
$record instanceof OperationRun => $this->forOperationRun($record),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
$snapshot->loadMissing('baselineProfile');
|
|
||||||
|
|
||||||
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
|
||||||
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
|
||||||
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
|
||||||
$isHistorical = (int) ($snapshot->baselineProfile?->active_snapshot_id ?? 0) !== (int) $snapshot->getKey()
|
|
||||||
&& $snapshot->baselineProfile !== null;
|
|
||||||
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
|
||||||
$severeGapReasons = array_filter(
|
|
||||||
$gapReasons,
|
|
||||||
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
|
||||||
ARRAY_FILTER_USE_BOTH,
|
|
||||||
);
|
|
||||||
$reasonCode = $this->firstReasonCode($severeGapReasons);
|
|
||||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
||||||
|
|
||||||
$artifactExistence = match (true) {
|
|
||||||
$isHistorical => 'historical_only',
|
|
||||||
! $hasItems => 'created_but_not_usable',
|
|
||||||
default => 'created',
|
|
||||||
};
|
|
||||||
|
|
||||||
$contentState = match ($fidelity) {
|
|
||||||
FidelityState::Full => $severeGapReasons === [] ? 'trusted' : 'partial',
|
|
||||||
FidelityState::Partial => 'partial',
|
|
||||||
FidelityState::ReferenceOnly => 'reference_only',
|
|
||||||
FidelityState::Unsupported => $hasItems ? 'unsupported' : 'empty',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (! $hasItems && $reasonCode !== null) {
|
|
||||||
$contentState = 'missing_input';
|
|
||||||
}
|
|
||||||
|
|
||||||
$freshnessState = $isHistorical ? 'stale' : 'current';
|
|
||||||
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
|
||||||
$actionability = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
|
||||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
|
||||||
$freshnessState === 'stale' => 'optional',
|
|
||||||
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
|
||||||
default => 'required',
|
|
||||||
};
|
|
||||||
|
|
||||||
[$primaryDomain, $primaryState, $primaryExplanation, $diagnosticLabel] = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => [
|
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
'historical_only',
|
|
||||||
'This snapshot remains readable for historical comparison, but it is not the current baseline artifact.',
|
|
||||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
|
||||||
],
|
|
||||||
$artifactExistence === 'created_but_not_usable' => [
|
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
'created_but_not_usable',
|
|
||||||
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
|
||||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
|
||||||
],
|
|
||||||
$contentState !== 'trusted' => [
|
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
|
||||||
$contentState,
|
|
||||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
|
||||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
|
||||||
'trusted',
|
|
||||||
'Structured capture content is available for this baseline snapshot.',
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
|
||||||
artifactFamily: 'baseline_snapshot',
|
|
||||||
artifactKey: 'baseline_snapshot:'.$snapshot->getKey(),
|
|
||||||
workspaceId: (int) $snapshot->workspace_id,
|
|
||||||
tenantId: null,
|
|
||||||
executionOutcome: null,
|
|
||||||
artifactExistence: $artifactExistence,
|
|
||||||
contentState: $contentState,
|
|
||||||
freshnessState: $freshnessState,
|
|
||||||
publicationReadiness: null,
|
|
||||||
supportState: $supportState,
|
|
||||||
actionability: $actionability,
|
|
||||||
primaryDomain: $primaryDomain,
|
|
||||||
primaryState: $primaryState,
|
|
||||||
primaryExplanation: $primaryExplanation,
|
|
||||||
diagnosticLabel: $diagnosticLabel,
|
|
||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
|
||||||
nextActionLabel: $this->nextActionLabel(
|
|
||||||
$actionability,
|
|
||||||
$reason,
|
|
||||||
match ($actionability) {
|
|
||||||
'required' => 'Inspect the related capture diagnostics before using this snapshot',
|
|
||||||
'optional' => 'Review the capture diagnostics before comparing this snapshot',
|
|
||||||
default => null,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
nextActionUrl: null,
|
|
||||||
relatedRunId: null,
|
|
||||||
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
|
||||||
includePublicationDimension: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
$snapshot->loadMissing('tenant');
|
|
||||||
|
|
||||||
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
|
||||||
$missingDimensions = (int) ($summary['missing_dimensions'] ?? 0);
|
|
||||||
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
|
|
||||||
$status = (string) $snapshot->status;
|
|
||||||
|
|
||||||
$artifactExistence = match ($status) {
|
|
||||||
'queued', 'generating' => 'not_created',
|
|
||||||
'expired', 'superseded' => 'historical_only',
|
|
||||||
'failed' => 'created_but_not_usable',
|
|
||||||
default => 'created',
|
|
||||||
};
|
|
||||||
|
|
||||||
$contentState = match (true) {
|
|
||||||
$artifactExistence === 'not_created' => 'missing_input',
|
|
||||||
$artifactExistence === 'historical_only' && $snapshot->completeness_state === 'missing' => 'empty',
|
|
||||||
$status === 'failed' => 'missing_input',
|
|
||||||
$snapshot->completeness_state === 'missing' => 'missing_input',
|
|
||||||
$snapshot->completeness_state === 'partial' => 'partial',
|
|
||||||
default => 'trusted',
|
|
||||||
};
|
|
||||||
|
|
||||||
if ((int) ($summary['dimension_count'] ?? 0) === 0 && $artifactExistence !== 'not_created') {
|
|
||||||
$contentState = 'empty';
|
|
||||||
}
|
|
||||||
|
|
||||||
$freshnessState = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'stale',
|
|
||||||
$snapshot->completeness_state === 'stale' || $staleDimensions > 0 => 'stale',
|
|
||||||
in_array($status, ['queued', 'generating'], true) => 'unknown',
|
|
||||||
default => 'current',
|
|
||||||
};
|
|
||||||
|
|
||||||
$actionability = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
|
||||||
in_array($status, ['queued', 'generating'], true) => 'optional',
|
|
||||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
|
||||||
$freshnessState === 'stale' => 'optional',
|
|
||||||
default => 'required',
|
|
||||||
};
|
|
||||||
|
|
||||||
$reasonCode = match (true) {
|
|
||||||
$status === 'failed' => 'evidence_generation_failed',
|
|
||||||
$missingDimensions > 0 => 'evidence_missing_dimensions',
|
|
||||||
$staleDimensions > 0 => 'evidence_stale_dimensions',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
||||||
|
|
||||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
|
||||||
$artifactExistence === 'not_created' => [
|
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
'not_created',
|
|
||||||
'The evidence generation request exists, but no tenant snapshot is available yet.',
|
|
||||||
],
|
|
||||||
$artifactExistence === 'historical_only' => [
|
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
'historical_only',
|
|
||||||
'This evidence snapshot remains available for history, but it is not the current working evidence artifact.',
|
|
||||||
],
|
|
||||||
$contentState !== 'trusted' => [
|
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
|
||||||
$contentState,
|
|
||||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
|
||||||
],
|
|
||||||
$freshnessState === 'stale' => [
|
|
||||||
BadgeDomain::GovernanceArtifactFreshness,
|
|
||||||
'stale',
|
|
||||||
$reason?->shortExplanation ?? 'The snapshot exists, but one or more evidence dimensions should be refreshed before relying on it.',
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
BadgeDomain::GovernanceArtifactContent,
|
|
||||||
'trusted',
|
|
||||||
'A current evidence snapshot is available for review work.',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
$nextActionUrl = $snapshot->operation_run_id
|
|
||||||
? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
|
||||||
artifactFamily: 'evidence_snapshot',
|
|
||||||
artifactKey: 'evidence_snapshot:'.$snapshot->getKey(),
|
|
||||||
workspaceId: (int) $snapshot->workspace_id,
|
|
||||||
tenantId: $snapshot->tenant_id !== null ? (int) $snapshot->tenant_id : null,
|
|
||||||
executionOutcome: null,
|
|
||||||
artifactExistence: $artifactExistence,
|
|
||||||
contentState: $contentState,
|
|
||||||
freshnessState: $freshnessState,
|
|
||||||
publicationReadiness: null,
|
|
||||||
supportState: 'normal',
|
|
||||||
actionability: $actionability,
|
|
||||||
primaryDomain: $primaryDomain,
|
|
||||||
primaryState: $primaryState,
|
|
||||||
primaryExplanation: $primaryExplanation,
|
|
||||||
diagnosticLabel: $missingDimensions > 0 && $staleDimensions > 0
|
|
||||||
? sprintf('%d missing, %d stale dimensions', $missingDimensions, $staleDimensions)
|
|
||||||
: null,
|
|
||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
|
||||||
nextActionLabel: $this->nextActionLabel(
|
|
||||||
$actionability,
|
|
||||||
$reason,
|
|
||||||
match ($actionability) {
|
|
||||||
'required' => 'Refresh evidence before using this snapshot',
|
|
||||||
'optional' => in_array($status, ['queued', 'generating'], true)
|
|
||||||
? 'Wait for evidence generation to finish'
|
|
||||||
: 'Review the evidence freshness before relying on this snapshot',
|
|
||||||
default => null,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
nextActionUrl: $nextActionUrl,
|
|
||||||
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
|
|
||||||
relatedArtifactUrl: $snapshot->tenant !== null
|
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
|
||||||
: null,
|
|
||||||
includePublicationDimension: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
$publishBlockers = $review->publishBlockers();
|
|
||||||
$status = $review->statusEnum();
|
|
||||||
$completeness = $review->completenessEnum()->value;
|
|
||||||
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
|
||||||
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
|
||||||
|
|
||||||
$artifactExistence = match ($status) {
|
|
||||||
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
|
||||||
TenantReviewStatus::Failed => 'created_but_not_usable',
|
|
||||||
default => 'created',
|
|
||||||
};
|
|
||||||
|
|
||||||
$contentState = match ($completeness) {
|
|
||||||
TenantReviewCompletenessState::Complete->value => 'trusted',
|
|
||||||
TenantReviewCompletenessState::Partial->value => 'partial',
|
|
||||||
TenantReviewCompletenessState::Missing->value => 'missing_input',
|
|
||||||
TenantReviewCompletenessState::Stale->value => 'trusted',
|
|
||||||
default => 'partial',
|
|
||||||
};
|
|
||||||
|
|
||||||
$freshnessState = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'stale',
|
|
||||||
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
|
|
||||||
default => 'current',
|
|
||||||
};
|
|
||||||
|
|
||||||
$publicationReadiness = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'internal_only',
|
|
||||||
$status === TenantReviewStatus::Published => 'publishable',
|
|
||||||
$publishBlockers !== [] => 'blocked',
|
|
||||||
$status === TenantReviewStatus::Ready => 'publishable',
|
|
||||||
default => 'internal_only',
|
|
||||||
};
|
|
||||||
|
|
||||||
$actionability = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
|
||||||
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
|
||||||
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
|
|
||||||
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
|
|
||||||
default => 'required',
|
|
||||||
};
|
|
||||||
|
|
||||||
$reasonCode = match (true) {
|
|
||||||
$publishBlockers !== [] => 'review_publish_blocked',
|
|
||||||
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
|
||||||
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
|
|
||||||
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
||||||
|
|
||||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => [
|
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
'historical_only',
|
|
||||||
'This review remains available as historical evidence, but it is no longer the current review artifact.',
|
|
||||||
],
|
|
||||||
$publicationReadiness === 'blocked' => [
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
'blocked',
|
|
||||||
$publishBlockers[0] ?? $reason?->shortExplanation ?? 'This review exists, but it is blocked from publication or export.',
|
|
||||||
],
|
|
||||||
$publicationReadiness === 'internal_only' => [
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
'internal_only',
|
|
||||||
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
|
||||||
],
|
|
||||||
$freshnessState === 'stale' => [
|
|
||||||
BadgeDomain::GovernanceArtifactFreshness,
|
|
||||||
'stale',
|
|
||||||
$reason?->shortExplanation ?? 'The review exists, but one or more required sections should be refreshed before publication.',
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
'publishable',
|
|
||||||
'This review is ready for publication and executive-pack export.',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
$nextActionUrl = $review->operation_run_id
|
|
||||||
? OperationRunLinks::tenantlessView((int) $review->operation_run_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ($publishBlockers !== [] && $review->tenant !== null) {
|
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
|
||||||
artifactFamily: 'tenant_review',
|
|
||||||
artifactKey: 'tenant_review:'.$review->getKey(),
|
|
||||||
workspaceId: (int) $review->workspace_id,
|
|
||||||
tenantId: $review->tenant_id !== null ? (int) $review->tenant_id : null,
|
|
||||||
executionOutcome: null,
|
|
||||||
artifactExistence: $artifactExistence,
|
|
||||||
contentState: $contentState,
|
|
||||||
freshnessState: $freshnessState,
|
|
||||||
publicationReadiness: $publicationReadiness,
|
|
||||||
supportState: 'normal',
|
|
||||||
actionability: $actionability,
|
|
||||||
primaryDomain: $primaryDomain,
|
|
||||||
primaryState: $primaryState,
|
|
||||||
primaryExplanation: $primaryExplanation,
|
|
||||||
diagnosticLabel: $contentState !== 'trusted'
|
|
||||||
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
|
||||||
: null,
|
|
||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
|
||||||
nextActionLabel: $this->nextActionLabel(
|
|
||||||
$actionability,
|
|
||||||
$reason,
|
|
||||||
match ($actionability) {
|
|
||||||
'required' => 'Resolve the review blockers before publication',
|
|
||||||
'optional' => 'Complete the remaining review work before publication',
|
|
||||||
default => null,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
nextActionUrl: $nextActionUrl,
|
|
||||||
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
|
|
||||||
relatedArtifactUrl: $review->tenant !== null
|
|
||||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
|
||||||
: null,
|
|
||||||
includePublicationDimension: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
$pack->loadMissing(['tenant', 'tenantReview']);
|
|
||||||
|
|
||||||
$summary = is_array($pack->summary) ? $pack->summary : [];
|
|
||||||
$status = (string) $pack->status;
|
|
||||||
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
|
||||||
$sourceReview = $pack->tenantReview;
|
|
||||||
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
|
||||||
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
|
||||||
|
|
||||||
$artifactExistence = match ($status) {
|
|
||||||
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
|
||||||
ReviewPackStatus::Expired->value => 'historical_only',
|
|
||||||
ReviewPackStatus::Failed->value => 'created_but_not_usable',
|
|
||||||
default => 'created',
|
|
||||||
};
|
|
||||||
|
|
||||||
$contentState = match (true) {
|
|
||||||
$artifactExistence === 'not_created' => 'missing_input',
|
|
||||||
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
|
||||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
|
|
||||||
default => 'trusted',
|
|
||||||
};
|
|
||||||
|
|
||||||
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
|
|
||||||
$publicationReadiness = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'internal_only',
|
|
||||||
$artifactExistence === 'not_created' => 'blocked',
|
|
||||||
$status === ReviewPackStatus::Failed->value => 'blocked',
|
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
|
|
||||||
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
|
||||||
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
|
||||||
};
|
|
||||||
|
|
||||||
$actionability = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
|
||||||
$publicationReadiness === 'publishable' => 'none',
|
|
||||||
$publicationReadiness === 'internal_only' => 'optional',
|
|
||||||
default => 'required',
|
|
||||||
};
|
|
||||||
|
|
||||||
$reasonCode = match (true) {
|
|
||||||
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
|
||||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
|
|
||||||
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
||||||
|
|
||||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
|
||||||
$artifactExistence === 'historical_only' => [
|
|
||||||
BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
'historical_only',
|
|
||||||
'This pack remains available as a historical export, but it is no longer the current stakeholder artifact.',
|
|
||||||
],
|
|
||||||
$publicationReadiness === 'blocked' => [
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
'blocked',
|
|
||||||
$sourceBlockers[0] ?? $reason?->shortExplanation ?? 'A pack file is not yet available for trustworthy stakeholder delivery.',
|
|
||||||
],
|
|
||||||
$publicationReadiness === 'internal_only' => [
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
'internal_only',
|
|
||||||
'This pack can be reviewed internally, but the source review is not currently publishable.',
|
|
||||||
],
|
|
||||||
default => [
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
'publishable',
|
|
||||||
'This executive pack is ready for stakeholder delivery.',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
$nextActionUrl = null;
|
|
||||||
|
|
||||||
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
|
||||||
} elseif ($pack->operation_run_id !== null) {
|
|
||||||
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
|
||||||
artifactFamily: 'review_pack',
|
|
||||||
artifactKey: 'review_pack:'.$pack->getKey(),
|
|
||||||
workspaceId: (int) $pack->workspace_id,
|
|
||||||
tenantId: $pack->tenant_id !== null ? (int) $pack->tenant_id : null,
|
|
||||||
executionOutcome: null,
|
|
||||||
artifactExistence: $artifactExistence,
|
|
||||||
contentState: $contentState,
|
|
||||||
freshnessState: $freshnessState,
|
|
||||||
publicationReadiness: $publicationReadiness,
|
|
||||||
supportState: 'normal',
|
|
||||||
actionability: $actionability,
|
|
||||||
primaryDomain: $primaryDomain,
|
|
||||||
primaryState: $primaryState,
|
|
||||||
primaryExplanation: $primaryExplanation,
|
|
||||||
diagnosticLabel: $contentState !== 'trusted'
|
|
||||||
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
|
||||||
: null,
|
|
||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
|
||||||
nextActionLabel: $this->nextActionLabel(
|
|
||||||
$actionability,
|
|
||||||
$reason,
|
|
||||||
match ($actionability) {
|
|
||||||
'required' => 'Open the source review before sharing this pack',
|
|
||||||
'optional' => 'Review the source review before sharing this pack',
|
|
||||||
default => null,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
nextActionUrl: $nextActionUrl,
|
|
||||||
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
|
|
||||||
relatedArtifactUrl: $pack->tenant !== null
|
|
||||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
|
||||||
: null,
|
|
||||||
includePublicationDimension: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
$artifact = $this->resolveArtifactForRun($run);
|
|
||||||
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
|
||||||
|
|
||||||
if ($artifact !== null) {
|
|
||||||
$artifactEnvelope = $this->for($artifact);
|
|
||||||
|
|
||||||
if ($artifactEnvelope instanceof ArtifactTruthEnvelope) {
|
|
||||||
$diagnosticParts = array_values(array_filter([
|
|
||||||
BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label !== 'Unknown'
|
|
||||||
? BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label
|
|
||||||
: null,
|
|
||||||
$artifactEnvelope->diagnosticLabel,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
|
||||||
artifactFamily: 'artifact_run',
|
|
||||||
artifactKey: 'artifact_run:'.$run->getKey(),
|
|
||||||
workspaceId: (int) $run->workspace_id,
|
|
||||||
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
|
||||||
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
|
||||||
artifactExistence: $artifactEnvelope->artifactExistence,
|
|
||||||
contentState: $artifactEnvelope->contentState,
|
|
||||||
freshnessState: $artifactEnvelope->freshnessState,
|
|
||||||
publicationReadiness: $artifactEnvelope->publicationReadiness,
|
|
||||||
supportState: $artifactEnvelope->supportState,
|
|
||||||
actionability: $artifactEnvelope->actionability,
|
|
||||||
primaryDomain: $artifactEnvelope->primaryDimension()?->badgeDomain ?? BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
primaryState: $artifactEnvelope->primaryDimension()?->badgeState ?? $artifactEnvelope->artifactExistence,
|
|
||||||
primaryExplanation: $artifactEnvelope->primaryExplanation ?? $reason?->shortExplanation ?? 'The run finished, but the related artifact needs review.',
|
|
||||||
diagnosticLabel: $diagnosticParts === [] ? null : implode(' · ', $diagnosticParts),
|
|
||||||
reason: $artifactEnvelope->reason,
|
|
||||||
nextActionLabel: $artifactEnvelope->nextActionLabel,
|
|
||||||
nextActionUrl: $artifactEnvelope->relatedArtifactUrl ?? $artifactEnvelope->nextActionUrl,
|
|
||||||
relatedRunId: (int) $run->getKey(),
|
|
||||||
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
|
||||||
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$artifactExistence = match ((string) $run->status) {
|
|
||||||
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'not_created',
|
|
||||||
default => 'not_created',
|
|
||||||
};
|
|
||||||
$contentState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
|
||||||
? 'missing_input'
|
|
||||||
: 'empty';
|
|
||||||
$actionability = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
|
||||||
? 'required'
|
|
||||||
: 'optional';
|
|
||||||
$primaryState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
|
||||||
? 'created_but_not_usable'
|
|
||||||
: 'not_created';
|
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
|
||||||
artifactFamily: 'artifact_run',
|
|
||||||
artifactKey: 'artifact_run:'.$run->getKey(),
|
|
||||||
workspaceId: (int) $run->workspace_id,
|
|
||||||
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
|
||||||
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
|
||||||
artifactExistence: $artifactExistence,
|
|
||||||
contentState: $contentState,
|
|
||||||
freshnessState: 'unknown',
|
|
||||||
publicationReadiness: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
|
||||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review'
|
|
||||||
? 'blocked'
|
|
||||||
: null,
|
|
||||||
supportState: 'normal',
|
|
||||||
actionability: $actionability,
|
|
||||||
primaryDomain: BadgeDomain::GovernanceArtifactExistence,
|
|
||||||
primaryState: $primaryState,
|
|
||||||
primaryExplanation: $reason?->shortExplanation ?? match ((string) $run->status) {
|
|
||||||
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'The artifact-producing run is still in progress, so no artifact is available yet.',
|
|
||||||
default => 'The run finished without a usable artifact result.',
|
|
||||||
},
|
|
||||||
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
|
||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
|
||||||
nextActionLabel: $this->nextActionLabel(
|
|
||||||
$actionability,
|
|
||||||
$reason,
|
|
||||||
$actionability === 'required'
|
|
||||||
? 'Inspect the blocked run details before retrying'
|
|
||||||
: '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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|EvidenceSnapshot|TenantReview|ReviewPack|null
|
|
||||||
{
|
|
||||||
return match (OperationCatalog::governanceArtifactFamily((string) $run->type)) {
|
|
||||||
'baseline_snapshot' => $run->relatedArtifactId() !== null
|
|
||||||
? BaselineSnapshot::query()->with('baselineProfile')->find($run->relatedArtifactId())
|
|
||||||
: null,
|
|
||||||
'evidence_snapshot' => EvidenceSnapshot::query()->with('tenant')->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
|
||||||
'tenant_review' => TenantReview::query()->with(['tenant', 'currentExportReviewPack'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
|
||||||
'review_pack' => ReviewPack::query()->with(['tenant', 'tenantReview'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function contentExplanation(string $contentState): string
|
|
||||||
{
|
|
||||||
return match ($contentState) {
|
|
||||||
'partial' => 'The artifact exists, but the captured content is incomplete for the primary operator task.',
|
|
||||||
'missing_input' => 'The artifact is blocked by missing upstream inputs or failed capture prerequisites.',
|
|
||||||
'metadata_only' => 'Only metadata was captured for this artifact. Use diagnostics for context, not as the primary truth signal.',
|
|
||||||
'reference_only' => 'Only reference-level placeholders were captured for this artifact.',
|
|
||||||
'empty' => 'The artifact row exists, but it does not contain usable captured content.',
|
|
||||||
'unsupported' => 'Structured support is limited for this artifact family, so the current rendering should be treated as diagnostic only.',
|
|
||||||
default => 'The artifact content is available for the intended workflow.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, int> $reasons
|
|
||||||
*/
|
|
||||||
private function firstReasonCode(array $reasons): ?string
|
|
||||||
{
|
|
||||||
foreach ($reasons as $reason => $count) {
|
|
||||||
if ((int) $count > 0 && is_string($reason) && trim($reason) !== '') {
|
|
||||||
return trim($reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function nextActionLabel(
|
|
||||||
string $actionability,
|
|
||||||
?ReasonResolutionEnvelope $reason,
|
|
||||||
?string $fallback = null,
|
|
||||||
): ?string {
|
|
||||||
if ($actionability === 'none') {
|
|
||||||
return 'No action needed';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($fallback) && trim($fallback) !== '') {
|
|
||||||
return $fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reason instanceof ReasonResolutionEnvelope && $reason->firstNextStep() !== null) {
|
|
||||||
return $reason->firstNextStep()?->label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeEnvelope(
|
|
||||||
string $artifactFamily,
|
|
||||||
string $artifactKey,
|
|
||||||
int $workspaceId,
|
|
||||||
?int $tenantId,
|
|
||||||
?string $executionOutcome,
|
|
||||||
string $artifactExistence,
|
|
||||||
string $contentState,
|
|
||||||
string $freshnessState,
|
|
||||||
?string $publicationReadiness,
|
|
||||||
string $supportState,
|
|
||||||
string $actionability,
|
|
||||||
BadgeDomain $primaryDomain,
|
|
||||||
string $primaryState,
|
|
||||||
?string $primaryExplanation,
|
|
||||||
?string $diagnosticLabel,
|
|
||||||
?ArtifactTruthCause $reason,
|
|
||||||
?string $nextActionLabel,
|
|
||||||
?string $nextActionUrl,
|
|
||||||
?int $relatedRunId,
|
|
||||||
?string $relatedArtifactUrl,
|
|
||||||
bool $includePublicationDimension,
|
|
||||||
): ArtifactTruthEnvelope {
|
|
||||||
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
|
||||||
$dimensions = [
|
|
||||||
$this->dimension(BadgeDomain::GovernanceArtifactExistence, $artifactExistence, 'artifact_existence', $primaryDomain === BadgeDomain::GovernanceArtifactExistence ? 'primary' : 'diagnostic'),
|
|
||||||
$this->dimension(BadgeDomain::GovernanceArtifactContent, $contentState, 'content_fidelity', $primaryDomain === BadgeDomain::GovernanceArtifactContent ? 'primary' : 'diagnostic'),
|
|
||||||
$this->dimension(BadgeDomain::GovernanceArtifactFreshness, $freshnessState, 'data_freshness', $primaryDomain === BadgeDomain::GovernanceArtifactFreshness ? 'primary' : 'diagnostic'),
|
|
||||||
$this->dimension(BadgeDomain::GovernanceArtifactActionability, $actionability, 'operator_actionability', 'diagnostic'),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($includePublicationDimension && $publicationReadiness !== null) {
|
|
||||||
$dimensions[] = $this->dimension(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
$publicationReadiness,
|
|
||||||
'publication_readiness',
|
|
||||||
$primaryDomain === BadgeDomain::GovernanceArtifactPublicationReadiness ? 'primary' : 'diagnostic',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($executionOutcome !== null && trim($executionOutcome) !== '') {
|
|
||||||
$dimensions[] = $this->dimension(BadgeDomain::OperationRunOutcome, $executionOutcome, 'execution_outcome', 'diagnostic');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($supportState === 'limited_support') {
|
|
||||||
$dimensions[] = new ArtifactTruthDimension(
|
|
||||||
axis: 'support_maturity',
|
|
||||||
state: 'limited_support',
|
|
||||||
label: 'Support limited',
|
|
||||||
classification: 'diagnostic',
|
|
||||||
badgeDomain: BadgeDomain::GovernanceArtifactContent,
|
|
||||||
badgeState: 'unsupported',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ArtifactTruthEnvelope(
|
|
||||||
artifactFamily: $artifactFamily,
|
|
||||||
artifactKey: $artifactKey,
|
|
||||||
workspaceId: $workspaceId,
|
|
||||||
tenantId: $tenantId,
|
|
||||||
executionOutcome: $executionOutcome,
|
|
||||||
artifactExistence: $artifactExistence,
|
|
||||||
contentState: $contentState,
|
|
||||||
freshnessState: $freshnessState,
|
|
||||||
publicationReadiness: $publicationReadiness,
|
|
||||||
supportState: $supportState,
|
|
||||||
actionability: $actionability,
|
|
||||||
primaryLabel: $primarySpec->label,
|
|
||||||
primaryExplanation: $primaryExplanation,
|
|
||||||
diagnosticLabel: $diagnosticLabel,
|
|
||||||
nextActionLabel: $nextActionLabel,
|
|
||||||
nextActionUrl: $nextActionUrl,
|
|
||||||
relatedRunId: $relatedRunId,
|
|
||||||
relatedArtifactUrl: $relatedArtifactUrl,
|
|
||||||
dimensions: array_values($dimensions),
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dimension(
|
|
||||||
BadgeDomain $domain,
|
|
||||||
string $state,
|
|
||||||
string $axis,
|
|
||||||
string $classification,
|
|
||||||
): ArtifactTruthDimension {
|
|
||||||
return new ArtifactTruthDimension(
|
|
||||||
axis: $axis,
|
|
||||||
state: $state,
|
|
||||||
label: BadgeCatalog::spec($domain, $state)->label,
|
|
||||||
classification: $classification,
|
|
||||||
badgeDomain: $domain,
|
|
||||||
badgeState: $state,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,7 +3,7 @@ # Product Roadmap
|
|||||||
> Strategic thematic blocks and release trajectory.
|
> Strategic thematic blocks and release trajectory.
|
||||||
> This is the "big picture" — not individual specs.
|
> This is the "big picture" — not individual specs.
|
||||||
|
|
||||||
**Last updated**: 2026-03-23
|
**Last updated**: 2026-03-21
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ ### Governance & Architecture Hardening
|
|||||||
|
|
||||||
**Active specs**: 144
|
**Active specs**: 144
|
||||||
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
||||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Provider Dispatch Gate Unification (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||||
|
|
||||||
### UI & Product Maturity Polish
|
### UI & Product Maturity Polish
|
||||||
|
|||||||
@ -3,9 +3,9 @@ # Spec Candidates
|
|||||||
> Concrete future specs waiting for prioritization.
|
> Concrete future specs waiting for prioritization.
|
||||||
> Each entry has enough structure to become a real spec when the time comes.
|
> Each entry has enough structure to become a real spec when the time comes.
|
||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-23 (added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, OperationRun humanization candidate, and absorbed extension targets updated)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -27,23 +27,28 @@ ## Inbox
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Promoted to Spec
|
|
||||||
|
|
||||||
> Historical ledger for candidates that are no longer open. Keep them here so prioritization stays clean without losing decision history.
|
|
||||||
|
|
||||||
- Queued Execution Reauthorization and Scope Continuity → Spec 149 (`queued-execution-reauthorization`)
|
|
||||||
- Livewire Context Locking and Trusted-State Reduction → Spec 152 (`livewire-context-locking`)
|
|
||||||
- Evidence Domain Foundation → Spec 153 (`evidence-domain-foundation`)
|
|
||||||
- Operator Outcome Taxonomy and Cross-Domain State Separation → Spec 156 (`operator-outcome-taxonomy`)
|
|
||||||
- Operator Reason Code Translation and Humanization Contract → Spec 157 (`reason-code-translation`)
|
|
||||||
- Governance Artifact Truthful Outcomes & Fidelity Semantics → Spec 158 (`artifact-truth-semantics`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Qualified
|
## Qualified
|
||||||
|
|
||||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||||
|
|
||||||
|
### Queued Execution Reauthorization and Scope Continuity
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: architecture audit 2026-03-15
|
||||||
|
- **Problem**: Queued work still relies too heavily on dispatch-time actor and tenant state. Execution-time scope continuity and capability revalidation are not yet hardened as a canonical backend contract.
|
||||||
|
- **Why it matters**: This is a backend trust-gap on the mutation path. It creates the class of failure where a UI action was valid at dispatch time but the queued execution is no longer legitimate when it runs.
|
||||||
|
- **Proposed direction**: Define execution-time reauthorization, tenant operability rechecks, denial semantics, and audit visibility as a dedicated spec instead of scattering local `authorize()` patches.
|
||||||
|
- **Dependencies**: Existing operations semantics, audit log foundation, queued job execution paths
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Livewire Context Locking and Trusted-State Reduction
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: architecture audit 2026-03-15
|
||||||
|
- **Problem**: Complex Livewire and Filament flows still expose ownership-relevant context in public component state without one explicit repo-wide hardening standard.
|
||||||
|
- **Why it matters**: This is a trust-boundary problem. Even without a known exploit, mutable client-visible identifiers and workflow context make future authorization and isolation mistakes more likely.
|
||||||
|
- **Proposed direction**: Define a reusable hardening pattern for locked identifiers, server-derived workflow truth, and forged-state regression tests on tier-1 component families.
|
||||||
|
- **Dependencies**: Managed tenant onboarding draft identity (Spec 138), onboarding lifecycle checkpoint work (Spec 140)
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
||||||
@ -107,6 +112,77 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
|
|||||||
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
|
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
|
||||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
||||||
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||||
|
- **Type**: foundation / hardening
|
||||||
|
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis
|
||||||
|
- **Problem**: TenantPilot uses ~12 overloaded words ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", "complete", "ready", "reference only", "metadata only", "needs attention") across at least 8 independent meaning axes that are systematically conflated. The product does not separate execution outcome from data completeness, evidence depth from product support maturity, governance deviation from publication readiness, data freshness from operator actionability. This produces a cross-product operator-trust problem: approximately 60% of warning-colored badges communicate something that is NOT a governance problem. Examples:
|
||||||
|
- Baseline snapshots show "Unsupported" (gray badge) and "Gaps present" (yellow badge) for policy types where the product simply uses a standard renderer — this is a product maturity fact, not a data quality failure, but it reads as a governance concern.
|
||||||
|
- Evidence completeness shows "Missing" (red/danger) when no findings exist yet — zero findings is a valid empty state, not missing evidence, but a new tenant permanently displays red badges.
|
||||||
|
- Restore runs show "Partial" (yellow) at both run and item level with different meanings — operators cannot determine scope of partial success or whether the tenant is in a consistent state.
|
||||||
|
- `OperationRunOutcome::PartiallySucceeded` provides no item-level breakdown — "99 of 100 succeeded" and "1 of 100 succeeded" are visually identical.
|
||||||
|
- "Blocked" appears across 4+ domains (operations, verification, restore, execution) without cause-specific explanation or next-action guidance.
|
||||||
|
- "Stale" is colored gray (passive/archived) when it actually represents data that requires operator attention (freshness issue, should be yellow/orange).
|
||||||
|
- Product support tier (e.g. fallback renderer vs. dedicated renderer) is surfaced on operator-facing badges where it should be diagnostics-only.
|
||||||
|
- **Why it matters now**: This is not cosmetic polish — it is a governance credibility problem. TenantPilot's core value proposition is trustworthy tenant governance and review. When 60% of warning badges are false alarms, operators are trained to ignore all warnings, which then masks the badges that represent real governance gaps. Every new domain (Entra Role Governance, Enterprise App Governance, Evidence Domain) will reproduce this conflation pattern unless a shared taxonomy is established first. The semantic-clarity audit classified three of the top five findings as P0 (actively damages operator trust). The problem is systemic and cross-domain — it cannot be solved by individual surface fixes without a shared foundation. The existing badge infrastructure (`BadgeCatalog`, `BadgeRenderer`, 43 badge domains, ~500 case values) is architecturally sound; the taxonomy feeding it is structurally wrong.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- **Define mandatory state-axis separation**: establish at minimum 8 independent axes that must never be flattened into a single badge or enum: execution lifecycle, execution outcome, item-level result, data coverage, evidence depth, product support tier, data freshness, and operator actionability. Each axis has its own vocabulary, its own badge domain, and its own color rules.
|
||||||
|
- **Cross-domain term dictionary**: produce a canonical vocabulary where each term has exactly one meaning across the entire product. Replace the 12 overloaded terms with axis-specific alternatives (e.g. "Partially succeeded" → item-breakdown-aware messaging; "Missing" → "Not collected" / "Not generated" / "Not granted" depending on axis; "Gaps" → categorized coverage notes with cause separation; "Unsupported" → "Standard rendering" moved to diagnostics only; "Stale" → freshness axis with correct severity color).
|
||||||
|
- **Color-severity decision rules**: codify when red/yellow/blue/green/gray are appropriate. Red = execution failure, governance violation, data loss risk. Yellow = operator action recommended, approaching threshold, mixed outcome. Blue = in-progress, informational. Green = succeeded, complete. Gray = archived, not applicable. Never use yellow for product maturity facts. Never use gray for freshness issues. Never use red for valid-empty states.
|
||||||
|
- **Diagnostic vs. primary classification**: every piece of state information must be classified as either primary (operator-facing badge/summary) or diagnostic (expandable/secondary technical detail). Product support tier, raw reason codes, Graph API error codes, internal IDs, and renderer metadata are diagnostic-only. Execution outcome, governance status, data freshness, and operator next-actions are primary.
|
||||||
|
- **Mandatory next-action rule**: every non-green, non-gray state must include either an inline explanation of what happened and whether action is needed, a link to a resolution path, or an explicit "No action needed — this is expected" indicator. States that fail this rule are treated as incomplete operator surfaces.
|
||||||
|
- **Shared reference document**: produce `docs/ui/operator-semantic-taxonomy.md` (or equivalent) that all domain specs, badge mappers, and new surface implementations reference. This becomes the cross-domain truth source for operator-facing state presentation.
|
||||||
|
- **Key domain decisions to encode**:
|
||||||
|
- Product support maturity (renderer tier, capture mode) is NEVER operator-alarming — it belongs in diagnostics
|
||||||
|
- Valid empty states (zero findings, zero operations, no evidence yet) are NEVER "Missing" (red) — they are "Not yet collected" (neutral) or "Empty" (informational)
|
||||||
|
- Freshness is a separate axis from completeness — stale data requires action (yellow/orange), not archival (gray)
|
||||||
|
- "Partial" must always be qualified: partial execution (N of M items), partial coverage (which dimensions), partial depth (which items) — never bare "Partial" without context
|
||||||
|
- "Blocked" must always specify cause and next action
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: state-axis separation rules, term dictionary, color-severity rules, diagnostic/primary classification, next-action policy, enum/badge restructuring guidance, shared reference document, domain adoption sequence recommendations
|
||||||
|
- **Out of scope**: individual domain adoption (baseline cleanup, evidence reclassification, restore semantic cleanup, etc. are separate domain follow-up specs that consume this foundation), badge rendering infrastructure changes (BadgeCatalog/BadgeRenderer are architecturally sound — the taxonomy they consume is the problem), visual design system or theme work, new component development, operation naming vocabulary (tracked separately as "Operations Naming Harmonization")
|
||||||
|
- **Why this should be one coherent candidate rather than fragmented domain fixes**: The semantic-clarity audit proves the problem is structural, not local. The same 12 overloaded terms leak into every domain independently. Fixing baselines without a shared taxonomy produces a different vocabulary than fixing evidence, which produces a different vocabulary than fixing operations. Domain-by-domain cleanup without a shared foundation guarantees vocabulary drift between domains. This foundation spec defines rules; domain specs apply them. The foundation is small and decisive (it produces a reference document and restructuring guidelines); the domain adoption specs do the actual refactoring.
|
||||||
|
- **Affected workflow families / surfaces**: Operations (all run types), Baselines (snapshots, profiles, compare), Evidence (snapshots, completeness), Findings (governance validity, diff messages), Reviews / Review Packs (completeness, freshness, publication readiness), Restore (run status, item results, preview decisions), Inventory (KPI badges, coverage, snapshot mode), Onboarding / Verification (report status, check status), Alerts / Notifications (delivery status, failure messages)
|
||||||
|
- **Dependencies**: None — this is foundational work that other candidates consume. The badge infrastructure (`BadgeCatalog`, `BadgeRenderer`) is stable and does not need changes — only the taxonomy it serves.
|
||||||
|
- **Related specs / candidates**: Baseline Capture Truthful Outcomes (consumes this taxonomy for baseline-specific reason codes), Operator Presentation & Lifecycle Action Hardening (complementary — rendering enforcement), Operations Naming Harmonization (complementary — operation type vocabulary), Surface Signal-to-Noise Optimization (complementary — visual weight hierarchy), Admin Visual Language Canon (broader visual convention codification), semantic-clarity-audit.md (source audit)
|
||||||
|
- **Strategic sequencing**: This is the recommended FIRST candidate in the operator-truth initiative sequence. The Reason Code Translation candidate depends on this taxonomy to define human-readable label targets. The Provider Dispatch Gate candidate benefits from shared outcome vocabulary for preflight results. Without this foundation, both downstream candidates will invent local vocabularies.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Operator Reason Code Translation and Humanization Contract
|
||||||
|
- **Type**: hardening
|
||||||
|
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis, cross-domain reason code inventory
|
||||||
|
- **Problem**: TenantPilot has 6 distinct reason-code artifacts across 4 different structural patterns (final class with string constants, backed enum with `->message()`, backed enum without `->message()`, spec-level taxonomy) spread across provider, baseline, execution, operability, RBAC, and verification domains. These reason codes are the backend's primary mechanism for explaining why an operation was blocked, denied, degraded, or failed. But the product lacks a consistent translation/humanization contract for surfacing these codes to operators:
|
||||||
|
- `ProviderReasonCodes` (24 codes) and `BaselineReasonCodes` (7 codes) are raw string constants with **no human-readable translation** — they leak directly into run detail contexts, notifications, and banners as technical fragments like `RateLimited`, `ProviderAuthFailed`, `ConsentNotGranted`, `CredentialsInvalid`.
|
||||||
|
- `TenantOperabilityReasonCode` (10 cases) and `RbacReason` (10 cases) are backed enums with **no `->message()` method** — they can reach operator-facing surfaces as raw enum values without translation.
|
||||||
|
- `BaselineCompareReasonCode` (5 cases) and `ExecutionDenialReasonCode` (9 cases) have inline `->message()` methods with hardcoded English strings — better, but inconsistent with the rest.
|
||||||
|
- `RunFailureSanitizer` already performs ad-hoc normalization across reason taxonomies using heuristic string-matching and its own `REASON_*` constants, proving the need for a systematic approach.
|
||||||
|
- `ProviderNextStepsRegistry` maps some provider reason codes to link-only remediation steps — but this is limited to provider-domain codes and provides navigation links, not human-readable explanations.
|
||||||
|
- Notification payloads (`OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`) include sanitized but still technical reason strings — operators receive "Execution was blocked. Rate limited." without retry guidance or contextual explanation.
|
||||||
|
- Run summary counts expose raw internal keys (`errors_recorded`, `report_deduped`, `posture_score`) in operator-facing summary lines via `SummaryCountsNormalizer`.
|
||||||
|
- **Why it matters now**: Reason codes are the backend's richest source of "why did this happen?" truth. The backend frequently already knows the cause, the prerequisite, and the next step — but this knowledge reaches operators as raw technical fragments because there is no systematic translation layer. As TenantPilot adds more provider domains, more operation types, and more governance workflows, every new reason code that lacks a human-readable translation reproduces the same operator-trust degradation. Some parts of the product already translate codes well (`BaselineCompareReasonCode::message()`, `RunbookReason::options()`), proving the pattern is viable. The gap is not the pattern — it is its inconsistent adoption across all 6+ reason-code families.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- **Reason code humanization contract**: every reason-code artifact (whether `final class` constants or backed enums) must provide a `->label()` or equivalent method that returns a human-readable, operator-appropriate string. Raw string constants that cannot provide methods must be wrapped in or migrated to enums that can.
|
||||||
|
- **Translation target vocabulary**: human-readable labels must use the vocabulary defined by the Operator Outcome Taxonomy. Internal codes remain stable for machines/logs/tests; operator-facing surfaces exclusively use translated labels. The translation contract is the bridge between internal precision and external clarity.
|
||||||
|
- **Structured reason resolution**: reason code translation should return not just a label but a structured resolution envelope containing: (1) human-readable label, (2) optional short explanation, (3) optional next-action link/text, (4) severity classification (retryable transient vs. permanent configuration vs. prerequisite missing). This envelope replaces the current pattern of ad-hoc string formatting in `RunFailureSanitizer`, notification builders, and presenter classes.
|
||||||
|
- **Cross-domain registry or convention**: either a central `ReasonCodeTranslator` service that dispatches to domain-specific translators, or a mandatory `Translatable` interface/trait that all reason-code artifacts must implement. Prefer the latter (each domain owns its translations) over a central monolith, but enforce the contract architecturally.
|
||||||
|
- **Summary count humanization**: `SummaryCountsNormalizer` (or its successor) must map internal metric keys to operator-readable labels. `errors_recorded` → "Errors", `report_deduped` → "Reports deduplicated", etc. Raw internal keys must never reach operator-facing summary lines.
|
||||||
|
- **Next-steps enrichment**: expand `ProviderNextStepsRegistry` pattern to all reason-code families — not just provider codes. Every operator-visible reason code that implies a prerequisite or recoverable condition should include actionable next-step guidance (link, instruction, or "contact support" fallback).
|
||||||
|
- **Notification payload cleanup**: notification builders (`OperationUxPresenter`, terminal notifications) must consume translated labels, not raw reason strings. Failure messages must include cause + retryability + next action, not just a sanitized error string.
|
||||||
|
- **Key decisions to encode**:
|
||||||
|
- Internal codes remain stable and must not be renamed for cosmetic reasons — they are machine contracts used in logs, tests, and audit events
|
||||||
|
- Operator-facing surfaces exclusively use translated labels — raw codes move to diagnostic/secondary detail areas
|
||||||
|
- Every reason code must be classifiable as retryable-transient, permanent-configuration, or prerequisite-missing — this classification drives notification tone and next-action guidance
|
||||||
|
- `RunFailureSanitizer` should be superseded by the structured translation contract, not extended with more heuristic string-matching
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: reason code humanization contract, translation interface/trait, structured resolution envelope, migration plan for existing 6 artifacts, summary count humanization, notification payload cleanup, next-steps enrichment across all reason families
|
||||||
|
- **Out of scope**: creating new reason codes (domain specs own that), changing the semantic meaning of existing codes, badge infrastructure changes (badges consume translated labels — the rendering infrastructure is stable), operation naming vocabulary (tracked separately), individual domain-specific notification redesign beyond label substitution
|
||||||
|
- **Affected workflow families / surfaces**: Operations (run detail, summary, notifications), Provider (connection health, blocked notifications, next-steps banners), Baselines (compare skip reasons, capture precondition reasons), Restore (run result messages, check severity explanations), Verification (check status explanations), RBAC (health check reasons), Onboarding (lifecycle denial reasons), Findings (diff unavailable messages, governance validity labels), System Console (triage, failure details)
|
||||||
|
- **Why this should be one coherent candidate rather than fragmented per-domain fixes**: The 6 reason-code artifacts share the same structural gap (no consistent humanization contract), and per-domain fixes would each invent a different translation pattern. The contract must be defined once so that all domains implement it consistently. A per-domain approach would produce 6 different label formats, 6 different next-step patterns, and no shared resolution envelope — exactly the inconsistency this candidate eliminates. The implementation touches each domain's reason-code artifact, but the contract that governs all of them must be a single decision.
|
||||||
|
- **Dependencies**: Operator Outcome Taxonomy (provides the target vocabulary that reason code labels translate into). Soft dependency — translation work can begin with pragmatic labels before the full taxonomy is ratified, but labels should converge with the taxonomy once available.
|
||||||
|
- **Related specs / candidates**: Operator Outcome Taxonomy and Cross-Domain State Separation (provides vocabulary target), Operator Presentation & Lifecycle Action Hardening (provides rendering enforcement), Baseline Capture Truthful Outcomes (consumes baseline-specific reason code translations), Provider Connection Resolution Normalization (provides backend connection plumbing that gate results reference), Operations Naming Harmonization (complementary — operation type labels vs. reason code labels)
|
||||||
|
- **Strategic sequencing**: Recommended SECOND in the operator-truth initiative sequence, after the Outcome Taxonomy. Can begin in parallel if pragmatic interim labels are acceptable, but final label convergence depends on the taxonomy.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
@ -138,106 +214,21 @@ ### Provider-Backed Action Preflight and Dispatch Gate Unification
|
|||||||
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
||||||
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
||||||
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
||||||
- **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work.
|
- **Strategic sequencing**: Recommended THIRD in the operator-truth initiative sequence. Benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation. However, the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with the other two candidates.
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Governance Operator Outcome Compression
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`)
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next?
|
|
||||||
- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions
|
|
||||||
- Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only**
|
|
||||||
- Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action**
|
|
||||||
- Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces
|
|
||||||
- Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs
|
|
||||||
- Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation
|
|
||||||
- **Primary adoption surfaces**:
|
|
||||||
- Baseline snapshot lists and detail pages
|
|
||||||
- Evidence snapshot lists and detail pages
|
|
||||||
- Evidence overview
|
|
||||||
- Tenant review lists and detail pages
|
|
||||||
- Review register
|
|
||||||
- Review pack lists and detail pages
|
|
||||||
- Shared governance detail templates and artifact-truth presenter surfaces
|
|
||||||
- Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy
|
|
||||||
- **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation
|
|
||||||
- **Core product principles to encode**:
|
|
||||||
- One primary operator statement per artifact on scan surfaces
|
|
||||||
- No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available
|
|
||||||
- Diagnostics are second-layer, not the default operator language
|
|
||||||
- Context-specific business language beats architecture-first vocabulary on primary governance surfaces
|
|
||||||
- Lists are scan surfaces, not diagnosis surfaces
|
|
||||||
- **Candidate requirements**:
|
|
||||||
- **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model
|
|
||||||
- **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier
|
|
||||||
- **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes
|
|
||||||
- **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics
|
|
||||||
- **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI
|
|
||||||
- **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not
|
|
||||||
- **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed
|
|
||||||
- **Acceptance points**:
|
|
||||||
- Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model
|
|
||||||
- `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces
|
|
||||||
- Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics
|
|
||||||
- Review and pack surfaces clearly answer whether the artifact is ready to publish or share
|
|
||||||
- Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable
|
|
||||||
- Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON
|
|
||||||
- The internal truth model remains fully usable for diagnostics, audit, and downstream APIs
|
|
||||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight
|
|
||||||
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate
|
|
||||||
- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Humanized Diagnostic Summaries for Governance Operations
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy.
|
|
||||||
- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON
|
|
||||||
- Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context
|
|
||||||
- Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful
|
|
||||||
- Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success
|
|
||||||
- Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration
|
|
||||||
- Keep raw JSON and low-level context fully available, but explicitly secondary
|
|
||||||
- **Primary adoption surfaces**:
|
|
||||||
- Canonical Monitoring run-detail pages for governance operation types
|
|
||||||
- Shared tenantless canonical run viewers and run-detail templates
|
|
||||||
- Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families
|
|
||||||
- **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages
|
|
||||||
- **Acceptance points**:
|
|
||||||
- A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON
|
|
||||||
- Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge
|
|
||||||
- Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state
|
|
||||||
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
|
|
||||||
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
|
|
||||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
|
|
||||||
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate
|
|
||||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
> **Operator Truth Initiative — Sequencing Note**
|
> **Operator Truth Initiative — Sequencing Note**
|
||||||
>
|
>
|
||||||
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
|
> The three candidates above (Operator Outcome Taxonomy, Reason Code Translation, Provider Dispatch Gate Unification) form a coherent cross-product initiative addressing the systemic gap between backend truth richness and operator-facing truth quality. They are sequenced as a dependency chain with parallelization opportunities:
|
||||||
>
|
>
|
||||||
> **Recommended order:**
|
> **Recommended order:**
|
||||||
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge.
|
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge.
|
||||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||||
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
> 3. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from both upstream candidates but has significant backend scope (gate extension + scope-busy enforcement) that can proceed independently.
|
||||||
> 4. **Governance Operator Outcome Compression** — applies the foundation to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
|
||||||
> 5. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
|
||||||
>
|
>
|
||||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The compression follow-up is then what turns that engine into a scanable operator cockpit before more governance features land. Gate unification remains highly valuable, but it is a neighboring hardening lane rather than the immediate follow-up needed to make governance truth semantics feel product-ready.
|
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation and gate unification are both P1-level (strongly confusing, should be fixed soon). The taxonomy is also the smallest and most decisive deliverable — it produces a reference that all other candidates consume. Shipping the taxonomy first prevents the other two candidates from making locally correct but globally inconsistent vocabulary choices. The gate unification has the largest implementation surface (~20 services) but much of its backend work (extending `ProviderOperationStartGate` scope, adding connection locking, dedup enforcement) can proceed in parallel once the taxonomy establishes the shared vocabulary for gate result presentation.
|
||||||
>
|
>
|
||||||
> **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. Governance operator outcome compression is a UI-information-architecture adoption slice across governance surfaces. 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.
|
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundaries. The taxonomy is a cross-cutting decision document. Reason code translation touches 6+ reason-code artifacts and notification builders. Gate unification touches ~20 services, the gate class, Filament action handlers, and notification templates. Merging them would create an unshippable monolith. Keeping them as a sequenced initiative preserves independent delivery while ensuring vocabulary convergence.
|
||||||
|
|
||||||
### Baseline Snapshot Fidelity Semantics
|
### Baseline Snapshot Fidelity Semantics
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
@ -278,6 +269,18 @@ ### Exception / Risk-Acceptance Workflow for Findings
|
|||||||
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
|
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Evidence Domain Foundation
|
||||||
|
- **Type**: feature
|
||||||
|
- **Source**: HANDOVER gap, R2 theme completion
|
||||||
|
- **Vehicle note**: Promoted into existing Spec 153. Do not create a second evidence-domain candidate for semantic cleanup; extend Spec 153 when evidence completeness / freshness semantics need to be corrected.
|
||||||
|
- **Problem**: Review pack export (Spec 109) and permission posture reports (104/105) exist as separate output artifacts. There is no first-class evidence domain model that curates, bundles, and tracks these artifacts as a coherent compliance deliverable for external audit submission.
|
||||||
|
- **Why it matters**: Enterprise customers need a single, versioned, auditor-ready package — not a collection of separate exports assembled manually. The gap is not export packaging (Spec 109 handles that); it is the absence of an evidence domain layer that owns curation, completeness tracking, and audit-trail linkage.
|
||||||
|
- **Proposed direction**: Evidence domain model with curated artifact references (review packs, posture reports, findings summaries, baseline governance snapshots). Completeness metadata. Immutable snapshots with generation timestamp and actor. Not a re-implementation of export — a higher-order assembly layer.
|
||||||
|
- **Explicit non-goals**: Not a presentation or reporting layer — this candidate owns data curation, completeness tracking, artifact storage, and immutable snapshots. Executive summaries, framework-oriented readiness views, management-ready outputs, and stakeholder-facing packaging belong to the Compliance Readiness & Executive Review Packs candidate, which consumes this foundation. Not a replacement for Spec 109's export packaging. Not a generic BI or data warehouse initiative.
|
||||||
|
- **Boundary with Compliance Readiness**: Evidence Domain Foundation = lower-level data assembly (what artifacts exist, are they complete, are they immutable). Compliance Readiness = upper-level presentation (how to arrange evidence into framework-oriented, stakeholder-facing deliverables). This candidate is a prerequisite; Compliance Readiness is a downstream consumer.
|
||||||
|
- **Dependencies**: Review pack export (109), permission posture (104/105)
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
### Compliance Readiness & Executive Review Packs
|
### Compliance Readiness & Executive Review Packs
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
||||||
|
|||||||
@ -77,9 +77,9 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
|
|||||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
{{ $fact['label'] ?? 'Fact' }}
|
{{ $fact['label'] ?? 'Fact' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
@if ($displayValue !== null)
|
@if ($displayValue !== null)
|
||||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
<span>{{ $displayValue }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($badge !== null)
|
@if ($badge !== null)
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
:collapsed="(bool) ($section['collapsed'] ?? false)"
|
:collapsed="(bool) ($section['collapsed'] ?? false)"
|
||||||
>
|
>
|
||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||||
@elseif ($items !== [])
|
@elseif ($items !== [])
|
||||||
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
|
|||||||
@ -19,9 +19,9 @@
|
|||||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
{{ $item['label'] ?? 'Detail' }}
|
{{ $item['label'] ?? 'Detail' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
@if ($displayValue !== null)
|
@if ($displayValue !== null)
|
||||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
<span>{{ $displayValue }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($badge !== null)
|
@if ($badge !== null)
|
||||||
|
|||||||
@ -35,7 +35,7 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
|||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
@include($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])
|
||||||
@elseif ($items !== [])
|
@elseif ($items !== [])
|
||||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
||||||
@elseif ($emptyState !== null)
|
@elseif ($emptyState !== null)
|
||||||
|
|||||||
@ -20,10 +20,10 @@
|
|||||||
@if ($view !== null)
|
@if ($view !== null)
|
||||||
@if ($entries !== [])
|
@if ($entries !== [])
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||||
@endif
|
@endif
|
||||||
@elseif ($emptyState !== null)
|
@elseif ($emptyState !== null)
|
||||||
<div @class(['mt-4' => $entries !== []])>
|
<div @class(['mt-4' => $entries !== []])>
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
@php
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
|
|
||||||
$resolvedState = isset($getState) ? $getState() : ($artifactTruthState ?? ($state ?? null));
|
|
||||||
$state = is_array($resolvedState) ? $resolvedState : [];
|
|
||||||
$dimensions = collect(is_array($state['dimensions'] ?? null) ? $state['dimensions'] : []);
|
|
||||||
|
|
||||||
$primary = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['classification'] ?? null) === 'primary');
|
|
||||||
$existence = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'artifact_existence');
|
|
||||||
$freshness = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'data_freshness');
|
|
||||||
$publication = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'publication_readiness');
|
|
||||||
$actionability = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'operator_actionability');
|
|
||||||
|
|
||||||
$specFor = static function (mixed $dimension): ?\App\Support\Badges\BadgeSpec {
|
|
||||||
if (! is_array($dimension)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($dimension['badgeDomain'] ?? null) || ! is_string($dimension['badgeState'] ?? null)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BadgeCatalog::spec(BadgeDomain::from($dimension['badgeDomain']), $dimension['badgeState']);
|
|
||||||
};
|
|
||||||
|
|
||||||
$primarySpec = $specFor($primary);
|
|
||||||
$existenceSpec = $specFor($existence);
|
|
||||||
$freshnessSpec = $specFor($freshness);
|
|
||||||
$publicationSpec = $specFor($publication);
|
|
||||||
$actionabilitySpec = $specFor($actionability);
|
|
||||||
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
|
||||||
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
|
||||||
@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 ($primarySpec)
|
|
||||||
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
|
||||||
{{ $primarySpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($actionabilitySpec)
|
|
||||||
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
|
|
||||||
{{ $actionabilitySpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 space-y-2">
|
|
||||||
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
|
|
||||||
{{ $state['primaryLabel'] ?? 'Artifact truth' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (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($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
|
||||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
Diagnostic: {{ $state['diagnosticLabel'] }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
@if ($existenceSpec)
|
|
||||||
<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">Artifact exists</dt>
|
|
||||||
<dd class="mt-1">
|
|
||||||
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
|
|
||||||
{{ $existenceSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($freshnessSpec)
|
|
||||||
<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">Freshness</dt>
|
|
||||||
<dd class="mt-1">
|
|
||||||
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
|
||||||
{{ $freshnessSpec->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($publicationSpec)
|
|
||||||
<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">Publication</dt>
|
|
||||||
<dd class="mt-1">
|
|
||||||
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
|
|
||||||
{{ $publicationSpec->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' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
@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>
|
|
||||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
@foreach ($nextSteps as $step)
|
|
||||||
@continue(! is_string($step) || trim($step) === '')
|
|
||||||
|
|
||||||
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
|
|
||||||
{{ $step }}
|
|
||||||
</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@ -1,3 +1,8 @@
|
|||||||
|
@php
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@if ($rows === [])
|
@if ($rows === [])
|
||||||
@ -16,36 +21,28 @@
|
|||||||
<thead class="bg-gray-50 text-left text-gray-600">
|
<thead class="bg-gray-50 text-left text-gray-600">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium">Tenant</th>
|
<th class="px-4 py-3 font-medium">Tenant</th>
|
||||||
<th class="px-4 py-3 font-medium">Artifact truth</th>
|
<th class="px-4 py-3 font-medium">Completeness</th>
|
||||||
<th class="px-4 py-3 font-medium">Freshness</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Generated</th>
|
<th class="px-4 py-3 font-medium">Generated</th>
|
||||||
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
||||||
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
||||||
<th class="px-4 py-3 font-medium">Next step</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Action</th>
|
<th class="px-4 py-3 font-medium">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
|
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
|
||||||
@foreach ($rows as $row)
|
@foreach ($rows as $row)
|
||||||
|
@php
|
||||||
|
$completenessSpec = BadgeRenderer::spec(BadgeDomain::EvidenceCompleteness, $row['completeness_state'] ?? null);
|
||||||
|
@endphp
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<x-filament::badge :color="data_get($row, 'artifact_truth.color', 'gray')" :icon="data_get($row, 'artifact_truth.icon')" size="sm">
|
<x-filament::badge :color="$completenessSpec->color" :icon="$completenessSpec->icon" size="sm">
|
||||||
{{ data_get($row, 'artifact_truth.label', 'Unknown') }}
|
{{ $completenessSpec->label }}
|
||||||
</x-filament::badge>
|
|
||||||
@if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '')
|
|
||||||
<div class="mt-1 text-xs text-gray-500">{{ data_get($row, 'artifact_truth.explanation') }}</div>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<x-filament::badge :color="data_get($row, 'freshness.color', 'gray')" :icon="data_get($row, 'freshness.icon')" size="sm">
|
|
||||||
{{ data_get($row, 'freshness.label', 'Unknown') }}
|
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
||||||
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
||||||
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
||||||
<td class="px-4 py-3">{{ $row['next_step'] ?? 'No action needed' }}</td>
|
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
|
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
# Specification Quality Checklist: Operator Reason Code Translation and Humanization Contract
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
||||||
**Created**: 2026-03-22
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] No implementation details (languages, frameworks, APIs)
|
|
||||||
- [x] Focused on user value and business needs
|
|
||||||
- [x] Written for non-technical stakeholders
|
|
||||||
- [x] All mandatory sections completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Success criteria are measurable
|
|
||||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
||||||
- [x] All acceptance scenarios are defined
|
|
||||||
- [x] Edge cases are identified
|
|
||||||
- [x] Scope is clearly bounded
|
|
||||||
- [x] Dependencies and assumptions identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] All functional requirements have clear acceptance criteria
|
|
||||||
- [x] User scenarios cover primary flows
|
|
||||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
||||||
- [x] No implementation details leak into specification
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Validation pass completed after correcting the feature number to `157` and confirming the spec stays bounded to the shared reason-translation contract rather than downstream domain implementation work.
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
# No External API Changes
|
|
||||||
|
|
||||||
Spec 157 defines an internal operator-facing translation contract for existing reason-bearing workflows.
|
|
||||||
|
|
||||||
This planning slice does **not** introduce:
|
|
||||||
|
|
||||||
- a new public REST API
|
|
||||||
- a new GraphQL schema
|
|
||||||
- a new webhook payload contract
|
|
||||||
- a new external integration surface
|
|
||||||
|
|
||||||
The contract artifacts in this folder are logical documentation for existing presenter, notification, banner, summary, and Filament rendering paths.
|
|
||||||
|
|
||||||
External consumers remain unchanged.
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Reason Resolution Logical Contract
|
|
||||||
version: 0.1.0
|
|
||||||
summary: Logical contract for resolving stable internal reason codes into operator-facing explanation envelopes.
|
|
||||||
description: |
|
|
||||||
This contract is logical rather than transport-prescriptive. It describes the
|
|
||||||
expected behavior of existing presenters, notifications, banners, summaries,
|
|
||||||
and Filament surfaces that consume translated reason state.
|
|
||||||
servers:
|
|
||||||
- url: https://tenantpilot.local
|
|
||||||
paths:
|
|
||||||
/contracts/reasons/resolve:
|
|
||||||
post:
|
|
||||||
summary: Resolve one internal reason code into an operator-facing explanation envelope
|
|
||||||
operationId: resolveReason
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ReasonResolutionRequest'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Operator-facing reason resolved
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ReasonResolutionEnvelope'
|
|
||||||
examples:
|
|
||||||
providerConsentMissing:
|
|
||||||
value:
|
|
||||||
internalCode: provider_consent_missing
|
|
||||||
operatorLabel: Admin consent required
|
|
||||||
shortExplanation: The provider connection cannot continue until admin consent is granted.
|
|
||||||
actionability: prerequisite_missing
|
|
||||||
showNoActionNeeded: false
|
|
||||||
nextSteps:
|
|
||||||
- label: Grant admin consent
|
|
||||||
kind: link
|
|
||||||
destination: admin-consent-url
|
|
||||||
authorizationRequired: true
|
|
||||||
scope: tenant
|
|
||||||
diagnosticCodeLabel: provider_consent_missing
|
|
||||||
/contracts/reasons/validate-adoption:
|
|
||||||
post:
|
|
||||||
summary: Validate an adopted surface's translated reason payloads
|
|
||||||
operationId: validateReasonAdoption
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ReasonAdoptionValidationRequest'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Validation result for an adopted reason surface
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ReasonAdoptionValidationResponse'
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
ReasonResolutionRequest:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- artifactKey
|
|
||||||
- internalCode
|
|
||||||
- surfaceType
|
|
||||||
properties:
|
|
||||||
artifactKey:
|
|
||||||
type: string
|
|
||||||
example: provider_reason_codes
|
|
||||||
internalCode:
|
|
||||||
type: string
|
|
||||||
example: provider_consent_missing
|
|
||||||
surfaceType:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- notification
|
|
||||||
- run_detail
|
|
||||||
- banner
|
|
||||||
- summary_line
|
|
||||||
- table
|
|
||||||
- helper_copy
|
|
||||||
includeDiagnostics:
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
actorIsEntitled:
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
ReasonResolutionEnvelope:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- internalCode
|
|
||||||
- operatorLabel
|
|
||||||
- shortExplanation
|
|
||||||
- actionability
|
|
||||||
- showNoActionNeeded
|
|
||||||
- nextSteps
|
|
||||||
properties:
|
|
||||||
internalCode:
|
|
||||||
type: string
|
|
||||||
example: provider_consent_missing
|
|
||||||
operatorLabel:
|
|
||||||
type: string
|
|
||||||
example: Admin consent required
|
|
||||||
shortExplanation:
|
|
||||||
type: string
|
|
||||||
example: The provider connection cannot continue until admin consent is granted.
|
|
||||||
actionability:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- retryable_transient
|
|
||||||
- permanent_configuration
|
|
||||||
- prerequisite_missing
|
|
||||||
- non_actionable
|
|
||||||
showNoActionNeeded:
|
|
||||||
type: boolean
|
|
||||||
nextSteps:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/NextStepOption'
|
|
||||||
diagnosticCodeLabel:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
NextStepOption:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- label
|
|
||||||
- kind
|
|
||||||
- authorizationRequired
|
|
||||||
- scope
|
|
||||||
properties:
|
|
||||||
label:
|
|
||||||
type: string
|
|
||||||
kind:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- link
|
|
||||||
- instruction
|
|
||||||
- diagnostic_only
|
|
||||||
destination:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
authorizationRequired:
|
|
||||||
type: boolean
|
|
||||||
scope:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- tenant
|
|
||||||
- workspace
|
|
||||||
- system
|
|
||||||
- none
|
|
||||||
ReasonAdoptionValidationRequest:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- target
|
|
||||||
- envelopes
|
|
||||||
properties:
|
|
||||||
target:
|
|
||||||
type: string
|
|
||||||
example: operations_notifications
|
|
||||||
envelopes:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ReasonResolutionEnvelope'
|
|
||||||
ReasonAdoptionValidationResponse:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- valid
|
|
||||||
- violations
|
|
||||||
properties:
|
|
||||||
valid:
|
|
||||||
type: boolean
|
|
||||||
violations:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- code
|
|
||||||
- message
|
|
||||||
properties:
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- raw_code_primary_exposure
|
|
||||||
- missing_actionability_class
|
|
||||||
- missing_required_next_step
|
|
||||||
- unauthorized_next_step_exposure
|
|
||||||
- fallback_overuse
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://tenantpilot.local/contracts/reason-translation-entry.schema.json",
|
|
||||||
"title": "Reason Translation Entry",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"artifactKey",
|
|
||||||
"internalCode",
|
|
||||||
"operatorLabel",
|
|
||||||
"shortExplanation",
|
|
||||||
"actionability",
|
|
||||||
"diagnosticVisibility",
|
|
||||||
"nextStepPolicy"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"artifactKey": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"internalCode": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"operatorLabel": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"shortExplanation": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"actionability": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"retryable_transient",
|
|
||||||
"permanent_configuration",
|
|
||||||
"prerequisite_missing",
|
|
||||||
"non_actionable"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"diagnosticVisibility": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"always_available",
|
|
||||||
"secondary_only"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nextStepPolicy": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"required",
|
|
||||||
"optional",
|
|
||||||
"none"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"taxonomyTerms": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"legacyInputs": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"actionability": {
|
|
||||||
"const": "prerequisite_missing"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"actionability"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"nextStepPolicy": {
|
|
||||||
"enum": [
|
|
||||||
"required",
|
|
||||||
"optional"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"actionability": {
|
|
||||||
"const": "non_actionable"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"actionability"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"nextStepPolicy": {
|
|
||||||
"const": "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
# Data Model: Operator Reason Code Translation and Humanization Contract
|
|
||||||
|
|
||||||
This feature defines a shared explanation model rather than introducing a new business-domain table. The entities below capture the contract the implementation and guard coverage must agree on.
|
|
||||||
|
|
||||||
## Entities
|
|
||||||
|
|
||||||
### ReasonCodeArtifact
|
|
||||||
|
|
||||||
Represents one source family that owns stable internal reason identifiers.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `key` (string): stable artifact identifier such as `provider_reason_codes` or `execution_denial_reason_code`
|
|
||||||
- `structure` (enum): `string_constants`, `enum_without_message`, `enum_with_message`, `localized_helper`, `mixed`
|
|
||||||
- `domain` (string): source domain such as `provider`, `operations`, `tenants`, `rbac`, `baseline`, `verification`
|
|
||||||
- `ownsStableCodes` (bool): whether the artifact is the source of truth for machine-readable codes
|
|
||||||
- `supportsNativeBehavior` (bool): whether the artifact can already expose methods such as `message()`
|
|
||||||
- `adoptionPriority` (enum): `P0`, `P1`, `P2`
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- Each adopted reason must belong to exactly one source artifact.
|
|
||||||
- `string_constants` artifacts require an adapter or registry-backed translation path.
|
|
||||||
- `enum_without_message` artifacts require shared translation behavior before adoption.
|
|
||||||
|
|
||||||
### ReasonTranslationEntry
|
|
||||||
|
|
||||||
Represents one mapping from a stable internal code to operator-facing explanation semantics.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `artifactKey` (string): owning `ReasonCodeArtifact`
|
|
||||||
- `internalCode` (string): stable machine-readable reason code
|
|
||||||
- `operatorLabel` (string): primary human-readable label
|
|
||||||
- `shortExplanation` (string): concise operator-facing explanation
|
|
||||||
- `actionability` (enum): `retryable_transient`, `permanent_configuration`, `prerequisite_missing`, `non_actionable`
|
|
||||||
- `diagnosticVisibility` (enum): `always_available`, `secondary_only`
|
|
||||||
- `nextStepPolicy` (enum): `required`, `optional`, `none`
|
|
||||||
- `taxonomyTerms` (list<string>): canonical outcome-taxonomy terms this translation relies on
|
|
||||||
- `legacyInputs` (list<string>): raw or heuristic inputs that may normalize into this entry
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- `operatorLabel` must not be the raw internal code.
|
|
||||||
- `nextStepPolicy = required` when the actionability class implies a useful remediation path.
|
|
||||||
- `non_actionable` entries must explicitly communicate that no operator action is required.
|
|
||||||
|
|
||||||
### ReasonResolutionEnvelope
|
|
||||||
|
|
||||||
Represents the shared operator-facing contract returned by the translation layer.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `internalCode` (string): stable machine-readable code preserved for diagnostics
|
|
||||||
- `operatorLabel` (string): primary translated label
|
|
||||||
- `shortExplanation` (string): concise explanation for default-visible surfaces
|
|
||||||
- `actionability` (enum): `retryable_transient`, `permanent_configuration`, `prerequisite_missing`, `non_actionable`
|
|
||||||
- `nextSteps` (list<NextStepOption>): zero or more remediation options
|
|
||||||
- `showNoActionNeeded` (bool): whether the envelope should explicitly say no action is required
|
|
||||||
- `diagnosticCodeLabel` (optional string): secondary detail label for raw-code display
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- Every adopted primary reason surface resolves through exactly one envelope.
|
|
||||||
- The envelope must preserve `internalCode` unchanged.
|
|
||||||
- An envelope may contain no `nextSteps` only when `nextStepPolicy` is `none` or the surface cannot safely expose the next step.
|
|
||||||
|
|
||||||
### NextStepOption
|
|
||||||
|
|
||||||
Represents one operator-facing remediation path associated with a translated reason.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `label` (string): action-oriented guidance such as `Grant admin consent` or `Review provider connection`
|
|
||||||
- `kind` (enum): `link`, `instruction`, `diagnostic_only`
|
|
||||||
- `destination` (optional string): logical destination or URL when the step is link-based
|
|
||||||
- `authorizationRequired` (bool): whether the destination requires entitlement checks before display or execution
|
|
||||||
- `scope` (enum): `tenant`, `workspace`, `system`, `none`
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- `destination` is required when `kind = link`.
|
|
||||||
- Unauthorized next-step options must not be surfaced on primary views.
|
|
||||||
- `diagnostic_only` next steps cannot become the only primary guidance for actionable states.
|
|
||||||
|
|
||||||
### TranslationFallbackRule
|
|
||||||
|
|
||||||
Represents the bounded fallback behavior used when a source reason lacks a domain-owned translation entry.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `sourcePattern` (string): input code or normalized pattern being matched
|
|
||||||
- `normalizedCode` (string): internal code chosen after bounded normalization
|
|
||||||
- `fallbackLabel` (string): understandable operator-facing fallback label
|
|
||||||
- `fallbackExplanation` (string): concise fallback explanation
|
|
||||||
- `allowedSurfaces` (list<string>): where fallback behavior is acceptable
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- Fallback labels must remain understandable and must not expose raw internal code as the only primary message.
|
|
||||||
- Fallback behavior cannot become the preferred long-term path for adopted reason families.
|
|
||||||
|
|
||||||
### AdoptionTarget
|
|
||||||
|
|
||||||
Represents one bounded surface family included in the first implementation slice.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `key` (string): stable target identifier
|
|
||||||
- `family` (enum): `operations`, `providers`, `tenants`, `rbac`, `baseline`, `verification`, `restore`, `onboarding`, `system_console`
|
|
||||||
- `sourceArtifacts` (list<string>): `ReasonCodeArtifact` keys adopted by this target
|
|
||||||
- `surfaceTypes` (list<string>): examples such as `notification`, `run_detail`, `banner`, `summary_line`, `table`, `helper_copy`
|
|
||||||
- `priority` (enum): `P0`, `P1`, `P2`
|
|
||||||
- `rolloutStage` (int): ordered rollout stage
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- The first slice must include operations, providers, tenant-operability governance, and adopted system-console RBAC or onboarding surfaces.
|
|
||||||
- Each target must identify both shared-code seams and user-visible surfaces.
|
|
||||||
|
|
||||||
### RegressionGuardCase
|
|
||||||
|
|
||||||
Represents one reusable test or guard invariant enforcing the contract.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `name` (string): guard identifier
|
|
||||||
- `assertion` (string): invariant being enforced
|
|
||||||
- `scope` (enum): `unit`, `feature`, `architecture`
|
|
||||||
- `coversFamilies` (list<string>): adopted families touched by the guard
|
|
||||||
- `failureSignal` (string): what should cause CI to fail
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- The first slice must include guards for raw-code primary exposure, fallback overuse, missing next-step guidance for actionable states, and cross-scope leak risks.
|
|
||||||
|
|
||||||
## Relationships
|
|
||||||
|
|
||||||
- `ReasonCodeArtifact` 1-to-many `ReasonTranslationEntry`
|
|
||||||
- `ReasonTranslationEntry` 1-to-1 `ReasonResolutionEnvelope` in adopted paths
|
|
||||||
- `ReasonResolutionEnvelope` 1-to-many `NextStepOption`
|
|
||||||
- `TranslationFallbackRule` supports many `ReasonCodeArtifact` families when no direct entry exists
|
|
||||||
- `AdoptionTarget` consumes many `ReasonCodeArtifact` and `ReasonTranslationEntry` combinations
|
|
||||||
- `RegressionGuardCase` validates many `AdoptionTarget` and `ReasonResolutionEnvelope` combinations
|
|
||||||
|
|
||||||
## Initial First-Slice Adoption Set
|
|
||||||
|
|
||||||
### Operations and notifications
|
|
||||||
- Source artifacts: `ExecutionDenialReasonCode`, normalized failure reasons, `OperationUxPresenter`, `OperationRunCompleted`, `SummaryCountsNormalizer`
|
|
||||||
- Primary needs: translated label, concise explanation, actionability guidance, diagnostic raw-code preservation
|
|
||||||
|
|
||||||
### Provider blocking and guidance
|
|
||||||
- Source artifacts: `ProviderReasonCodes`, `ProviderNextStepsRegistry`, provider-connection blocking flows
|
|
||||||
- Primary needs: stable provider code translation, next-step guidance, bounded fallback for unknown provider errors
|
|
||||||
|
|
||||||
### Tenant operability and RBAC governance
|
|
||||||
- Source artifacts: `TenantOperabilityReasonCode`, `RbacReason`
|
|
||||||
- Primary needs: move from raw enum values to translated operator-facing envelopes without changing existing domain semantics
|
|
||||||
|
|
||||||
## Out-of-slice but adjacent families
|
|
||||||
|
|
||||||
- `BaselineReasonCodes` and `BaselineCompareReasonCode`
|
|
||||||
- onboarding lifecycle raw string reasons
|
|
||||||
- verification check reason payloads
|
|
||||||
- restore item-level reason payloads
|
|
||||||
|
|
||||||
These remain explicit downstream adoption candidates once the shared contract is proven on the first slice.
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
# Implementation Plan: Operator Reason Code Translation and Humanization Contract
|
|
||||||
|
|
||||||
**Branch**: `157-reason-code-translation` | **Date**: 2026-03-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/157-reason-code-translation/spec.md`
|
|
||||||
**Input**: Feature specification from `/specs/157-reason-code-translation/spec.md`
|
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Define one shared reason translation contract that converts stable internal reason codes into operator-facing labels, explanations, retryability classes, and next-step guidance. The implementation should preserve existing backend reason-code precision, avoid new business-domain storage, and adopt the contract first where the repo already centralizes reason-bearing UX: operations notifications and run detail, provider next-step flows, tenant-operability governance, and adopted system-console RBAC or onboarding health surfaces. Existing heuristic string matching in `RunFailureSanitizer` should shrink from being the primary explanation path on adopted surfaces to being a bounded fallback only.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15
|
|
||||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
|
||||||
**Storage**: PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice
|
|
||||||
**Testing**: Pest feature tests, unit tests for reason-translation contracts and fallback behavior, existing architecture or guard-style tests, focused notification and Filament surface assertions
|
|
||||||
**Target Platform**: Laravel web application running locally via Sail and deployed via Dokploy
|
|
||||||
**Project Type**: Web application
|
|
||||||
**Performance Goals**: No render-time external calls; reason translation must remain constant-time and fit inside current presenter and badge paths; Monitoring and canonical views remain DB-only at render time; notification generation must not add query-heavy joins beyond existing run and tenant lookups
|
|
||||||
**Constraints**: Preserve stable internal reason-code contracts, preserve RBAC 404 versus 403 semantics, preserve existing Ops-UX lifecycle and notification rules, avoid page-local ad-hoc translation helpers, and keep the first slice bounded to central adoption seams rather than full-repo migration
|
|
||||||
**Scale/Scope**: Cross-domain contract foundation plus bounded first-slice adoption across operations and notifications, provider blocking and next-steps, tenant-operability governance, and adopted system-console RBAC or onboarding reason surfaces
|
|
||||||
|
|
||||||
### Filament v5 Implementation Notes
|
|
||||||
|
|
||||||
- **Livewire v4.0+ compliance**: Maintained. This work changes operator-facing explanation behavior inside the existing Filament v5 + Livewire v4 stack and introduces no incompatible Livewire pattern.
|
|
||||||
- **Provider registration location**: No new panel is introduced. Existing panel providers remain registered in `bootstrap/providers.php`.
|
|
||||||
- **Global search rule**: No new globally searchable resource is added. Existing reason-bearing labels and next-step hints must remain non-member-safe on canonical and tenant-context views.
|
|
||||||
- **Destructive actions**: No new destructive action family is introduced. Existing destructive actions on adopted surfaces remain confirmation-protected and capability-gated.
|
|
||||||
- **Asset strategy**: No new global or on-demand assets are planned. Deployment behavior remains unchanged, including `php artisan filament:assets` where already required.
|
|
||||||
- **Testing plan**: Add Pest coverage for reason translation envelopes, fallback behavior, notification wording, adopted surface rendering, and authorization-safe next-step guidance.
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
||||||
|
|
||||||
- Inventory-first: PASS. The feature changes explanation semantics only and does not alter inventory versus snapshot ownership or capture paths.
|
|
||||||
- Read/write separation: PASS. No new mutation flow is introduced. Existing write flows remain governed by their owning specs.
|
|
||||||
- Graph contract path: PASS. No new Graph path or contract registry change is required.
|
|
||||||
- Deterministic capabilities: PASS. Capability derivation remains unchanged and continues to gate adopted surfaces server-side.
|
|
||||||
- RBAC-UX plane separation: PASS. The feature spans tenant/admin and system surfaces while preserving 404 for non-members and 403 for in-scope capability denial.
|
|
||||||
- Workspace isolation: PASS. Humanized reason labels, summaries, and next-step hints must be derived only from entitled workspace scope.
|
|
||||||
- Tenant isolation: PASS. Shared reason wording must not leak unauthorized tenant state in canonical views, filters, summaries, or notifications.
|
|
||||||
- Destructive confirmation standard: PASS. No destructive semantics are changed.
|
|
||||||
- Global search tenant safety: PASS WITH WORK. Shared translated labels and next-step hints must remain non-member-safe; focused regression coverage is required.
|
|
||||||
- Run observability: PASS. Existing `OperationRun` usage remains canonical. This feature only changes how reasons are translated and displayed.
|
|
||||||
- Ops-UX 3-surface feedback: PASS WITH WORK. Adopted operation notifications and run details must stay within the existing toast, progress, and terminal notification contract.
|
|
||||||
- Ops-UX lifecycle: PASS. `OperationRun.status` and `OperationRun.outcome` remain service-owned. This feature only changes explanation paths.
|
|
||||||
- Ops-UX summary counts: PASS WITH WORK. `SummaryCountsNormalizer` already humanizes keys, but the first slice must improve reason-bearing summary language without changing the numeric contract.
|
|
||||||
- Ops-UX guards: PASS WITH WORK. Add guard coverage so adopted surfaces cannot fall back to raw or heuristic-only operator reason strings without an approved translation path.
|
|
||||||
- Ops-UX system runs: PASS. No change to initiator-null notification semantics.
|
|
||||||
- Automation: PASS. No queue, lock, or retry mechanism is changed by the contract itself.
|
|
||||||
- Data minimization: PASS. This feature reduces raw reason exposure on primary operator surfaces by pushing internal codes into diagnostics.
|
|
||||||
- Badge semantics (BADGE-001): PASS WITH WORK. Where translated reasons affect status-like wording, they must align with the existing outcome taxonomy and centralized badge semantics.
|
|
||||||
- UI naming (UI-NAMING-001): PASS WITH WORK. Shared reason labels become part of the operator-facing vocabulary and must stay consistent across run detail, notifications, banners, and guidance text.
|
|
||||||
- Operator surfaces (OPSURF-001): PASS WITH WORK. Default-visible content on adopted surfaces must show label, explanation, and next step while relegating raw codes and payload fragments to diagnostics.
|
|
||||||
- Filament UI Action Surface Contract: PASS. Existing action surfaces remain structurally unchanged; the rollout changes explanation copy and guidance only.
|
|
||||||
- Filament UI UX-001: PASS. No new screen category is introduced. Adopted surfaces keep their current layout while improving the operator-first information hierarchy.
|
|
||||||
|
|
||||||
**Phase 0 Gate Result**: PASS
|
|
||||||
|
|
||||||
- The feature is bounded to a shared translation contract plus a first-slice adoption set, not a full domain rewrite.
|
|
||||||
- Existing presenter, registry, and notification seams provide a practical implementation path.
|
|
||||||
- The main delivery risk is inconsistency between translated labels and raw-code fallbacks, which is addressable through contract tests and guard coverage.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/157-reason-code-translation/
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ ├── no-external-api-changes.md
|
|
||||||
│ ├── reason-resolution.logical.openapi.yaml
|
|
||||||
│ └── reason-translation-entry.schema.json
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/
|
|
||||||
├── Filament/
|
|
||||||
│ ├── Resources/
|
|
||||||
│ └── Widgets/
|
|
||||||
├── Notifications/
|
|
||||||
├── Services/
|
|
||||||
│ ├── Operations/
|
|
||||||
│ ├── Providers/
|
|
||||||
│ ├── Tenants/
|
|
||||||
│ ├── Intune/
|
|
||||||
│ └── Verification/
|
|
||||||
├── Support/
|
|
||||||
│ ├── Baselines/
|
|
||||||
│ ├── Operations/
|
|
||||||
│ ├── OpsUx/
|
|
||||||
│ ├── Providers/
|
|
||||||
│ └── Tenants/
|
|
||||||
└── Models/
|
|
||||||
|
|
||||||
tests/
|
|
||||||
├── Feature/
|
|
||||||
├── Unit/
|
|
||||||
└── Architecture/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Use the existing Laravel web application structure. The documentation artifacts live under `specs/157-reason-code-translation/`, while the planned implementation targets concentrate in `app/Support/OpsUx`, `app/Support/Providers`, `app/Support/Operations`, `app/Support/Tenants`, `app/Notifications`, `app/Filament/Resources`, and focused Pest suites under `tests/Unit`, `tests/Feature`, and existing guard-oriented directories.
|
|
||||||
|
|
||||||
## Phase 0 — Research (complete)
|
|
||||||
|
|
||||||
- Output: [specs/157-reason-code-translation/research.md](research.md)
|
|
||||||
- Resolved key decisions:
|
|
||||||
- Preserve stable internal reason codes and translate them through a shared resolution envelope rather than renaming backend contracts.
|
|
||||||
- Use existing central seams as first-slice adoption points: `OperationUxPresenter`, `OperationRunCompleted`, `ProviderNextStepsRegistry`, and enum-backed reason families that already model reason semantics.
|
|
||||||
- Reduce `RunFailureSanitizer` from being the primary operator explanation path on adopted surfaces to being a bounded fallback or normalization seam only.
|
|
||||||
- Prefer domain-owned translations behind one common contract shape over a monolithic page-local formatting pattern.
|
|
||||||
- First-slice rollout order is contract foundation, enum-backed families, provider next-step flows, then operations notifications and run-detail wording.
|
|
||||||
|
|
||||||
## Phase 1 — Design & Contracts (complete)
|
|
||||||
|
|
||||||
- Output: [data-model.md](./data-model.md) defines the shared reason artifact, resolution envelope, translation entry, next-step option, and adoption-target model.
|
|
||||||
- Output: [contracts/reason-resolution.logical.openapi.yaml](./contracts/reason-resolution.logical.openapi.yaml) captures the logical request and response contract for resolving a raw reason into operator-facing presentation.
|
|
||||||
- Output: [contracts/reason-translation-entry.schema.json](./contracts/reason-translation-entry.schema.json) defines the documentation-first schema for a translation entry and its actionability constraints.
|
|
||||||
- Output: [contracts/no-external-api-changes.md](./contracts/no-external-api-changes.md) records that this feature is internal-contract work with no public transport API changes.
|
|
||||||
- Output: [quickstart.md](./quickstart.md) documents the recommended rollout order and focused validation commands.
|
|
||||||
|
|
||||||
### Post-design Constitution Re-check
|
|
||||||
|
|
||||||
- PASS: No new panel, Graph path, route family, or business-domain storage is introduced.
|
|
||||||
- PASS: The design preserves Filament v5 + Livewire v4 and keeps provider registration unchanged in `bootstrap/providers.php`.
|
|
||||||
- PASS WITH WORK: Operations notifications, run detail, and summary wording need focused validation so translated labels improve clarity without violating the existing Ops-UX contract.
|
|
||||||
- PASS WITH WORK: Canonical and tenant-context views need explicit non-member regression coverage because translated next-step hints and summary labels are part of the leak surface.
|
|
||||||
- PASS WITH WORK: Fallback behavior must remain understandable by meeting a minimum label, explanation, and action-guidance floor, and it must not become a loophole that reintroduces raw-code-as-primary-message patterns.
|
|
||||||
|
|
||||||
## Phase 2 — Implementation Planning
|
|
||||||
|
|
||||||
`tasks.md` should cover:
|
|
||||||
|
|
||||||
- Defining the shared reason resolution envelope and domain-facing translation contract in `app/Support` so adopted reason families return the same minimum shape: label, explanation, actionability class, and next-step guidance when applicable.
|
|
||||||
- Implementing the first enum-backed adoption slice for `ExecutionDenialReasonCode`, `TenantOperabilityReasonCode`, and `RbacReason`, reusing their existing semantics while standardizing their operator-facing output.
|
|
||||||
- Extending provider-domain flows so `ProviderReasonCodes` can be resolved through the shared contract and `ProviderNextStepsRegistry` becomes one domain implementation of that contract rather than the only next-step registry in the system.
|
|
||||||
- Updating `OperationUxPresenter` and `OperationRunCompleted` so terminal notifications and run detail wording consume translated reason envelopes instead of raw or heuristically sanitized fragments.
|
|
||||||
- Updating adopted summary and banner paths so humanized labels remain operator-first while raw internal reason codes stay available in diagnostics.
|
|
||||||
- Reducing adopted uses of heuristic string matching in `RunFailureSanitizer` so it no longer acts as the primary operator explanation path on first-slice surfaces.
|
|
||||||
- Adding unit and feature tests for translation envelopes, fallback behavior, retryability or actionability classes, entitlement-safe next-step guidance, and adopted notification wording.
|
|
||||||
- Adding guard coverage that fails when adopted surfaces expose raw internal reason codes as the primary operator-facing message or drift away from the canonical operator vocabulary for blocked, missing, denied, stale, unsupported, partial, and retry states.
|
|
||||||
|
|
||||||
### Contract Implementation Note
|
|
||||||
|
|
||||||
- The OpenAPI file is logical, not transport-prescriptive. It documents how existing presenters, notifications, badge mappers, and Filament surfaces should resolve raw reason state into operator-facing output.
|
|
||||||
- The JSON schema is documentation-first and guard-friendly. It can be enforced through fixtures, curated translation registries, or unit tests rather than a new runtime parser in the first slice.
|
|
||||||
- The no-external-api note makes the boundary explicit: this feature standardizes internal explanation behavior and transport payload composition, not external REST endpoints.
|
|
||||||
|
|
||||||
### Deployment Sequencing Note
|
|
||||||
|
|
||||||
- No migration is expected in the first slice.
|
|
||||||
- No asset publish change is expected.
|
|
||||||
- Recommended rollout order: shared contract foundation, enum-backed adoption slice, provider next-step slice, operations notification and run-detail slice, then broader domain adoption only after guards are green.
|
|
||||||
|
|
||||||
### Story Delivery Note
|
|
||||||
|
|
||||||
- User Story 1 and User Story 2 are both P1. The executable delivery order should start with User Story 2's contract and diagnostic-boundary requirements because preserving backend precision is the precondition for safe translation.
|
|
||||||
- User Story 1 follows immediately through operations and provider-facing wording because those are the highest-leverage operator surfaces.
|
|
||||||
- User Story 3 finishes the first slice by extending the common contract across additional reason families and guard coverage.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| None | Not applicable | Not applicable |
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
# Quickstart: Operator Reason Code Translation and Humanization Contract
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Validate the shared reason translation contract on a bounded first slice without breaking RBAC, existing Ops-UX lifecycle rules, or diagnostic precision.
|
|
||||||
|
|
||||||
## First-Slice Scope
|
|
||||||
|
|
||||||
The recommended first slice covers:
|
|
||||||
|
|
||||||
1. Operations run detail and terminal notifications
|
|
||||||
2. Provider blocked-state guidance and next-step rendering
|
|
||||||
3. Tenant-operability governance reason presentation
|
|
||||||
4. Adopted system-console RBAC or onboarding health reason presentation
|
|
||||||
5. Shared fallback behavior for untranslated adopted reasons
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. Define the shared resolution envelope and domain-facing translation contract
|
|
||||||
2. Adopt enum-backed reason families first
|
|
||||||
3. Extend provider next-step and provider blocking flows to the same contract shape
|
|
||||||
4. Wire `OperationUxPresenter` and `OperationRunCompleted` to translated envelopes
|
|
||||||
5. Add fallback, vocabulary, and non-leakage guard coverage before expanding beyond the first slice
|
|
||||||
|
|
||||||
## Focused Validation Commands
|
|
||||||
|
|
||||||
Run all commands through Sail.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vendor/bin/sail artisan test --compact \
|
|
||||||
tests/Unit/OpsUx/RunFailureSanitizerTest.php \
|
|
||||||
tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php \
|
|
||||||
tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected additions or extensions during the slice:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vendor/bin/sail artisan test --compact \
|
|
||||||
tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php \
|
|
||||||
tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php \
|
|
||||||
tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php \
|
|
||||||
tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php \
|
|
||||||
tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php \
|
|
||||||
tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php \
|
|
||||||
tests/Feature/Notifications/OperationRunNotificationTest.php \
|
|
||||||
tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php \
|
|
||||||
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
|
|
||||||
tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php \
|
|
||||||
tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php
|
|
||||||
|
|
||||||
vendor/bin/sail bin pint --dirty --format agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## First-Slice Implementation Notes
|
|
||||||
|
|
||||||
- Operations notifications and the canonical run-detail surface now resolve reason codes through a shared envelope before rendering the primary message.
|
|
||||||
- Provider blocked-start notifications now render translated labels, explanations, and next-step guidance instead of raw provider codes.
|
|
||||||
- RBAC governance details now render a translated reason label and explanation while keeping the diagnostic reason code visible in secondary detail.
|
|
||||||
- `OperationRun.context.reason_translation` stores the translated envelope for adopted run surfaces, while `reason_code` remains unchanged for diagnostics and audit use cases.
|
|
||||||
|
|
||||||
## Manual Smoke Checklist
|
|
||||||
|
|
||||||
### `/admin/operations` and run detail
|
|
||||||
|
|
||||||
- Blocked runs show a translated label and concise explanation.
|
|
||||||
- The primary surface does not show the raw internal reason code as the headline message.
|
|
||||||
- If action is required, the surface shows a next step or explicit remediation guidance.
|
|
||||||
- Raw reason codes remain available only in diagnostics or secondary detail.
|
|
||||||
|
|
||||||
### Provider connection and provider-blocked flows
|
|
||||||
|
|
||||||
- Provider blocking states show translated labels instead of bare provider reason codes.
|
|
||||||
- The first next-step hint remains actionable and entitlement-safe.
|
|
||||||
- Unknown provider failures still render an understandable fallback label.
|
|
||||||
|
|
||||||
### Tenant or RBAC governance slice
|
|
||||||
|
|
||||||
- Raw enum values such as `missing_capability` or `manual_assignment_required` do not appear as the primary operator label.
|
|
||||||
- The operator can tell whether the state is transient, prerequisite-bound, or requires manual intervention.
|
|
||||||
|
|
||||||
## Manual Review Protocol For SC-157-004
|
|
||||||
|
|
||||||
Review exactly 12 curated examples after the first slice is implemented:
|
|
||||||
|
|
||||||
1. 4 operations examples covering blocked, denied, retryable, and non-actionable outcomes
|
|
||||||
2. 4 provider guidance examples covering prerequisite missing, permission required, connectivity, and fallback behavior
|
|
||||||
3. 2 tenant-operability examples covering readiness degradation and manual intervention
|
|
||||||
4. 2 adopted system-console examples covering RBAC health or onboarding prerequisite reasons
|
|
||||||
|
|
||||||
For each example, record pass or fail for these two checks:
|
|
||||||
|
|
||||||
1. Cause clarity: the default-visible label and explanation make the underlying issue understandable without exposing the raw internal code as the headline.
|
|
||||||
2. Next-step clarity: the default-visible message either provides an explicit next step or clearly states that no operator action is required.
|
|
||||||
|
|
||||||
SC-157-004 passes when at least 11 of the 12 curated examples pass both checks.
|
|
||||||
|
|
||||||
## Validation Checklist
|
|
||||||
|
|
||||||
- Adopted primary surfaces never use a raw internal reason code as the default-visible message.
|
|
||||||
- Diagnostics still preserve the original internal reason code.
|
|
||||||
- Actionable reasons include guidance or an explicit next step.
|
|
||||||
- Non-actionable reasons explicitly communicate that no action is required.
|
|
||||||
- Fallback labels are sentence-case, are not identical to the raw internal code, and include either an explicit next step or an explicit no-action-needed signal.
|
|
||||||
- Canonical and tenant-context surfaces do not reveal unauthorized remediation paths or protected state.
|
|
||||||
- `RunFailureSanitizer` remains bounded to sanitization and fallback behavior on adopted surfaces.
|
|
||||||
|
|
||||||
## Rollout Note
|
|
||||||
|
|
||||||
Do not migrate every reason-bearing family opportunistically. Keep the slice bounded to operations, provider guidance, tenant-operability governance, and adopted system-console RBAC or onboarding surfaces so that translation regressions remain attributable and reversible.
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# Research: Operator Reason Code Translation and Humanization Contract
|
|
||||||
|
|
||||||
## Decision 1: Preserve internal reason codes and translate them through a shared envelope
|
|
||||||
|
|
||||||
- Decision: Keep stable internal reason codes as machine contracts for logs, audits, tests, and existing records, and add one shared operator-facing resolution envelope that derives label, explanation, actionability, retryability, and next-step guidance from those codes.
|
|
||||||
- Rationale: The repo already stores and compares raw reason codes in operations, onboarding, provider resolution, and verification flows. Renaming them for operator copy would create unnecessary churn and risk breaking audit semantics.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Rename all reason codes to more human-readable strings: rejected because backend precision and compatibility matter more than cosmetic internal naming.
|
|
||||||
- Keep reason translation purely page-local: rejected because that would reproduce inconsistency across operations, provider, baseline, RBAC, and onboarding flows.
|
|
||||||
- Collapse raw codes into human prose only: rejected because diagnostics and tests need stable machine-readable contracts.
|
|
||||||
|
|
||||||
## Decision 2: Treat current reason-code families as one structural problem with multiple shapes
|
|
||||||
|
|
||||||
- Decision: Model the current repo as a set of distinct artifact families that all need the same translation contract despite structural differences.
|
|
||||||
- Rationale: The codebase uses at least four structural patterns today: string-constant registries (`ProviderReasonCodes`, `BaselineReasonCodes`), enums without translation methods (`TenantOperabilityReasonCode`, `RbacReason`), enums with `message()` (`BaselineCompareReasonCode`, `ExecutionDenialReasonCode`), and localized helper or options patterns (`RunbookReason`). The contract must span all of them.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Standardize only enum-based families first and ignore string-constant registries: rejected because provider and baseline preconditions are some of the most visible raw-code leak sources.
|
|
||||||
- Build separate contracts per family: rejected because the feature's value is one shared operator-facing explanation shape.
|
|
||||||
|
|
||||||
## Decision 3: Use existing central seams as the first adoption slice
|
|
||||||
|
|
||||||
- Decision: Start adoption where the repo already centralizes operator-facing reason UX: `OperationUxPresenter`, `OperationRunCompleted`, `ProviderNextStepsRegistry`, and enum-backed families such as `ExecutionDenialReasonCode`.
|
|
||||||
- Rationale: These seams already shape cross-domain notifications and blocked-prerequisite guidance, which makes them high-leverage proof points for the contract.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Start with low-level job classes: rejected because that would spread translation logic outward instead of centralizing it.
|
|
||||||
- Start with only one domain such as baseline compare: rejected because it would not prove the cross-domain contract.
|
|
||||||
- Start by rewriting every reason-bearing surface in one pass: rejected because the rollout would be too large to validate safely.
|
|
||||||
|
|
||||||
## Decision 4: Reduce heuristic string matching to fallback-only status on adopted surfaces
|
|
||||||
|
|
||||||
- Decision: `RunFailureSanitizer` should remain available for sanitization and bounded normalization fallback, but it must stop being the primary explanation path on first-slice adopted surfaces.
|
|
||||||
- Rationale: The current `normalizeReasonCode()` method relies on heuristic string matching for throttling, auth, timeout, permission, validation, and conflict patterns. That is useful as a compatibility layer, but it is too weak and opaque to remain the product's primary operator explanation mechanism.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Delete all heuristics immediately: rejected because the repo still receives raw throwable messages from multiple jobs and services.
|
|
||||||
- Keep heuristics as the main translation path: rejected because the feature explicitly exists to move beyond heuristic operator wording.
|
|
||||||
- Replace sanitization and normalization together: rejected because sanitization still has a valid security purpose even after structured translation is introduced.
|
|
||||||
|
|
||||||
## Decision 5: Next-step guidance belongs inside the same contract as label and explanation
|
|
||||||
|
|
||||||
- Decision: The reason-translation contract must include next-step guidance or an explicit no-action-needed marker rather than leaving next steps to ad-hoc presenter logic.
|
|
||||||
- Rationale: Provider flows already prove the need through `ProviderNextStepsRegistry`, and operations surfaces already try to infer next steps through `OperationUxPresenter`. The operator-facing contract is incomplete if it explains the cause but not the expected action.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Keep next steps in separate registries and translate only labels: rejected because surfaces would still need to stitch together multiple inconsistent sources.
|
|
||||||
- Always require a navigation link: rejected because some reasons need instruction text or an explicit no-action-needed signal instead of a link.
|
|
||||||
|
|
||||||
## Decision 6: The first slice should prove both translation and diagnostic boundary behavior
|
|
||||||
|
|
||||||
- Decision: The first slice must assert both that primary surfaces show translated labels and that diagnostics still preserve the original internal reason code.
|
|
||||||
- Rationale: The feature is about explanation quality without losing backend truth. A rollout that only improves the UI but discards raw diagnostic precision would fail the spec's second P1 story.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Hide raw codes entirely: rejected because support, audit, and regression use cases still need them.
|
|
||||||
- Leave raw codes primary on some surfaces while humanizing others: rejected because that would keep the current inconsistency alive.
|
|
||||||
|
|
||||||
## Decision 7: Start with enum-backed families before adapting string-constant registries deeply
|
|
||||||
|
|
||||||
- Decision: Begin implementation with enum-backed families such as `ExecutionDenialReasonCode`, `TenantOperabilityReasonCode`, and `RbacReason`, then extend the same contract to string-constant registries such as `ProviderReasonCodes` and `BaselineReasonCodes` through adapters or registries.
|
|
||||||
- Rationale: Enums already package reason identity in one type and some already have behavior (`message()`, `denialClass()`), so they provide the safest and fastest proving ground for the shared contract.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Start with provider constants first: rejected because it requires a broader adapter decision before the contract is proven.
|
|
||||||
- Delay provider adoption entirely: rejected because provider reasons are among the highest-volume operator-facing blocked states.
|
|
||||||
|
|
||||||
## Decision 8: The first slice should cover operations, provider guidance, tenant-operability, and adopted system-console governance
|
|
||||||
|
|
||||||
- Decision: The bounded first slice should include operations notifications and run detail, provider next-step guidance, tenant-operability governance, and adopted system-console RBAC or onboarding health surfaces.
|
|
||||||
- Rationale: This proves the contract across different explanation patterns: run lifecycle messaging, prerequisite guidance, tenant-context governance logic, and platform or system-surface health messaging.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Operations only: rejected because the feature would look too local and would not prove cross-domain reuse.
|
|
||||||
- Provider only: rejected because it would leave the highest-leverage notification path unchanged.
|
|
||||||
|
|
||||||
## Decision 9: Summary humanization must stay aligned with existing numeric contracts
|
|
||||||
|
|
||||||
- Decision: Adopted summary labels should become more operator-readable, but summary metrics remain governed by `OperationSummaryKeys::all()` and numeric-only normalization.
|
|
||||||
- Rationale: `SummaryCountsNormalizer` already humanizes labels such as `Failed items` and `Completed successfully`. This feature should extend clarity around reason-bearing summaries without changing the operations metrics contract.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Introduce free-form summary prose per operation: rejected because it would weaken determinism and complicate testing.
|
|
||||||
- Leave summary wording untouched: rejected because raw or overly technical labels are part of the same operator-trust problem.
|
|
||||||
|
|
||||||
## Decision 10: Authorization-safe translation is part of the contract, not a follow-up concern
|
|
||||||
|
|
||||||
- Decision: The translation contract must treat next-step hints, labels, summaries, and notification wording as authorization-sensitive output, and the first slice must include explicit non-leakage regression coverage.
|
|
||||||
- Rationale: The repo uses both tenant-context and canonical workspace views, and translated reason hints can become a leak surface if they reveal inaccessible remediation paths or hidden tenant state.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Treat authorization as purely the page layer's concern: rejected because the translated payload itself can leak information.
|
|
||||||
- Defer non-leakage testing to a later hardening pass: rejected because the spec explicitly spans canonical and tenant-context surfaces now.
|
|
||||||
|
|
||||||
## Decision 11: Fallback output and shared vocabulary need explicit quality floors
|
|
||||||
|
|
||||||
- Decision: The first slice should define a minimum fallback quality floor and regression guards for shared operator vocabulary, not just raw-code suppression.
|
|
||||||
- Rationale: A fallback that avoids raw codes but still emits inconsistent wording such as mixed blocked or denied synonyms would satisfy the letter of translation while still degrading operator trust across domains.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Validate only that raw internal codes are hidden: rejected because that still allows drift in blocked, missing, stale, unsupported, denied, partial, and retry phrasing.
|
|
||||||
- Leave fallback readability to reviewer judgment alone: rejected because the spec needs deterministic quality thresholds that future adopters can follow.
|
|
||||||
|
|
||||||
## Implementation Notes For Future Adopters
|
|
||||||
|
|
||||||
- Adopted surfaces should resolve reasons through `App\Support\ReasonTranslation\ReasonTranslator` or `App\Support\ReasonTranslation\ReasonPresenter` rather than formatting raw reason strings inline.
|
|
||||||
- Provider-domain next-step links should continue to flow through `ProviderNextStepsRegistry`; it now delegates to the shared translation contract instead of owning wording itself.
|
|
||||||
- Operation-run surfaces should prefer the persisted `context.reason_translation` envelope when present, while still keeping `context.reason_code` and `failure_summary[*].reason_code` stable for diagnostics.
|
|
||||||
- New domain families should add translation behavior close to the reason-code source type first, then wire the resulting envelope into presenters or notifications.
|
|
||||||
- `unknown_error` should remain a bounded fallback for explicitly reason-bearing flows only; generic failure codes from unrelated domains should continue to use their existing follow-up messaging until they adopt the shared contract directly.
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
# Feature Specification: Operator Reason Code Translation and Humanization Contract
|
|
||||||
|
|
||||||
**Feature Branch**: `157-reason-code-translation`
|
|
||||||
**Created**: 2026-03-22
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Operator Reason Code Translation and Humanization Contract"
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace + tenant + canonical-view
|
|
||||||
- **Primary Routes**:
|
|
||||||
- `/admin/operations`
|
|
||||||
- `/admin/operations/{run}`
|
|
||||||
- `/admin/t/{tenant}/...` adopted tenant governance surfaces that currently show blocked, denied, degraded, skipped, or failed reasons
|
|
||||||
- `/system/...` adopted system-console health and onboarding surfaces that expose execution or prerequisite reasons to platform operators
|
|
||||||
- **Data Ownership**:
|
|
||||||
- This feature does not introduce a new business-domain record. It defines the shared operator-facing translation contract for reason-bearing outcomes already produced by existing workspace-owned and tenant-owned workflows.
|
|
||||||
- Workspace-owned records affected include operation runs, system-console summaries, notification payloads, and workspace-scoped diagnostic surfaces.
|
|
||||||
- Tenant-owned records affected include tenant governance records whose blocked, degraded, validation, or readiness states already carry reason codes.
|
|
||||||
- Internal machine-readable reason codes remain stable; only their operator-facing translation and resolution shape changes.
|
|
||||||
- **RBAC**:
|
|
||||||
- Existing workspace membership, tenant entitlement, platform access, and capability rules remain the access boundary for all adopted surfaces.
|
|
||||||
- This feature changes explanation quality, not access rights.
|
|
||||||
- Non-members and cross-scope actors remain deny-as-not-found. Humanized labels, next-step hints, filter values, and summaries must not reveal hidden tenant or workspace state.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: Canonical workspace views open prefiltered to the current tenant when entered from tenant context, but only for records the actor is entitled to inspect. Reason labels and next-step facets must respect the same prefilter.
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Humanized reason labels, next-step links, severity groupings, notification text, and summary counts must be derived only from authorized records. Unauthorized reasons must not become inferable through shared wording such as `Blocked`, `Permission required`, `Reconnect provider`, or `Retry later`.
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Operations list and run detail | Tenant or workspace operator | List/detail | Why did this run block, fail, or degrade, and what should I do next? | Human-readable reason label, short explanation, retryability or next-step signal, affected scope | Raw machine reason code, low-level payload fragments, internal identifiers | execution outcome, operator actionability, retryability | TenantPilot only / Microsoft tenant / simulation only depending on the adopted run type | View run, follow next step, retry when allowed | Existing dangerous follow-up actions only; no new dangerous action added by this spec |
|
|
||||||
| Provider and tenant governance surfaces using reason-bearing states | Tenant operator | Detail/list/summary | What is preventing progress or reducing trust on this record? | Human-readable reason label, concise explanation, whether action is required | Raw technical code, provider metadata, internal classification detail | readiness, prerequisite state, operator actionability | TenantPilot only or Microsoft tenant depending on existing workflow | Resolve prerequisite, inspect detail | Existing dangerous actions remain unchanged |
|
|
||||||
| Adopted system-console health and onboarding surfaces | Platform operator | Detail/triage | Is this a transient issue, a configuration issue, or a missing prerequisite? | Human-readable reason class, short explanation, next-step guidance | Raw code, stack-oriented detail, internal IDs | execution outcome, retryability, actionability | TenantPilot only / Microsoft tenant / simulation only depending on adopted action | Inspect diagnostics, follow remediation path | Existing dangerous actions remain unchanged |
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Understand why work was blocked or failed (Priority: P1)
|
|
||||||
|
|
||||||
As an operator, I want blocked, denied, degraded, and failed states to explain themselves in plain language, so that I can understand the cause and next step without decoding internal reason strings.
|
|
||||||
|
|
||||||
**Why this priority**: This is the direct operator-trust gap. When reason strings leak through unchanged, the product already knows the truth but fails to communicate it.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by inspecting adopted blocked and failed examples on operations and governance surfaces and verifying that the primary surface shows a human-readable label, a short explanation, and action guidance without exposing only raw internal codes.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an adopted surface currently carries a machine-readable blocked or failed reason, **When** the operator opens that surface, **Then** the primary message uses a human-readable label and short explanation instead of the raw internal code.
|
|
||||||
2. **Given** an adopted reason represents a recoverable prerequisite problem, **When** the operator views it, **Then** the surface tells the operator what to do next or where to go next.
|
|
||||||
3. **Given** an adopted reason is non-actionable or diagnostic-only, **When** the operator views it, **Then** the surface makes that clear instead of presenting it like an unexplained warning.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Preserve backend precision without polluting the primary surface (Priority: P1)
|
|
||||||
|
|
||||||
As a maintainer and senior operator, I want internal reason codes to remain stable and available for diagnostics, so that logs, tests, and audits keep their precision while the primary UI stays operator-first.
|
|
||||||
|
|
||||||
**Why this priority**: The solution must not trade away machine contracts or audit clarity. The requirement is translation, not lossy replacement.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by verifying that adopted surfaces show humanized labels by default while raw reason codes remain available through diagnostics, logs, or secondary detail.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an adopted record has a machine-readable reason code, **When** the operator views the primary surface, **Then** the raw code is not the primary label.
|
|
||||||
2. **Given** a maintainer or advanced operator needs diagnostic precision, **When** they inspect secondary detail, **Then** the original internal reason code remains available and unchanged.
|
|
||||||
3. **Given** an audit or regression test already depends on the machine-readable reason, **When** this feature is adopted, **Then** the internal code contract remains stable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Reuse one translation contract across domains (Priority: P2)
|
|
||||||
|
|
||||||
As a product owner, I want one shared reason translation contract used across operations, provider, baseline, verification, RBAC, restore, and onboarding surfaces, so that each domain does not invent a separate explanation format.
|
|
||||||
|
|
||||||
**Why this priority**: Per-domain cleanup would recreate inconsistency. The strategic value is the common contract.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by reviewing a bounded first-slice adoption set across multiple domains and confirming that each adopted reason provides the same minimum resolution shape: label, explanation, actionability, and next-step semantics where applicable.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** two different adopted domains expose reasons for blocked or degraded states, **When** the operator compares them, **Then** both use the same translation pattern rather than domain-specific ad-hoc wording.
|
|
||||||
2. **Given** an adopted reason is transient and retryable in one domain and permanent in another, **When** the operator views each result, **Then** the translation contract distinguishes the retryability class clearly.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A reason has a valid internal code but no operator-facing translation yet; the fallback must remain understandable and must not leak a bare internal code as the only message.
|
|
||||||
- A reason is shared by multiple surfaces but needs different surrounding context; the core translated label must stay consistent while the explanation may be surface-specific.
|
|
||||||
- A reason is non-actionable and should explicitly say that no action is required.
|
|
||||||
- A summary surface aggregates multiple reasons; the display must remain human-readable without exposing raw internal keys.
|
|
||||||
- A translated next step points to a protected surface; the product must not reveal inaccessible remediation paths to unauthorized users.
|
|
||||||
- A transient reason later becomes permanent because the underlying state changed; the translation must be recalculated from current classification rather than cached as stale prose.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call path and no new mutation workflow. It defines the shared explanation contract for existing reason-bearing outcomes across current workflows. Existing write and queue safety rules remain unchanged. If adopted surfaces are DB-only, they still remain auditable through current audit and operations records; this spec changes how reasons are translated and surfaced, not whether those records exist.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** This feature reuses existing operation and notification surfaces without changing the Ops-UX three-surface contract. Start toasts remain intent-only. Progress remains confined to active-run surfaces. Terminal notifications remain driven by the existing run lifecycle. This spec changes how terminal and diagnostic reasons are translated for operators, not how runs are created or transitioned. `OperationRun.status` and `OperationRun.outcome` remain service-owned. Any humanized summary labels derived from numeric counts must keep the underlying numeric contract stable.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** This feature affects both tenant/admin and platform/system explanation surfaces. Cross-plane access remains deny-as-not-found. Non-members and wrong-scope actors remain `404`; in-scope actors missing required capabilities remain `403`. Humanized labels, explanations, filter values, summaries, next-step hints, and notification text must not reveal inaccessible records or remediation surfaces. Authorization remains server-side and independent from translation.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not touch authentication handshakes.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** Where adopted reason translations affect status-like labels, they must consume the centralized outcome taxonomy rather than introduce page-local severity mappings. The translation contract must classify actionability and retryability in a way that stays aligned with the shared operator outcome taxonomy.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** The target objects are operator-facing reason labels shown in run details, notifications, banners, summaries, and governance surfaces. Operator-facing wording must preserve the shared vocabulary established by the outcome taxonomy. Implementation-first terms and raw internal code names remain diagnostics-only.
|
|
||||||
|
|
||||||
**Constitution alignment (OPSURF-001):** This feature materially refines existing operator-facing surfaces by moving raw reason strings out of the default-visible path. The primary surface must show operator-first meaning: label, short explanation, and next action or explicit no-action-needed guidance. Raw internal codes, low-level payloads, and engineering detail remain secondary diagnostics. Existing mutation scope language for adopted actions remains unchanged.
|
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** This feature does not introduce a new Filament action family. Existing action surfaces remain responsible for capability gating, confirmation, and audit. The Action Surface Contract remains satisfied because the change is limited to how reasons are explained on existing surfaces.
|
|
||||||
|
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not introduce a new screen category. Adopted surfaces keep their current layouts while replacing raw or overly technical reason text in the default-visible hierarchy with operator-first explanation. Diagnostics remain explicitly secondary.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-157-001**: The system MUST define one shared reason translation contract for adopted reason-bearing states across operations, provider, baseline, execution, operability, verification, RBAC, restore, onboarding, and system-console surfaces.
|
|
||||||
- **FR-157-002**: The shared contract MUST preserve machine-readable internal reason codes as stable backend and audit contracts.
|
|
||||||
- **FR-157-003**: The primary operator-facing surface for an adopted reason MUST use a human-readable label rather than exposing the raw internal reason code as the primary message.
|
|
||||||
- **FR-157-004**: Each adopted translated reason MUST provide, at minimum, a human-readable label and a short explanation suitable for operator-facing surfaces.
|
|
||||||
- **FR-157-005**: Each adopted translated reason MUST also declare whether it is retryable-transient, permanent-configuration, prerequisite-missing, or intentionally non-actionable.
|
|
||||||
- **FR-157-006**: When a translated reason implies a useful next step, the contract MUST provide actionable guidance, a destination, or an explicit instruction.
|
|
||||||
- **FR-157-007**: When a translated reason does not require operator action, the contract MUST say so explicitly rather than leaving the operator to infer that from silence.
|
|
||||||
- **FR-157-008**: Internal reason codes MUST remain available in diagnostic or secondary detail areas for logs, support, and audit-oriented troubleshooting.
|
|
||||||
- **FR-157-009**: The system MUST NOT rename internal reason codes for cosmetic operator-facing wording changes.
|
|
||||||
- **FR-157-010**: Adopted notification payloads MUST use the translated label and explanation contract rather than raw or heuristically sanitized reason fragments.
|
|
||||||
- **FR-157-011**: Adopted run-detail, banner, and summary surfaces MUST consume the shared contract rather than building local ad-hoc string formatting rules for reasons.
|
|
||||||
- **FR-157-012**: The system MUST humanize operator-facing summary labels derived from internal metric or reason keys so raw backend keys do not appear as the default-visible wording.
|
|
||||||
- **FR-157-013**: The system MUST provide one consistent fallback behavior for adopted reasons that have not yet received a domain-specific translation, and that fallback MUST remain understandable to operators by producing a sentence-case label that is not the raw internal code, a concise explanation, and either an explicit next step or an explicit no-action-needed marker.
|
|
||||||
- **FR-157-014**: The first implementation slice MUST cover a bounded adoption set that includes operations, notifications, and at least two additional reason-bearing domain families beyond operations.
|
|
||||||
- **FR-157-015**: The first implementation slice MUST define migration guidance for the existing reason families so downstream domains can adopt the shared contract without inventing parallel translation patterns.
|
|
||||||
- **FR-157-016**: The system MUST retire heuristic, string-matching-only operator reason formatting as the primary translation path on adopted surfaces.
|
|
||||||
- **FR-157-017**: Humanized reason text MUST use the shared vocabulary established by the operator outcome taxonomy and MUST NOT introduce conflicting synonyms for blocked, partial, missing, stale, unsupported, denied, or retry states.
|
|
||||||
- **FR-157-018**: Humanized next-step guidance MUST remain entitlement-safe and MUST NOT reveal inaccessible remediation surfaces or protected tenant information.
|
|
||||||
- **FR-157-019**: The feature MUST include regression coverage proving that translated labels appear on adopted surfaces while raw internal codes remain available in diagnostics.
|
|
||||||
- **FR-157-020**: The feature MUST include regression coverage for retryable, permanent, prerequisite, and non-actionable reason classes.
|
|
||||||
- **FR-157-021**: The feature MUST include at least one positive and one negative authorization regression test proving that translation-backed summaries and next-step hints do not leak unauthorized records or scopes.
|
|
||||||
- **FR-157-022**: The feature MUST allow domain-owned translations to vary in explanation detail when necessary, but the minimum contract shape and operator vocabulary MUST remain consistent across all adopted domains.
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Operations list and run detail | Existing operations surfaces | Existing controls unchanged | Existing run inspection remains primary | None added by this feature | None added by this feature | Existing CTA unchanged | Existing run actions unchanged | N/A | Existing audit model unchanged | This feature changes the explanation contract for reasons, not the action set |
|
|
||||||
| Adopted tenant governance surfaces | Existing tenant-context detail and summary surfaces with reason-bearing states | Existing controls unchanged | Existing row or detail inspection unchanged | None added by this feature | None added by this feature | Existing CTA unchanged | Existing actions unchanged | N/A | Existing audit model unchanged | Applies to reason labels, explanation text, and next-step wording only |
|
|
||||||
| Adopted system-console health and onboarding surfaces | Existing system/operator triage surfaces | Existing controls unchanged | Existing diagnostic drill-in unchanged | None added by this feature | None added by this feature | Existing CTA unchanged | Existing actions unchanged | N/A | Existing audit model unchanged | The change is operator-first reason wording with diagnostics boundary preserved |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Reason Code**: The stable machine-readable identifier that captures why a workflow was blocked, denied, degraded, skipped, or failed.
|
|
||||||
- **Reason Translation**: The operator-facing label and short explanation derived from a stable reason code.
|
|
||||||
- **Reason Resolution Envelope**: The shared operator-facing shape that combines label, explanation, retryability class, and next-step guidance.
|
|
||||||
- **Diagnostic Reason Detail**: Secondary information that preserves the original internal code and low-level context for troubleshooting without becoming the default-visible message.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-157-001**: In the first implementation slice, 100% of adopted reason-bearing primary surfaces show a translated human-readable label instead of a raw internal code as the primary message.
|
|
||||||
- **SC-157-002**: In focused regression coverage, 100% of adopted translated reasons are classified into one of the declared operator-facing actionability classes.
|
|
||||||
- **SC-157-003**: In focused regression coverage, 100% of adopted notification messages use translated reason wording rather than raw or heuristically sanitized reason fragments.
|
|
||||||
- **SC-157-004**: In the manual review protocol defined in `quickstart.md`, 12 curated adopted examples made up of 4 operations cases, 4 provider-guidance cases, 2 tenant-operability cases, and 2 adopted system-console RBAC or onboarding cases MUST be scored against a pass/fail checklist for cause clarity and next-step clarity, and at least 11 of the 12 examples MUST pass.
|
|
||||||
- **SC-157-005**: In focused regression coverage, 100% of adopted surfaces preserve access-safe behavior so that translated reasons and next-step hints do not reveal unauthorized tenant or workspace state.
|
|
||||||
- **SC-157-006**: In the first implementation slice, no adopted surface relies on heuristic free-form string matching as its primary reason-humanization mechanism.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Spec 156 provides the shared operator vocabulary this feature translates into.
|
|
||||||
- Existing machine-readable reason codes across domains are worth preserving as stable contracts for logs, tests, and audit trails.
|
|
||||||
- Not every domain must adopt the contract in one release; the value comes from a shared contract plus a bounded first-slice rollout.
|
|
||||||
- Some adopted surfaces may need surface-specific explanation text, but they should not diverge on label meaning, retryability class, or next-step semantics.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Spec 156 - Operator Outcome Taxonomy and Cross-Domain State Separation
|
|
||||||
- Existing reason-bearing workflows across operations, provider, baseline, verification, restore, onboarding, RBAC, and system-console surfaces
|
|
||||||
- Existing notification and summary surfaces that currently expose raw or overly technical reason wording
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Creating new business-domain reason codes
|
|
||||||
- Changing the semantic meaning of existing reason codes
|
|
||||||
- Renaming backend machine contracts for cosmetic reasons
|
|
||||||
- Redesigning the visual component system or badge infrastructure
|
|
||||||
- Reworking the broader operation naming taxonomy
|
|
||||||
- Extending provider preflight or dispatch gating itself; that remains a downstream consumer of this contract
|
|
||||||
|
|
||||||
## Final Direction
|
|
||||||
|
|
||||||
This spec is the strategically next step after the operator outcome taxonomy because it turns backend truth into operator-usable language without sacrificing backend precision. It defines one shared contract for translating reason codes into label, explanation, retryability, and next-step guidance, so later domain work can explain problems consistently instead of leaking raw internal fragments or inventing one-off wording rules.
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
# Tasks: Operator Reason Code Translation and Humanization Contract
|
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/157-reason-code-translation/`
|
|
||||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
|
|
||||||
|
|
||||||
**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature changes operator-facing behavior on existing runtime surfaces, so tests are required for each implemented story.
|
|
||||||
**Operations**: This feature does not introduce a new `OperationRun` type or change lifecycle ownership, but it does change adopted `OperationRun` notifications and run-detail explanation paths. Tasks must preserve the existing Ops-UX contract, keep `OperationRun.status` and `OperationRun.outcome` service-owned, and avoid adding new queued or running DB notifications.
|
|
||||||
**RBAC**: This feature changes operator-facing explanation text on tenant/admin and platform/system surfaces. Tasks must preserve 404 versus 403 semantics, keep next-step guidance entitlement-safe, and include positive and negative authorization coverage.
|
|
||||||
**Organization**: Tasks are grouped by user story so each story remains independently implementable and testable after the foundational phase.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Create the documentation-first implementation skeleton for the new shared reason-translation slice.
|
|
||||||
|
|
||||||
- [X] T001 Create the shared implementation namespace under `app/Support/ReasonTranslation/`
|
|
||||||
- [X] T002 [P] Create the shared unit-test namespace under `tests/Unit/Support/ReasonTranslation/`
|
|
||||||
- [X] T003 [P] Create the feature-test namespace for translation safety under `tests/Feature/Authorization/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Build the common contract that every adopted reason family and surface will consume.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
|
||||||
|
|
||||||
- [X] T004 Implement the shared next-step value object in `app/Support/ReasonTranslation/NextStepOption.php`
|
|
||||||
- [X] T005 Implement the shared resolution envelope in `app/Support/ReasonTranslation/ReasonResolutionEnvelope.php`
|
|
||||||
- [X] T006 [P] Implement the shared translation contract interface in `app/Support/ReasonTranslation/Contracts/TranslatesReasonCode.php`
|
|
||||||
- [X] T007 Implement the central reason translator registry in `app/Support/ReasonTranslation/ReasonTranslator.php`
|
|
||||||
- [X] T008 Implement bounded fallback translation behavior in `app/Support/ReasonTranslation/FallbackReasonTranslator.php`
|
|
||||||
- [X] T009 [P] Add unit coverage for the shared envelope and fallback contract in `tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php`
|
|
||||||
- [X] T010 [P] Add guard coverage for raw-code primary-message and canonical vocabulary regressions in `tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Shared reason-translation foundation is ready. User story implementation can now begin.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Understand Why Work Was Blocked Or Failed (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Operators see human-readable labels, explanations, and next steps instead of raw internal reason codes on the highest-leverage adopted surfaces.
|
|
||||||
|
|
||||||
**Independent Test**: A blocked or failed run and a blocked provider flow can be opened independently, and both show translated labels plus actionable guidance without exposing raw internal codes as the primary message.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [X] T011 [P] [US1] Extend blocked run behavior coverage in `tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php`
|
|
||||||
- [X] T012 [P] [US1] Extend provider blocked guidance coverage in `tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php`
|
|
||||||
- [X] T013 [P] [US1] Add notification translation coverage in `tests/Feature/Notifications/OperationRunNotificationTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [X] T014 [US1] Implement execution-denial translation behavior in `app/Support/Operations/ExecutionDenialReasonCode.php`
|
|
||||||
- [X] T015 [US1] Add provider reason translation support in `app/Support/Providers/ProviderReasonTranslator.php`
|
|
||||||
- [X] T016 [US1] Refactor provider next-step resolution to consume the shared contract in `app/Support/Providers/ProviderNextStepsRegistry.php`
|
|
||||||
- [X] T017 [US1] Update terminal notification rendering to use translated reason envelopes in `app/Support/OpsUx/OperationUxPresenter.php`
|
|
||||||
- [X] T018 [US1] Update persisted operation notification payloads to use translated reason wording in `app/Notifications/OperationRunCompleted.php`
|
|
||||||
- [X] T019 [US1] Update adopted run-detail reason presentation in `app/Filament/Resources/OperationRunResource.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is independently functional when operations and provider-blocked flows show translated labels and next-step guidance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Preserve Backend Precision Without Polluting The Primary Surface (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Raw internal reason codes remain stable and available in diagnostics while primary adopted surfaces stay operator-first.
|
|
||||||
|
|
||||||
**Independent Test**: Adopted operation and provider surfaces show translated messages by default, while the original internal reason code remains available in diagnostics and stored payloads.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [X] T020 [P] [US2] Add raw-code diagnostic-retention coverage in `tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php`
|
|
||||||
- [X] T021 [P] [US2] Add provider translation and fallback coverage in `tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php`
|
|
||||||
- [X] T022 [P] [US2] Add positive and negative authorization coverage for translated guidance, summaries, and next-step hints in `tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [X] T023 [US2] Add translator-backed diagnostic presentation helpers in `app/Support/ReasonTranslation/ReasonPresenter.php`
|
|
||||||
- [X] T024 [US2] Reduce heuristic reason explanation usage in `app/Support/OpsUx/RunFailureSanitizer.php`
|
|
||||||
- [X] T025 [US2] Update summary-line reason humanization for adopted operation surfaces in `app/Support/OpsUx/SummaryCountsNormalizer.php`
|
|
||||||
- [X] T026 [US2] Preserve internal-code storage while adding translated envelopes in `app/Services/OperationRunService.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is independently functional when diagnostics still expose stable internal codes while primary adopted surfaces no longer rely on them as the headline message.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Reuse One Translation Contract Across Domains (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: The same shared contract works beyond operations and providers by adopting tenant-operability governance and adopted system-console RBAC or onboarding surfaces with consistent actionability semantics.
|
|
||||||
|
|
||||||
**Independent Test**: A tenant-operability surface and an adopted system-console RBAC or onboarding surface can be opened independently and both show the same label, explanation, and actionability shape as the operations and provider slices.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [X] T027 [P] [US3] Add tenant-operability translation coverage in `tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php`
|
|
||||||
- [X] T028 [P] [US3] Add RBAC translation coverage in `tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php`
|
|
||||||
- [X] T029 [P] [US3] Add governance-surface feature coverage for tenant-operability and adopted system-console reason labels in `tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [X] T030 [US3] Implement tenant-operability translation behavior in `app/Support/Tenants/TenantOperabilityReasonCode.php`
|
|
||||||
- [X] T031 [US3] Implement RBAC reason translation behavior in `app/Support/RbacReason.php`
|
|
||||||
- [X] T032 [US3] Update tenant-operability result rendering to consume the shared contract in `app/Services/Tenants/TenantOperabilityService.php`
|
|
||||||
- [X] T033 [US3] Update adopted system-console RBAC health and onboarding surfaces to consume translated reasons in `app/Services/Intune/RbacHealthService.php`
|
|
||||||
|
|
||||||
**Checkpoint**: All three user stories are independently functional once operations, providers, tenant-operability governance, and adopted system-console RBAC or onboarding surfaces share the same translation contract.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Complete consistency, documentation, and final validation across the first slice.
|
|
||||||
|
|
||||||
- [X] T034 [P] Update the internal rollout and validation notes in `specs/157-reason-code-translation/quickstart.md`
|
|
||||||
- [X] T035 Add implementation notes for future adopters in `specs/157-reason-code-translation/research.md`
|
|
||||||
- [X] T036 Run focused first-slice validation from `specs/157-reason-code-translation/quickstart.md`
|
|
||||||
- [X] T037 Run formatting for changed files with `vendor/bin/sail bin pint --dirty --format agent`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
|
||||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
|
||||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
|
||||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow after enough of US1 exists to validate the operator-facing contract on real surfaces.
|
|
||||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and should follow after the contract is proven on operations and provider flows.
|
|
||||||
- **Polish (Phase 6)**: Depends on all implemented stories.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **US1 (P1)**: Can start immediately after the foundational contract is ready; it is the MVP slice.
|
|
||||||
- **US2 (P1)**: Depends on the foundational contract and benefits from US1's adopted operation and provider surfaces.
|
|
||||||
- **US3 (P2)**: Depends on the foundational contract and reuses the patterns proven in US1 and US2.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Tests should be added or extended before implementation changes are finalized.
|
|
||||||
- Translation artifacts before surface wiring.
|
|
||||||
- Surface wiring before feature-level validation.
|
|
||||||
- Story-specific regression coverage before moving to the next story.
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- `T002` and `T003` can run in parallel.
|
|
||||||
- `T004`, `T005`, and `T006` can partially overlap after the namespace exists.
|
|
||||||
- `T009` and `T010` can run in parallel once the foundational classes exist.
|
|
||||||
- Within US1, `T011`, `T012`, and `T013` can run in parallel.
|
|
||||||
- Within US2, `T020`, `T021`, and `T022` can run in parallel.
|
|
||||||
- Within US3, `T027`, `T028`, and `T029` can run in parallel.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Parallelize the story-specific regression coverage:
|
|
||||||
Task: "Extend blocked run behavior coverage in tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php"
|
|
||||||
Task: "Extend provider blocked guidance coverage in tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php"
|
|
||||||
Task: "Add notification translation coverage in tests/Feature/Notifications/OperationRunNotificationTest.php"
|
|
||||||
|
|
||||||
# After the tests exist, parallelize translation seams where they do not touch the same file:
|
|
||||||
Task: "Implement execution-denial translation behavior in app/Support/Operations/ExecutionDenialReasonCode.php"
|
|
||||||
Task: "Add provider reason translation support in app/Support/Providers/ProviderReasonTranslator.php"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### MVP First (User Story 1 Only)
|
|
||||||
|
|
||||||
1. Complete Phase 1: Setup
|
|
||||||
2. Complete Phase 2: Foundational contract work
|
|
||||||
3. Complete Phase 3: User Story 1
|
|
||||||
4. **Stop and validate** with the blocked-run and provider-guidance tests
|
|
||||||
5. Demo the first translated operator-facing slice
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Foundation contract and guards
|
|
||||||
2. Operations and provider translation slice (US1)
|
|
||||||
3. Diagnostic-boundary and fallback hardening (US2)
|
|
||||||
4. Tenant-operability and adopted system-console governance adoption (US3)
|
|
||||||
5. Polish and validation
|
|
||||||
|
|
||||||
### Parallel Team Strategy
|
|
||||||
|
|
||||||
With multiple developers:
|
|
||||||
|
|
||||||
1. One developer builds the shared translation foundation.
|
|
||||||
2. Once the foundation is complete:
|
|
||||||
- Developer A implements operations and notifications.
|
|
||||||
- Developer B implements provider guidance and fallback coverage.
|
|
||||||
- Developer C prepares tenant-operability and RBAC adoption tests.
|
|
||||||
3. Merge only after the guard and authorization-safe translation tests are green.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `[P]` tasks touch different files and can be parallelized safely.
|
|
||||||
- User story labels map directly to the stories in `spec.md`.
|
|
||||||
- Keep the first slice bounded to operations, providers, tenant-operability governance, and adopted system-console RBAC or onboarding surfaces.
|
|
||||||
- Do not expand the adoption set opportunistically into baseline, restore, onboarding raw-string reasons, or verification payload reasons during this tasks pass.
|
|
||||||
- All PHP, Artisan, Composer, and test commands must run through Sail.
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
# Specification Quality Checklist: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
||||||
**Created**: 2026-03-22
|
|
||||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/158-artifact-truth-semantics/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-22 after first-pass authoring.
|
|
||||||
- Scope is intentionally bounded to governance artifacts and artifact-targeted run truth, not general provider or restore lifecycle semantics.
|
|
||||||
|
|
||||||
*** Delete File: /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-artifact-truth-semantics/spec.md
|
|
||||||
@ -1,409 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Governance Artifact Truth Internal Contract
|
|
||||||
version: 0.1.0
|
|
||||||
summary: Internal planning contract for Spec 158 operator-surface read models.
|
|
||||||
description: |
|
|
||||||
This contract documents the normalized truth-envelope expected by the first
|
|
||||||
implementation slice for baseline snapshots, evidence snapshots, tenant
|
|
||||||
reviews, review packs, and artifact-targeted operation runs. These paths map
|
|
||||||
to existing Filament surfaces and internal action handlers; they are not a
|
|
||||||
commitment to a public JSON API.
|
|
||||||
servers:
|
|
||||||
- url: /admin
|
|
||||||
paths:
|
|
||||||
/baseline-snapshots:
|
|
||||||
get:
|
|
||||||
summary: List workspace baseline snapshots with normalized truth summaries
|
|
||||||
operationId: listBaselineSnapshotsWithTruth
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Baseline snapshot list rows
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
|
||||||
/baseline-snapshots/{snapshotId}:
|
|
||||||
get:
|
|
||||||
summary: Read one workspace baseline snapshot with truth envelope and diagnostics
|
|
||||||
operationId: viewBaselineSnapshotWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/SnapshotId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Baseline snapshot detail
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
|
||||||
/evidence/overview:
|
|
||||||
get:
|
|
||||||
summary: List canonical workspace evidence rows limited to entitled tenants
|
|
||||||
operationId: listEvidenceOverviewWithTruth
|
|
||||||
parameters:
|
|
||||||
- in: query
|
|
||||||
name: tenant_id
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
required: false
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Canonical evidence overview rows
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
|
||||||
/t/{tenantId}/evidence:
|
|
||||||
get:
|
|
||||||
summary: List tenant evidence snapshots with truth summaries
|
|
||||||
operationId: listEvidenceSnapshotsWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/TenantId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Evidence snapshot list rows
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
|
||||||
/t/{tenantId}/evidence/{snapshotId}:
|
|
||||||
get:
|
|
||||||
summary: Read one evidence snapshot with truth envelope, per-dimension state, and diagnostics
|
|
||||||
operationId: viewEvidenceSnapshotWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/TenantId'
|
|
||||||
- $ref: '#/components/parameters/SnapshotId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Evidence snapshot detail
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
|
||||||
/reviews:
|
|
||||||
get:
|
|
||||||
summary: List canonical review register rows limited to entitled tenants
|
|
||||||
operationId: listReviewRegisterWithTruth
|
|
||||||
parameters:
|
|
||||||
- in: query
|
|
||||||
name: tenant_id
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
required: false
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Canonical review register rows
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
|
||||||
/t/{tenantId}/reviews:
|
|
||||||
get:
|
|
||||||
summary: List tenant reviews with lifecycle, completeness, and publication truth
|
|
||||||
operationId: listTenantReviewsWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/TenantId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Tenant review list rows
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
|
||||||
/t/{tenantId}/reviews/{reviewId}:
|
|
||||||
get:
|
|
||||||
summary: Read one tenant review with truth envelope and blocker explanations
|
|
||||||
operationId: viewTenantReviewWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/TenantId'
|
|
||||||
- $ref: '#/components/parameters/ReviewId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Tenant review detail
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
|
||||||
/t/{tenantId}/review-packs:
|
|
||||||
get:
|
|
||||||
summary: List review packs with lifecycle and provenance truth
|
|
||||||
operationId: listReviewPacksWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/TenantId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Review-pack list rows
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
|
||||||
/t/{tenantId}/review-packs/{packId}:
|
|
||||||
get:
|
|
||||||
summary: Read one review pack with truth envelope, provenance, and diagnostics
|
|
||||||
operationId: viewReviewPackWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/TenantId'
|
|
||||||
- $ref: '#/components/parameters/PackId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Review-pack detail
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
|
||||||
/operations/{runId}:
|
|
||||||
get:
|
|
||||||
summary: Read canonical artifact-targeted run detail with normalized artifact result
|
|
||||||
operationId: viewArtifactRunWithTruth
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/RunId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Operation-run detail
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ArtifactRunDetailResponse'
|
|
||||||
components:
|
|
||||||
parameters:
|
|
||||||
TenantId:
|
|
||||||
in: path
|
|
||||||
name: tenantId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
SnapshotId:
|
|
||||||
in: path
|
|
||||||
name: snapshotId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
ReviewId:
|
|
||||||
in: path
|
|
||||||
name: reviewId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
PackId:
|
|
||||||
in: path
|
|
||||||
name: packId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
RunId:
|
|
||||||
in: path
|
|
||||||
name: runId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
schemas:
|
|
||||||
ArtifactCollectionResponse:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- rows
|
|
||||||
properties:
|
|
||||||
rows:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ArtifactSummaryRow'
|
|
||||||
ArtifactDetailResponse:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- artifact
|
|
||||||
- truth
|
|
||||||
properties:
|
|
||||||
artifact:
|
|
||||||
type: object
|
|
||||||
additionalProperties: true
|
|
||||||
truth:
|
|
||||||
$ref: '#/components/schemas/ArtifactTruthEnvelope'
|
|
||||||
diagnostics:
|
|
||||||
type: object
|
|
||||||
additionalProperties: true
|
|
||||||
ArtifactRunDetailResponse:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ArtifactDetailResponse'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
run:
|
|
||||||
type: object
|
|
||||||
additionalProperties: true
|
|
||||||
ArtifactSummaryRow:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- artifactKey
|
|
||||||
- artifactFamily
|
|
||||||
- truth
|
|
||||||
properties:
|
|
||||||
artifactKey:
|
|
||||||
type: string
|
|
||||||
artifactFamily:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- baseline_snapshot
|
|
||||||
- evidence_snapshot
|
|
||||||
- tenant_review
|
|
||||||
- review_pack
|
|
||||||
- artifact_run
|
|
||||||
displayTitle:
|
|
||||||
type: string
|
|
||||||
tenantName:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
truth:
|
|
||||||
$ref: '#/components/schemas/ArtifactTruthEnvelope'
|
|
||||||
ArtifactTruthEnvelope:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- artifactFamily
|
|
||||||
- artifactExistence
|
|
||||||
- contentState
|
|
||||||
- freshnessState
|
|
||||||
- supportState
|
|
||||||
- actionability
|
|
||||||
- primaryLabel
|
|
||||||
properties:
|
|
||||||
artifactFamily:
|
|
||||||
type: string
|
|
||||||
executionOutcome:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
enum:
|
|
||||||
- pending
|
|
||||||
- succeeded
|
|
||||||
- partially_succeeded
|
|
||||||
- blocked
|
|
||||||
- failed
|
|
||||||
- null
|
|
||||||
artifactExistence:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- not_created
|
|
||||||
- historical_only
|
|
||||||
- created
|
|
||||||
- created_but_not_usable
|
|
||||||
contentState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- trusted
|
|
||||||
- partial
|
|
||||||
- missing_input
|
|
||||||
- metadata_only
|
|
||||||
- reference_only
|
|
||||||
- empty
|
|
||||||
- unsupported
|
|
||||||
freshnessState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- current
|
|
||||||
- stale
|
|
||||||
- unknown
|
|
||||||
publicationReadiness:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
enum:
|
|
||||||
- not_applicable
|
|
||||||
- internal_only
|
|
||||||
- publishable
|
|
||||||
- blocked
|
|
||||||
- null
|
|
||||||
supportState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- normal
|
|
||||||
- limited_support
|
|
||||||
actionability:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- none
|
|
||||||
- optional
|
|
||||||
- required
|
|
||||||
primaryLabel:
|
|
||||||
type: string
|
|
||||||
primaryExplanation:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
reason:
|
|
||||||
$ref: '#/components/schemas/ArtifactTruthCause'
|
|
||||||
nextAction:
|
|
||||||
$ref: '#/components/schemas/ArtifactTruthNextAction'
|
|
||||||
dimensions:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ArtifactTruthDimension'
|
|
||||||
ArtifactTruthDimension:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- axis
|
|
||||||
- state
|
|
||||||
- label
|
|
||||||
- classification
|
|
||||||
properties:
|
|
||||||
axis:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- execution_outcome
|
|
||||||
- artifact_existence
|
|
||||||
- content_fidelity
|
|
||||||
- data_freshness
|
|
||||||
- publication_readiness
|
|
||||||
- support_maturity
|
|
||||||
- operator_actionability
|
|
||||||
state:
|
|
||||||
type: string
|
|
||||||
label:
|
|
||||||
type: string
|
|
||||||
classification:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- primary
|
|
||||||
- secondary
|
|
||||||
- diagnostic
|
|
||||||
badgeDomain:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
badgeState:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
ArtifactTruthCause:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
reasonCode:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
operatorLabel:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
shortExplanation:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
diagnosticCode:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
nextSteps:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
ArtifactTruthNextAction:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
label:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
url:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
required:
|
|
||||||
type: boolean
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
# Data Model: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This feature introduces a shared read-model for operator-facing artifact truth across existing governance artifacts. The first slice does not require a new primary table. Instead, it standardizes how existing persisted artifact records are projected into one truth envelope.
|
|
||||||
|
|
||||||
## Entities
|
|
||||||
|
|
||||||
### ArtifactTruthEnvelope
|
|
||||||
|
|
||||||
Operator-facing read model derived from one governance artifact or artifact-targeted run.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `artifactFamily` (enum): `baseline_snapshot`, `evidence_snapshot`, `tenant_review`, `review_pack`, `artifact_run`
|
|
||||||
- `artifactKey` (string): stable identifier in the form `{family}:{id}`
|
|
||||||
- `workspaceId` (int)
|
|
||||||
- `tenantId` (int|null): `null` for workspace-owned baseline snapshots and workspace-level runs
|
|
||||||
- `executionOutcome` (enum|null): `pending`, `succeeded`, `partially_succeeded`, `blocked`, `failed`
|
|
||||||
- `artifactExistence` (enum): `not_created`, `historical_only`, `created`, `created_but_not_usable`
|
|
||||||
- `contentState` (enum): `trusted`, `partial`, `missing_input`, `metadata_only`, `reference_only`, `empty`, `unsupported`
|
|
||||||
- `freshnessState` (enum): `current`, `stale`, `unknown`
|
|
||||||
- `publicationReadiness` (enum|null): `not_applicable`, `internal_only`, `publishable`, `blocked`
|
|
||||||
- `supportState` (enum): `normal`, `limited_support`
|
|
||||||
- `actionability` (enum): `none`, `optional`, `required`
|
|
||||||
- `primaryReasonCode` (string|null)
|
|
||||||
- `primaryLabel` (string): top-level operator-facing state summary
|
|
||||||
- `primaryExplanation` (string|null): concise explanation of the main degraded dimension
|
|
||||||
- `diagnosticLabel` (string|null): renderer or implementation-oriented label shown only secondarily
|
|
||||||
- `nextActionLabel` (string|null)
|
|
||||||
- `nextActionUrl` (string|null)
|
|
||||||
- `relatedRunId` (int|null)
|
|
||||||
- `relatedArtifactUrl` (string|null)
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- `artifactExistence = not_created` MUST NOT coexist with `contentState = trusted`.
|
|
||||||
- `publicationReadiness` MAY be non-null only for review and review-pack families.
|
|
||||||
- `supportState = limited_support` MUST NOT be used as the primary failure dimension if another truth dimension explains operator impact.
|
|
||||||
- `actionability = required` MUST include either `nextActionLabel` or a stable reason explanation.
|
|
||||||
|
|
||||||
### ArtifactTruthDimension
|
|
||||||
|
|
||||||
Normalized dimension entry for badges, helper text, summaries, and canonical filter values.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `axis` (enum): `execution_outcome`, `artifact_existence`, `content_fidelity`, `data_freshness`, `publication_readiness`, `support_maturity`, `operator_actionability`
|
|
||||||
- `state` (string)
|
|
||||||
- `label` (string)
|
|
||||||
- `classification` (enum): `primary`, `secondary`, `diagnostic`
|
|
||||||
- `badgeDomain` (string|null)
|
|
||||||
- `badgeState` (string|null)
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- At most one `primary` dimension may drive the top-level alert state at a time.
|
|
||||||
- Diagnostic dimensions MUST still be preserved for detail pages even when not rendered on list pages.
|
|
||||||
|
|
||||||
### ArtifactTruthCause
|
|
||||||
|
|
||||||
Stable explanation payload for degraded states.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
- `reasonCode` (string|null)
|
|
||||||
- `translationArtifact` (string|null): `provider_reason_codes`, `execution_denial_reason_code`, `tenant_operability_reason_code`, `rbac_reason`, or a bounded baseline/review artifact
|
|
||||||
- `operatorLabel` (string|null)
|
|
||||||
- `shortExplanation` (string|null)
|
|
||||||
- `diagnosticCode` (string|null)
|
|
||||||
- `nextSteps` (list<string>)
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
- If `reasonCode` is translated, `operatorLabel` SHOULD come from the centralized translator.
|
|
||||||
- Unknown codes MAY fall back to persisted message text, but raw codes remain diagnostics-only.
|
|
||||||
|
|
||||||
## Source Projections
|
|
||||||
|
|
||||||
### BaselineSnapshotProjection
|
|
||||||
|
|
||||||
Existing workspace-owned source record: `BaselineSnapshot`.
|
|
||||||
|
|
||||||
**Source fields used**:
|
|
||||||
- `id`, `workspace_id`, `captured_at`
|
|
||||||
- `summary_jsonb.fidelity_counts`
|
|
||||||
- `summary_jsonb.gaps`
|
|
||||||
- derived fidelity from `FidelityState::fromSummary(...)`
|
|
||||||
|
|
||||||
**Derived truth rules**:
|
|
||||||
- Full artifact trust requires usable captured content and no misleading gap interpretation.
|
|
||||||
- `unsupported` fidelity remains diagnostic-only unless it also implies that no trustworthy comparison artifact exists.
|
|
||||||
- Zero-subject or unusable-upstream cases should be explained from related run context where available.
|
|
||||||
|
|
||||||
### EvidenceSnapshotProjection
|
|
||||||
|
|
||||||
Existing tenant-owned source record: `EvidenceSnapshot`.
|
|
||||||
|
|
||||||
**Source fields used**:
|
|
||||||
- `id`, `workspace_id`, `tenant_id`, `status`, `completeness_state`, `generated_at`, `expires_at`
|
|
||||||
- `summary.missing_dimensions`, `summary.stale_dimensions`
|
|
||||||
- child `items.state`, `items.source_kind`, `items.freshness_at`
|
|
||||||
- `operation_run_id`
|
|
||||||
|
|
||||||
**Derived truth rules**:
|
|
||||||
- `status` models lifecycle; `completeness_state` models coverage/freshness and must not be collapsed into lifecycle.
|
|
||||||
- `missing_dimensions > 0` indicates incomplete coverage, not run failure.
|
|
||||||
- `stale_dimensions > 0` indicates freshness follow-up, not absence.
|
|
||||||
|
|
||||||
### TenantReviewProjection
|
|
||||||
|
|
||||||
Existing tenant-owned source record: `TenantReview`.
|
|
||||||
|
|
||||||
**Source fields used**:
|
|
||||||
- `id`, `workspace_id`, `tenant_id`, `status`, `completeness_state`, `generated_at`, `published_at`, `archived_at`
|
|
||||||
- `summary.publish_blockers`
|
|
||||||
- `summary.section_state_counts`
|
|
||||||
- `evidence_snapshot_id`, `current_export_review_pack_id`, `operation_run_id`
|
|
||||||
- child `sections.completeness_state`, `sections.measured_at`
|
|
||||||
|
|
||||||
**Derived truth rules**:
|
|
||||||
- `status` answers lifecycle (`draft`, `ready`, `published`, etc.), not evidence completeness by itself.
|
|
||||||
- Publish blockers control `publicationReadiness = blocked` even when the review artifact exists.
|
|
||||||
- A review may be internally useful while still not publishable.
|
|
||||||
|
|
||||||
### ReviewPackProjection
|
|
||||||
|
|
||||||
Existing tenant-owned source record: `ReviewPack`.
|
|
||||||
|
|
||||||
**Source fields used**:
|
|
||||||
- `id`, `workspace_id`, `tenant_id`, `status`, `generated_at`, `expires_at`, `file_size`
|
|
||||||
- `summary.review_status`
|
|
||||||
- `summary.evidence_resolution.*`
|
|
||||||
- `tenant_review_id`, `evidence_snapshot_id`, `operation_run_id`
|
|
||||||
|
|
||||||
**Derived truth rules**:
|
|
||||||
- `status = ready` means a file exists, not necessarily that the source review is still publishable.
|
|
||||||
- Provenance must consider linked review state and evidence completeness at generation time.
|
|
||||||
- `expired` is historical-only rather than a runtime failure.
|
|
||||||
|
|
||||||
### ArtifactRunProjection
|
|
||||||
|
|
||||||
Existing source record: `OperationRun` limited to artifact-targeted families.
|
|
||||||
|
|
||||||
**Source fields used**:
|
|
||||||
- `id`, `workspace_id`, `tenant_id`, `type`, `status`, `outcome`, `summary_counts`, `failure_summary`, `context`
|
|
||||||
- translated reason via `ReasonPresenter`
|
|
||||||
- related links via `OperationRunLinks`
|
|
||||||
|
|
||||||
**Derived truth rules**:
|
|
||||||
- Run lifecycle and run outcome remain distinct from whether an artifact was actually produced.
|
|
||||||
- Artifact-targeted families in this slice: `baseline_capture`, `tenant.evidence.snapshot.generate`, `tenant.review.compose`, `tenant.review_pack.generate`
|
|
||||||
- The run truth section may reuse persisted `context` or summary enrichment to state whether the intended artifact exists and is usable.
|
|
||||||
|
|
||||||
## State Transitions
|
|
||||||
|
|
||||||
### Truth-envelope transitions
|
|
||||||
|
|
||||||
These are read-model transitions, not persistence transitions:
|
|
||||||
|
|
||||||
1. `not_created` → `created`
|
|
||||||
- Trigger: artifact record becomes available and usable.
|
|
||||||
2. `created` → `created_but_not_usable`
|
|
||||||
- Trigger: artifact exists but content, freshness, or readiness makes it unsafe for the primary operator task.
|
|
||||||
3. `created` or `created_but_not_usable` → `historical_only`
|
|
||||||
- Trigger: artifact remains intelligible for history, but is expired, superseded, or no longer current for primary use.
|
|
||||||
4. `publicationReadiness = blocked` → `publishable`
|
|
||||||
- Trigger: review blockers clear or a derived pack is generated from a publishable review.
|
|
||||||
|
|
||||||
### Existing persisted lifecycle references
|
|
||||||
|
|
||||||
- `EvidenceSnapshot.status`: `queued` → `generating` → `active` → `superseded|expired|failed`
|
|
||||||
- `TenantReview.status`: `draft` → `ready` → `published|archived|superseded|failed`
|
|
||||||
- `ReviewPack.status`: `queued` → `generating` → `ready|failed|expired`
|
|
||||||
- `OperationRun.status/outcome`: service-owned lifecycle that remains unchanged by this feature
|
|
||||||
|
|
||||||
## No New Primary Persistence in First Slice
|
|
||||||
|
|
||||||
- No new top-level table is required.
|
|
||||||
- Backward-compatible enrichment of existing JSON payloads is allowed if a family cannot otherwise satisfy truthful artifact provenance.
|
|
||||||
- Any enrichment must remain optional for historical records and degrade gracefully when absent.
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
# Implementation Plan: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
|
||||||
|
|
||||||
**Branch**: `158-artifact-truth-semantics` | **Date**: 2026-03-22 | **Spec**: [spec.md](./spec.md)
|
|
||||||
**Input**: Feature specification from `/specs/158-artifact-truth-semantics/spec.md`
|
|
||||||
|
|
||||||
**Note**: This plan covers the first bounded adoption slice for truthful artifact semantics across baseline snapshots, evidence snapshots, tenant reviews, review packs, and artifact-targeted operation-run detail. It intentionally excludes provider dispatch gating, restore-lifecycle cleanup, and broad platform-wide status redesign.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Implement a shared governance-artifact truth layer that separates artifact existence, execution outcome, completeness or fidelity, freshness, publication readiness, support maturity, and operator actionability across existing governance surfaces. The first slice is presentation-first: reuse current persisted enums, `summary` payloads, `context` payloads, badge domains, reason translation, and canonical RBAC filters wherever they already encode the truth, then add only targeted derived helpers or minimal summary/context enrichment when a surface cannot distinguish trustworthy vs merely present artifacts or cannot explain current-vs-historical artifact provenance. Integration lands in existing Filament resources and canonical pages, plus the tenantless `OperationRun` detail view, with focused regression coverage for false-green baselines, partial or stale evidence, non-publishable reviews, review-pack provenance, empty-state distinctions, and tenant-safe canonical summaries.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15
|
|
||||||
**Primary Dependencies**: 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
|
|
||||||
**Storage**: 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, but minimal additive summary/context enrichment is allowed where truthful provenance cannot otherwise be derived
|
|
||||||
**Testing**: Pest feature tests, Pest unit tests, and Livewire/Filament component tests
|
|
||||||
**Target Platform**: Laravel Sail web application with Filament admin and tenant panels
|
|
||||||
**Project Type**: Web application monolith
|
|
||||||
**Performance Goals**: All adopted list/detail surfaces remain DB-only at render time; canonical evidence/review pages keep entitlement filtering query-first; no added remote calls; artifact-truth presentation adds negligible overhead beyond current eager-loading and summary rendering
|
|
||||||
**Constraints**: No new Microsoft Graph call path; no new artifact-producing operation family; existing destructive actions keep confirmation and audit semantics; `OperationRun.status` and `OperationRun.outcome` remain service-owned; canonical pages must not leak unauthorized tenant counts, filters, or labels
|
|
||||||
**Scale/Scope**: First slice covers 8 existing surfaces across 4 artifact families plus tenantless run detail, using one shared truth vocabulary and preserving historical artifact intelligibility without widening into inventory, onboarding, or restore domains
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
||||||
|
|
||||||
- **Pre-Phase-0 Gate: PASS**
|
|
||||||
- Inventory-first: PASS. The feature reclassifies existing baseline, evidence, review, and review-pack artifacts; it does not change the inventory/evidence collection model or promote snapshots over last-observed state.
|
|
||||||
- Read/write separation: PASS. The slice is primarily presentational. Existing writes such as refresh, publish, export, archive, and expire remain explicit, confirmed where destructive, audited where already required, and tested.
|
|
||||||
- Graph contract path: PASS. No new Graph calls or contract changes are introduced.
|
|
||||||
- Deterministic capabilities: PASS. Existing capabilities remain canonical (`workspace baselines`, `evidence.view/manage`, `tenant_review.view/manage`, `review_pack.view/manage`); no raw role checks are added.
|
|
||||||
- RBAC-UX and isolation: PASS. Tenant detail remains tenant-scoped; workspace canonical evidence/review pages stay query-scoped to entitled tenants only; non-members remain 404, in-scope capability denials remain 403.
|
|
||||||
- Global search: PASS. All affected resources are already non-globally-searchable or unchanged in search behavior; no new global-search surface is added.
|
|
||||||
- Run observability: PASS. Existing artifact-producing operations already use `OperationRun`; this slice only improves how run detail communicates artifact truth.
|
|
||||||
- Ops-UX 3-surface feedback: PASS. No new feedback surface is introduced; run detail remains the canonical live/terminal interpretation surface.
|
|
||||||
- Ops-UX lifecycle ownership: PASS. No direct `status` or `outcome` transitions are introduced outside `OperationRunService`.
|
|
||||||
- Ops-UX summary counts: PASS. Existing numeric summary counts remain canonical; artifact-truth interpretation may consume them but will not replace them with free-text metrics.
|
|
||||||
- Data minimization: PASS. The design prefers derived truth envelopes over new raw payload persistence.
|
|
||||||
- BADGE-001: PASS. Shared semantics must flow through centralized badge domains or presenter helpers, not page-local label/color mappings.
|
|
||||||
- UI-NAMING-001 and OPSURF-001: PASS. Operator-facing surfaces lead with trustworthy artifact answers and keep renderer/support limitations in secondary diagnostics.
|
|
||||||
- Filament Action Surface Contract: PASS. The first slice changes status messaging, helper copy, empty states, and action-enable explanations on existing resources/pages without expanding mutation surface area.
|
|
||||||
- Filament UX-001: PASS. Existing list/detail patterns remain intact; truth messaging is layered into current Infolists, tables, and canonical pages.
|
|
||||||
- UI-STD-001 list surface checklist: PASS WITH REQUIRED REVIEW. Because existing baseline, evidence, tenant review, review-pack, and canonical summary list surfaces are modified, implementation and validation must reference `docs/product/standards/list-surface-review-checklist.md`.
|
|
||||||
|
|
||||||
**Post-Phase-1 Re-check: PASS**
|
|
||||||
- The proposed design keeps the slice presentation-first, respects tenant/workspace ownership, avoids new remote work, reuses existing canonical action surfaces, and introduces no constitution violations.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/158-artifact-truth-semantics/
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── artifact-truth.openapi.yaml
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/
|
|
||||||
├── Filament/
|
|
||||||
│ ├── Pages/
|
|
||||||
│ │ ├── Monitoring/
|
|
||||||
│ │ ├── Operations/
|
|
||||||
│ │ └── Reviews/
|
|
||||||
│ └── Resources/
|
|
||||||
│ ├── BaselineSnapshotResource.php
|
|
||||||
│ ├── EvidenceSnapshotResource.php
|
|
||||||
│ ├── ReviewPackResource.php
|
|
||||||
│ ├── TenantReviewResource.php
|
|
||||||
│ └── OperationRunResource.php
|
|
||||||
├── Models/
|
|
||||||
│ ├── EvidenceSnapshot.php
|
|
||||||
│ ├── ReviewPack.php
|
|
||||||
│ ├── TenantReview.php
|
|
||||||
│ └── OperationRun.php
|
|
||||||
├── Services/
|
|
||||||
│ ├── Baselines/
|
|
||||||
│ ├── Evidence/
|
|
||||||
│ └── TenantReviews/
|
|
||||||
└── Support/
|
|
||||||
├── Badges/
|
|
||||||
├── ReasonTranslation/
|
|
||||||
├── Ui/
|
|
||||||
└── Operations/
|
|
||||||
|
|
||||||
resources/
|
|
||||||
└── views/
|
|
||||||
└── filament/
|
|
||||||
|
|
||||||
tests/
|
|
||||||
├── Feature/
|
|
||||||
│ ├── Evidence/
|
|
||||||
│ ├── TenantReview/
|
|
||||||
│ ├── ReviewPack/
|
|
||||||
│ ├── Monitoring/
|
|
||||||
│ └── ManagedTenants/
|
|
||||||
└── Unit/
|
|
||||||
├── Evidence/
|
|
||||||
├── TenantReview/
|
|
||||||
├── ReviewPack/
|
|
||||||
└── Badges/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Keep the existing Laravel monolith structure and deliver the slice by modifying existing resources/pages, shared badge or presenter support, and focused tests. No new base folders or new runtime subsystems are required. Any new helper should live under existing `app/Support` or domain service namespaces rather than introducing a new module root.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| None | N/A | N/A |
|
|
||||||
|
|
||||||
## Filament v5 Agent Output Contract
|
|
||||||
|
|
||||||
1. **Livewire v4.0+ compliance**: Yes. The plan stays within Filament v5 + Livewire v4 components and helpers already present in the repo.
|
|
||||||
2. **Provider registration**: No new panel is introduced. Existing panel providers remain registered in `bootstrap/providers.php`.
|
|
||||||
3. **Global search**: Affected resources remain non-globally-searchable in this slice, so the Edit/View-page rule is unchanged. No new globally searchable resource is added.
|
|
||||||
4. **Destructive actions**: Existing destructive actions remain the only destructive actions in scope: evidence snapshot expire, review archive, review-pack expire, and any existing destructive baseline actions. They continue to use `->action(...)`, confirmation, and server-side authorization.
|
|
||||||
5. **Asset strategy**: No new custom assets are planned. The slice uses existing Filament rendering, badges, and Blade views. Deployment still relies on the existing Filament asset pipeline, including `vendor/bin/sail artisan filament:assets` where registered assets are deployed.
|
|
||||||
6. **Testing plan**: Cover unit semantics for badge or truth mapping, Livewire or feature tests for modified baseline/evidence/review/review-pack Filament resources and canonical pages, canonical RBAC regression tests for evidence/review summaries, operation-run detail regression tests for artifact-targeted truth messaging, empty-state distinctions, and the 12-case curated manual validation set captured in `quickstart.md`.
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
# Quickstart: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Deliver the first truthful-artifact slice without widening scope beyond existing governance artifacts and artifact-targeted run detail.
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Shared Truth Envelope
|
|
||||||
|
|
||||||
**Goal:** Centralize how governance artifacts express truth, degradation, readiness, and next steps.
|
|
||||||
|
|
||||||
1. Add or extend shared presenter support under existing `app/Support` or domain service namespaces.
|
|
||||||
2. Normalize which dimensions are primary vs diagnostic for:
|
|
||||||
- baseline snapshot fidelity and gaps
|
|
||||||
- evidence completeness and freshness
|
|
||||||
- tenant review completeness and publication blockers
|
|
||||||
- review-pack lifecycle vs provenance
|
|
||||||
- artifact-targeted run outcome vs artifact result
|
|
||||||
3. Reuse `BadgeCatalog`, `BadgeRenderer`, `OperatorOutcomeTaxonomy`, and `ReasonPresenter` instead of page-local label logic.
|
|
||||||
4. Add targeted unit tests for any new or remapped badge or presenter semantics.
|
|
||||||
|
|
||||||
### Phase 2: Artifact Family Integration
|
|
||||||
|
|
||||||
**Goal:** Apply the truth envelope to existing resource lists and detail views.
|
|
||||||
|
|
||||||
1. Update baseline snapshot list/detail messaging so fidelity/support signals stay diagnostic while false-green capture outcomes are clearly degraded.
|
|
||||||
2. Update evidence snapshot list/detail and evidence overview rows so completeness, freshness, and next-step semantics are explicit.
|
|
||||||
3. Update tenant review list/detail and review register so review existence, completeness, and publication readiness are separated.
|
|
||||||
4. Update review-pack list/detail so file existence is separated from trustworthy stakeholder-output provenance.
|
|
||||||
5. Keep current action surfaces and authorization rules unchanged unless explanation or enablement reasons must reflect truth semantics.
|
|
||||||
|
|
||||||
### Phase 3: Canonical Run Detail Integration
|
|
||||||
|
|
||||||
**Goal:** Make artifact-targeted operation runs answer whether a trustworthy artifact was produced.
|
|
||||||
|
|
||||||
1. Extend the operation-run enterprise detail builder for:
|
|
||||||
- `baseline_capture`
|
|
||||||
- `tenant.evidence.snapshot.generate`
|
|
||||||
- `tenant.review.compose`
|
|
||||||
- `tenant.review_pack.generate`
|
|
||||||
2. Show a primary artifact-truth summary before raw JSON/context.
|
|
||||||
3. Reuse related links and reason translation for next-step guidance.
|
|
||||||
4. Preserve diagnostics as a secondary boundary.
|
|
||||||
|
|
||||||
### Phase 4: RBAC and Regression Coverage
|
|
||||||
|
|
||||||
**Goal:** Prove the slice is truthful and tenant-safe.
|
|
||||||
|
|
||||||
1. Add positive and negative authorization tests for canonical evidence and review pages.
|
|
||||||
2. Add focused coverage for:
|
|
||||||
- false-green baseline examples
|
|
||||||
- partial and stale evidence
|
|
||||||
- non-publishable reviews
|
|
||||||
- review-pack provenance and historical availability
|
|
||||||
- empty-state distinctions for not-created, degraded, and historical-only cases
|
|
||||||
- artifact-targeted run detail messaging
|
|
||||||
3. Run formatter and affected tests.
|
|
||||||
4. Review the affected list surfaces against `docs/product/standards/list-surface-review-checklist.md`.
|
|
||||||
|
|
||||||
## Curated Manual Validation Set
|
|
||||||
|
|
||||||
Validate 12 curated artifact cases before sign-off so the operator can answer from one inspection step whether the artifact exists, whether it is trustworthy, and whether action is required.
|
|
||||||
|
|
||||||
1. Healthy baseline artifact
|
|
||||||
2. False-green baseline with no trustworthy artifact
|
|
||||||
3. Historical baseline trace that is intentionally non-usable
|
|
||||||
4. Healthy evidence snapshot
|
|
||||||
5. Partial evidence snapshot with missing dimensions
|
|
||||||
6. Stale evidence snapshot with no further action required
|
|
||||||
7. Draft tenant review that is internally useful but not publishable
|
|
||||||
8. Blocked tenant review with explicit next step
|
|
||||||
9. Publishable tenant review
|
|
||||||
10. Historical review pack derived from a formerly publishable review
|
|
||||||
11. Current review pack blocked by regressed source readiness
|
|
||||||
12. Artifact-targeted run that completed but produced a degraded artifact
|
|
||||||
|
|
||||||
## Completed Validation Checklist
|
|
||||||
|
|
||||||
Validated on March 22, 2026 through the focused Spec 158 regression suite plus the canonical authorization and guard checks.
|
|
||||||
|
|
||||||
- [x] Healthy baseline artifact
|
|
||||||
- [x] False-green baseline with no trustworthy artifact
|
|
||||||
- [x] Historical baseline trace that is intentionally non-usable
|
|
||||||
- [x] Healthy evidence snapshot
|
|
||||||
- [x] Partial evidence snapshot with missing dimensions
|
|
||||||
- [x] Stale evidence snapshot with no further action required
|
|
||||||
- [x] Draft tenant review that is internally useful but not publishable
|
|
||||||
- [x] Blocked tenant review with explicit next step
|
|
||||||
- [x] Publishable tenant review
|
|
||||||
- [x] Historical review pack derived from a formerly publishable review
|
|
||||||
- [x] Current review pack blocked by regressed source readiness
|
|
||||||
- [x] Artifact-targeted run that completed but produced a degraded artifact
|
|
||||||
|
|
||||||
## List Surface Review Notes
|
|
||||||
|
|
||||||
- [x] Baseline snapshot list/detail keeps artifact truth separate from fidelity diagnostics while preserving the existing sortable/searchable structure.
|
|
||||||
- [x] Evidence overview remains a tenant-safe read-only summary with one drill-down action per row and explicit artifact-truth, freshness, and next-step columns.
|
|
||||||
- [x] Evidence snapshot, tenant review, review register, and review pack surfaces keep badge-based state rendering, domain-specific empty states, and authorization boundaries intact after the new truth columns were added.
|
|
||||||
- [x] Canonical evidence and review summaries continue to scope rows, tenant filters, and drill-down links to entitled tenants only.
|
|
||||||
|
|
||||||
## Likely File Inventory
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/Filament/Resources/BaselineSnapshotResource.php
|
|
||||||
app/Filament/Resources/EvidenceSnapshotResource.php
|
|
||||||
app/Filament/Resources/TenantReviewResource.php
|
|
||||||
app/Filament/Resources/ReviewPackResource.php
|
|
||||||
app/Filament/Resources/OperationRunResource.php
|
|
||||||
app/Filament/Pages/Monitoring/EvidenceOverview.php
|
|
||||||
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
|
||||||
app/Filament/Pages/Reviews/ReviewRegister.php
|
|
||||||
app/Support/Badges/OperatorOutcomeTaxonomy.php
|
|
||||||
app/Support/Badges/BadgeCatalog.php
|
|
||||||
app/Support/Badges/Domains/*.php
|
|
||||||
app/Support/ReasonTranslation/*.php
|
|
||||||
app/Services/TenantReviews/TenantReviewReadinessGate.php
|
|
||||||
tests/Feature/Evidence/*.php
|
|
||||||
tests/Feature/TenantReview/*.php
|
|
||||||
tests/Feature/ReviewPack/*.php
|
|
||||||
tests/Feature/Monitoring/*.php
|
|
||||||
tests/Unit/**/*.php
|
|
||||||
```
|
|
||||||
|
|
||||||
### Possible New Helper Files
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/Support/Ui/GovernanceArtifactTruth/*.php
|
|
||||||
app/Support/Badges/Domains/<new-or-remapped-domain>.php
|
|
||||||
tests/Unit/Badges/GovernanceArtifactTruthTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vendor/bin/sail artisan test --compact tests/Feature/Evidence
|
|
||||||
vendor/bin/sail artisan test --compact tests/Feature/TenantReview
|
|
||||||
vendor/bin/sail artisan test --compact tests/Feature/ReviewPack
|
|
||||||
vendor/bin/sail artisan test --compact tests/Feature/Monitoring
|
|
||||||
vendor/bin/sail bin pint --dirty --format agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification Review Checklist
|
|
||||||
|
|
||||||
1. Review baseline snapshot, evidence snapshot, tenant review, review-pack, evidence overview, and review register list surfaces against `docs/product/standards/list-surface-review-checklist.md`.
|
|
||||||
2. Confirm empty-state wording distinguishes not-created, degraded, and historical-only cases where those states can appear.
|
|
||||||
3. Confirm canonical evidence/review list filters and counts remain tenant-safe for both allowed and disallowed tenants.
|
|
||||||
|
|
||||||
## Rollout Notes
|
|
||||||
|
|
||||||
- No migration is required unless implementation reveals a minimal summary/context enrichment need.
|
|
||||||
- If enrichment is needed, keep it additive to existing `summary` or `context` payloads and limit it to provenance or usability distinctions that cannot be derived safely at render time.
|
|
||||||
- No deploy-time asset change is expected beyond the existing Filament asset pipeline.
|
|
||||||
- Historical records must remain readable even if some new truth-envelope inputs are absent.
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
# Research: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
|
||||||
|
|
||||||
## Decision 1: Implement the first slice as a shared presentation/read-model layer over existing persisted artifact fields
|
|
||||||
|
|
||||||
- **Decision**: Introduce a shared artifact-truth presenter or view-model layer that composes current persisted artifact state from existing enums, badge domains, `summary` payloads, and `OperationRun.context` rather than adding a new database table in the first slice.
|
|
||||||
- **Rationale**: Baseline snapshots already expose fidelity and gap summaries, evidence snapshots already persist completeness plus missing/stale counts, tenant reviews already persist completeness and publish blockers, and review packs already persist generation state plus source review/evidence metadata. The main problem is inconsistent operator interpretation, not missing primary persistence.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Add a new `artifact_truth_states` table: rejected because the first slice would duplicate existing truth and risk divergence.
|
|
||||||
- Hard-code per-page label logic: rejected because it would regress BADGE-001 and Spec 156.
|
|
||||||
|
|
||||||
## Decision 2: Reuse centralized badge semantics and reason translation instead of creating a parallel truth taxonomy
|
|
||||||
|
|
||||||
- **Decision**: Build the truth envelope on top of `BadgeCatalog`, `BadgeRenderer`, `OperatorOutcomeTaxonomy`, and `ReasonPresenter`, extending or remapping badge domains only where existing domains still collapse artifact meanings.
|
|
||||||
- **Rationale**: Evidence completeness and tenant review completeness already consume the taxonomy, baseline fidelity already uses a centralized badge domain, and run detail already translates blocked reasons through `ReasonPresenter`. This creates visible adoption of Specs 156 and 157 instead of inventing a competing presentation model.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Create dedicated per-artifact truth enums for every surface: rejected because it would fragment semantics and duplicate the taxonomy.
|
|
||||||
- Rely only on free-form helper text: rejected because operator consistency would remain untestable.
|
|
||||||
|
|
||||||
## Decision 3: Keep canonical workspace summaries entitlement-safe by filtering tenants before deriving labels, counts, and filter options
|
|
||||||
|
|
||||||
- **Decision**: Preserve the current query-first tenant filtering pattern on canonical workspace pages and derive truth labels, readiness counts, and filter options only from the already-authorized record set.
|
|
||||||
- **Rationale**: `EvidenceOverview` builds rows only from tenants for which the user can `evidence.view`, and `ReviewRegister` limits both the query and tenant filter options to entitled tenants via `TenantReviewRegisterService` and `CanonicalAdminTenantFilterState`. This is the correct security boundary for Spec 158's non-leakage requirements.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Precompute workspace-wide aggregate truth counts and hide unauthorized rows later: rejected because counts and filter options would become leakage-prone.
|
|
||||||
- Allow canonical pages to read all tenant rows and filter in Blade/Livewire: rejected because that would weaken 404/403 semantics and be harder to regression-test.
|
|
||||||
|
|
||||||
## Decision 4: Attach artifact-truth messaging to tenantless run detail via the existing enterprise-detail builder and related-link model
|
|
||||||
|
|
||||||
- **Decision**: Extend `OperationRunResource::enterpriseDetailPage()` and the tenantless run viewer to add an artifact-truth section for baseline capture, evidence generation, tenant review composition, and review-pack generation, reusing `OperationRunLinks` and `ReasonPresenter`.
|
|
||||||
- **Rationale**: The canonical run detail page already leads with operator-first state, exposes related links, and has operation-type-specific sections for baseline compare and baseline capture evidence. Adding artifact-produced/usable/degraded interpretation there stays aligned with the existing Ops-UX reference implementation.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Add a separate artifact-truth run detail page: rejected because it would violate the single canonical run-detail surface.
|
|
||||||
- Expose only raw `context` or failure JSON: rejected because it does not satisfy FR-158-012.
|
|
||||||
|
|
||||||
## Decision 5: Use targeted summary enrichment only where current persisted fields cannot explain truthful artifact provenance
|
|
||||||
|
|
||||||
- **Decision**: Preserve existing models as the primary source, but allow small, backward-compatible enrichment of existing `summary` or `context` payloads when current fields cannot truthfully answer whether an artifact is usable, current, or publishable.
|
|
||||||
- **Rationale**: Some families already have enough persisted state, while others, especially review packs and baseline-capture runs, may need a stable provenance or degraded-artifact explanation beyond the lifecycle enum alone. Enriching current JSON payloads is lower-risk than adding new tables or widening operation types.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Force the entire slice to use current fields only: rejected because some false-green cases would stay ambiguous.
|
|
||||||
- Redesign underlying artifact schemas first: rejected because it would turn a bounded truth-semantics slice into a domain rewrite.
|
|
||||||
|
|
||||||
## Decision 6: Review-pack truth should derive from pack lifecycle plus source review and evidence provenance, not from pack status alone
|
|
||||||
|
|
||||||
- **Decision**: Model review-pack truth as a composition of pack lifecycle (`queued`, `generating`, `ready`, `failed`, `expired`), source review readiness or publication state, anchored evidence completeness, and freshness/provenance metadata already persisted in `summary`.
|
|
||||||
- **Rationale**: `ReviewPackStatusBadge` currently treats `ready` as if it were enough, but the spec explicitly requires operators to know whether the pack is a trustworthy export of a publishable review or only a historical derivative.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Keep `ReviewPackStatus` as the sole operator state: rejected because it cannot distinguish “pack file exists” from “pack is a trustworthy stakeholder output.”
|
|
||||||
- Add a second lifecycle enum to `ReviewPack`: rejected for the first slice because provenance can be derived from existing linked models and summary payloads.
|
|
||||||
|
|
||||||
## Decision 7: Migration guidance is semantic migration, not data migration, for the first slice
|
|
||||||
|
|
||||||
- **Decision**: Treat FR-158-017 as a semantic migration of labels, helper copy, badge usage, and empty-state wording rather than a schema or record backfill migration.
|
|
||||||
- **Rationale**: The current ambiguity stems from overloaded labels such as `missing`, `partial`, `ready`, and `draft`. Existing records remain valid if the new truth layer interprets them more precisely and, where needed, supplements them with cause-specific explanation.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Backfill historical records with new truth fields: rejected because current records can be interpreted through derived envelopes.
|
|
||||||
- Leave old wording in place for backward compatibility: rejected because the feature's core value is truthful operator meaning.
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
# Feature Specification: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
|
||||||
|
|
||||||
**Feature Branch**: `158-artifact-truth-semantics`
|
|
||||||
**Created**: 2026-03-22
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Governance Artifact Truthful Outcomes & Fidelity Semantics"
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace + tenant + canonical-view
|
|
||||||
- **Primary Routes**:
|
|
||||||
- Workspace-scoped baseline snapshot list and detail surfaces
|
|
||||||
- Tenant-scoped evidence snapshot list and detail surfaces
|
|
||||||
- Workspace-scoped evidence overview surface
|
|
||||||
- Tenant-scoped tenant-review list and detail surfaces
|
|
||||||
- Workspace-scoped review register surface
|
|
||||||
- Tenant-scoped review-pack list and detail surfaces where publication or export readiness is shown
|
|
||||||
- Existing run-detail surfaces only when the run explains the truth state of a baseline, evidence, review, or review-pack artifact
|
|
||||||
- **Data Ownership**:
|
|
||||||
- Workspace-owned records affected: baseline snapshots, baseline snapshot items, workspace-scoped review register summaries, and canonical evidence overview summaries.
|
|
||||||
- Tenant-owned records affected: evidence snapshots, evidence snapshot items, tenant reviews, tenant review sections, review packs, and tenant-scoped artifact readiness summaries.
|
|
||||||
- Workspace-owned `OperationRun` records remain the canonical observability layer for baseline capture, evidence generation, tenant review composition, and review-pack generation, but this feature only changes how those runs explain artifact truth.
|
|
||||||
- This feature does not change ownership boundaries; it standardizes operator-facing truth semantics across existing artifact families.
|
|
||||||
- **RBAC**:
|
|
||||||
- Workspace membership remains mandatory for every affected surface.
|
|
||||||
- Workspace baseline surfaces continue to require the existing workspace baseline capabilities for list/detail visibility and management actions.
|
|
||||||
- Evidence surfaces continue to require `evidence.view` and `evidence.manage`.
|
|
||||||
- Tenant review surfaces continue to require `tenant_review.view` and `tenant_review.manage`.
|
|
||||||
- Review-pack surfaces continue to require `review_pack.view` and `review_pack.manage`.
|
|
||||||
- Non-members or wrong-scope users remain deny-as-not-found; in-scope members missing required capability remain forbidden.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: Canonical evidence and review register surfaces open prefiltered to the current tenant when entered from tenant context. Workspace-owned baseline snapshot surfaces remain workspace-scoped by default, but any tenant-linked drill-in or summary chip must stay limited to the current tenant context rather than broadening back to all tenants implicitly.
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Badge labels, summary rows, readiness totals, filter values, and empty-state wording on canonical evidence and review surfaces must be derived only from authorized tenant records. Shared truth labels such as `Partial`, `Stale`, `Publishable`, `Blocked`, or `No data` must not allow operators to infer unauthorized tenant state.
|
|
||||||
|
|
||||||
## 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 snapshot list and detail | Workspace governance operator | List/detail | Is this baseline artifact trustworthy enough to compare or review? | Snapshot existence, fidelity or completeness state, freshness, whether comparison use is safe, next action when degraded | Renderer limitations, raw provenance fields, low-level capture counters, internal reason fragments | artifact existence, execution outcome, fidelity or completeness, support maturity, operator actionability | TenantPilot only / simulation only | View snapshot, open related compare or run detail | Existing destructive actions unchanged |
|
|
||||||
| Evidence snapshot list/detail and evidence overview | Tenant or workspace governance operator | List/detail/overview | What evidence exists, how complete is it, and is it still current? | Snapshot status, completeness, stale or missing dimensions, whether evidence is reviewable, next action | Raw dimension payloads, internal metric keys, technical source references | artifact existence, completeness, freshness, readiness, operator actionability | TenantPilot only | View snapshot, create or refresh snapshot where allowed | Existing expiration actions unchanged |
|
|
||||||
| Tenant review list/detail and review register | Tenant or workspace review owner | List/detail/register | Is this review merely present, or is it actually ready to publish or export? | Review status, completeness, readiness blockers, publishability, anchored evidence basis summary, next action | Raw section payloads, fingerprint data, internal readiness calculations | artifact existence, completeness, publication readiness, freshness, operator actionability | TenantPilot only | View review, create next review, refresh, publish when eligible | Existing archive action remains destructive |
|
|
||||||
| Review-pack list/detail | Tenant review operator | List/detail | Is this pack a trustworthy stakeholder output or only a draft or blocked derivative? | Pack existence, generation outcome, readiness source, blocking cause, export suitability, next action | Raw generation payloads, internal export options, technical error fragments | artifact existence, execution outcome, publication readiness, freshness, operator actionability | TenantPilot only | View pack, export pack, open source review | Existing archive or destructive actions unchanged |
|
|
||||||
| Artifact-targeted run detail | Governance operator | Detail | Did the run merely finish, or did it produce a trustworthy artifact? | Primary cause, whether the intended artifact was produced, whether the artifact is usable, next action | Raw context JSON, low-level reason codes, internal counters beyond the operator summary | execution outcome, artifact existence, fidelity or completeness, operator actionability | TenantPilot only / simulation only depending on run family | View related artifact, retry where already allowed | Existing dangerous rerun or mutation actions unchanged |
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Distinguish real artifact truth from cosmetic success (Priority: P1)
|
|
||||||
|
|
||||||
As a governance operator, I want baseline, evidence, review, and review-pack surfaces to tell me whether a usable artifact truly exists, so that I do not mistake `completed` or `present` for `trustworthy and ready`.
|
|
||||||
|
|
||||||
**Why this priority**: This is the direct product-trust problem. False-green or ambiguous artifact states damage review credibility more than internal platform inconsistency that operators never see.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by preparing examples where an artifact exists but is degraded, stale, blocked by prerequisites, metadata-only, or not publishable, then verifying that the primary surface distinguishes those cases from truly usable artifacts in one inspection step.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a baseline capture run completed without producing a credible baseline artifact, **When** the operator opens the related run detail or snapshot surface, **Then** the product states that no trustworthy baseline was produced and explains the next action.
|
|
||||||
2. **Given** an evidence snapshot exists but some required dimensions are stale or missing, **When** the operator views the snapshot or overview, **Then** the surface shows the snapshot as partial or stale rather than as fully ready.
|
|
||||||
3. **Given** a tenant review exists but is not ready to publish or export, **When** the operator views the review or review register, **Then** the product distinguishes `review exists` from `review is publishable` and names the blocking reason.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Understand fidelity and readiness without decoding diagnostics (Priority: P1)
|
|
||||||
|
|
||||||
As an operator, I want artifact surfaces to separate completeness, freshness, support limitations, and publication readiness, so that I can tell what is wrong and whether action is required without reading raw payloads or internal codes.
|
|
||||||
|
|
||||||
**Why this priority**: The product already contains the truth in many places, but it reaches operators as overloaded words such as `missing`, `stale`, `unsupported`, or `partial` without saying which dimension they belong to.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by reviewing curated baseline, evidence, review, and review-pack examples containing mixed conditions and confirming that each degraded condition is shown on the correct semantic dimension with the correct next-action guidance.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an artifact is usable but stale, **When** the operator views it, **Then** freshness is shown as the primary issue and the artifact is not mislabeled as missing or broken.
|
|
||||||
2. **Given** an artifact is metadata-only, reference-only, or affected by renderer maturity limits, **When** the operator views it, **Then** those support facts are shown as diagnostics and do not replace the primary truth state.
|
|
||||||
3. **Given** an artifact is non-green but no operator action is required, **When** the operator views it, **Then** the surface explicitly says that no action is needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Reuse one truth model across the governance chain (Priority: P2)
|
|
||||||
|
|
||||||
As a product owner, I want baseline, evidence, review, and review-pack surfaces to share one truthful outcome and fidelity model, so that the governance chain reads as one coherent product rather than a set of locally invented status systems.
|
|
||||||
|
|
||||||
**Why this priority**: This turns the outcome taxonomy and reason translation foundations into visible product value. Without this adoption slice, the foundations remain theoretical.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by reviewing the bounded adoption set and confirming that equivalent states such as `partial`, `stale`, `blocked by prerequisite`, `ready`, and `not publishable` keep the same meaning across artifact families.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** two artifact families show comparable degraded states, **When** the operator moves between them, **Then** the same word keeps the same meaning and severity.
|
|
||||||
2. **Given** one artifact is present but not publishable and another is absent entirely, **When** the operator compares them, **Then** the surfaces distinguish existence from readiness rather than collapsing both into a generic warning.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A baseline snapshot exists and a related run is marked complete, but the snapshot was built from zero in-scope subjects or an unusable upstream source; the surface must say that the artifact is not trustworthy.
|
|
||||||
- An evidence snapshot is present but only contains metadata or reference placeholders for one or more dimensions; the surface must not present those dimensions as fully collected evidence.
|
|
||||||
- A tenant review is draft because required sections are incomplete, but the review itself is still useful for internal preparation; the surface must distinguish internal usability from stakeholder readiness.
|
|
||||||
- A review pack exists from a prior publishable review, but the latest review has regressed and is no longer publishable; historical pack availability must not mask current readiness regression.
|
|
||||||
- A surface must show more than one degraded dimension at once, such as `artifact exists`, `freshness stale`, and `publication blocked`; the dimensions must remain separate instead of collapsing into one badge.
|
|
||||||
- Canonical evidence or review surfaces are opened by an operator entitled to only a subset of tenants; state filters and counts must not expose foreign-tenant degraded artifacts.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call path and no new artifact-producing operation family. It standardizes the operator-facing truth contract for existing baseline capture, evidence generation, tenant review composition, and review-pack generation flows. Existing safety gates, audit logging, and tenant isolation remain mandatory. If an adopted surface relies on DB-only lifecycle changes such as publish or archive, those actions continue to require audit history and existing confirmation rules.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` families for baseline capture, evidence generation, tenant review composition, and review-pack generation. It does not change the Ops-UX three-surface contract or create a parallel progress surface. `OperationRun.status` and `OperationRun.outcome` remain service-owned. The feature changes how artifact-targeted runs communicate whether the intended artifact was produced, whether it is usable, and what the operator should do next. Any summary-count wording shown to operators must stay compatible with numeric-only summary keys while avoiding false-green interpretations.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** This feature affects workspace-admin baseline views, tenant-admin evidence and review surfaces, and workspace-scoped canonical evidence and review surfaces. Cross-plane access remains deny-as-not-found. Non-members or wrong-scope users remain `404`; in-scope users missing capability remain `403`. Artifact truth labels, readiness badges, filter values, and next-step hints must not reveal unauthorized tenant state. Existing server-side policies remain the source of truth for all view and mutation permissions.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not touch authentication handshakes.
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** This feature is a direct BADGE-001 adoption slice. Baseline fidelity, evidence completeness, review readiness, publication readiness, and artifact-targeted run truth states must consume centralized semantics rather than page-local mappings. Tests must cover new or remapped values introduced by this feature.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** Target objects are baseline snapshots, evidence snapshots, tenant reviews, review packs, and artifact-targeted runs. Operator vocabulary must preserve one meaning per term across list badges, detail headers, helper copy, notifications, and readiness messages. Implementation-first wording such as `render fallback`, `metadata projection`, `section composer`, or `fingerprint mismatch` remains diagnostics-only.
|
|
||||||
|
|
||||||
**Constitution alignment (OPSURF-001):** This feature materially refines existing operator-facing governance surfaces. Default-visible content must stay operator-first and answer: does the artifact exist, is it trustworthy, is it current, is it publishable, and what should I do next? Raw payloads, internal provenance, low-level counters, and machine-oriented reasons remain explicit secondary diagnostics.
|
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** This feature changes existing Filament resources and pages but does not add a new mutation family. The Action Surface Contract remains satisfied when affected pages keep their existing inspect affordances, preserve capability-gated mutations, and apply the new truth model only to status, helper copy, readiness messaging, and related action enablement reasons.
|
|
||||||
|
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Affected list and detail screens keep their existing layouts, but status badges, readiness chips, summary panels, and empty states must reflect the truthful artifact model. Empty states must distinguish `artifact not created yet`, `artifact exists but is not ready`, and `no action needed` scenarios instead of treating all three as one generic absence state.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-STD-001 list surfaces):** Because this feature modifies existing list surfaces for baseline snapshots, evidence snapshots, tenant reviews, review packs, and canonical review/evidence summaries, implementation and verification MUST reference `docs/product/standards/list-surface-review-checklist.md` so inspection affordances, filters, empty states, and row semantics remain compliant while truth messaging changes.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-158-001**: The system MUST define one governance-artifact truth model shared by baseline snapshots, evidence snapshots, tenant reviews, review packs, and artifact-targeted run summaries.
|
|
||||||
- **FR-158-002**: The shared model MUST separate at least execution outcome, artifact existence, fidelity or completeness, freshness, support maturity, publication readiness, and operator actionability into distinct dimensions.
|
|
||||||
- **FR-158-003**: An adopted surface MUST NOT treat `artifact exists` as equivalent to `artifact is trustworthy` or `artifact is ready`.
|
|
||||||
- **FR-158-004**: Baseline-related surfaces MUST distinguish a technically completed run from a run that produced a credible baseline artifact.
|
|
||||||
- **FR-158-005**: Baseline-related surfaces MUST treat zero-subject, unusable-upstream, blocked-upstream, or empty-snapshot outcomes as non-success artifact states unless the artifact is explicitly marked as non-usable historical trace.
|
|
||||||
- **FR-158-006**: Evidence surfaces MUST distinguish complete, partial, stale, missing-input, metadata-only, and reference-only conditions without collapsing them into one generic warning.
|
|
||||||
- **FR-158-007**: Review surfaces MUST distinguish review existence, review completeness, and review publication readiness as separate states.
|
|
||||||
- **FR-158-008**: Review-pack surfaces MUST distinguish pack existence, pack generation outcome, and whether the source review was actually publishable at the time the pack was generated.
|
|
||||||
- **FR-158-009**: Product-support or renderer-maturity facts MUST remain diagnostics-only and MUST NOT be shown as the primary warning or error state for an adopted artifact surface.
|
|
||||||
- **FR-158-010**: Every adopted non-green artifact state MUST include a cause-specific explanation and either a next action, a destination, or an explicit `No action needed` message.
|
|
||||||
- **FR-158-011**: Adopted surfaces MUST use the shared operator vocabulary established by Spec 156 and MUST consume humanized reasons from Spec 157 where cause wording is required.
|
|
||||||
- **FR-158-012**: Artifact-targeted run detail MUST state whether the intended artifact was produced, whether it is usable, and why it is degraded when the run outcome alone would otherwise read as ambiguous.
|
|
||||||
- **FR-158-013**: Canonical evidence and review overview surfaces MUST apply the same truth vocabulary as tenant-scoped detail surfaces while remaining entitlement-safe.
|
|
||||||
- **FR-158-014**: The first implementation slice MUST cover baseline snapshot list/detail, evidence snapshot list/detail, evidence overview, tenant review list/detail, review register, review-pack list/detail, and artifact-targeted run detail summaries.
|
|
||||||
- **FR-158-015**: The first implementation slice MUST explicitly exclude provider dispatch gating, restore lifecycle cleanup, and broad inventory or onboarding semantics beyond any existing artifact truth they already surface.
|
|
||||||
- **FR-158-016**: Adopted surfaces MUST preserve historical artifact intelligibility, so older evidence snapshots, reviews, and review packs remain understandable even when newer artifacts are more complete or more current.
|
|
||||||
- **FR-158-017**: The feature MUST define migration guidance for replacing overloaded labels such as `missing`, `stale`, `partial`, `unsupported`, `ready`, and `draft` with dimension-specific artifact truth messaging.
|
|
||||||
- **FR-158-018**: The feature MUST include regression coverage for false-green baseline outcomes, partial or stale evidence, non-publishable reviews, review-pack readiness provenance, and non-member-safe canonical summaries.
|
|
||||||
- **FR-158-019**: The feature MUST include at least one positive and one negative authorization test proving that truth labels, readiness counts, and filter values on canonical artifact surfaces do not leak unauthorized tenant state.
|
|
||||||
- **FR-158-020**: The feature MUST ensure that adopted empty states distinguish `not created yet`, `created but degraded`, and `historical artifact available but not current` where those distinctions matter to operator decisions.
|
|
||||||
|
|
||||||
## 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 snapshot list/detail | Existing workspace baseline snapshot resource | Existing actions unchanged | Existing clickable inspection remains primary | None added by this feature | None added by this feature | Existing CTA unchanged | Existing actions unchanged | N/A | Existing audit model unchanged | This feature changes fidelity, truth, and next-action messaging only |
|
|
||||||
| Evidence snapshot list/detail | Existing tenant evidence snapshot resource | `Create snapshot` remains existing entry point | Existing clickable inspection remains primary | Existing row actions unchanged | None added by this feature | `Create first snapshot` remains | Existing actions unchanged | N/A | Existing audit model unchanged | Status, empty-state wording, and readiness explanation change |
|
|
||||||
| Evidence overview | Existing workspace evidence overview page | `Clear filters` | Existing drill-down link remains primary | None | None | `Clear filters` when filtered | N/A | N/A | No new mutation | Canonical summary rows adopt truthful completeness and freshness semantics |
|
|
||||||
| Tenant review list/detail and review register | Existing tenant review resource and workspace review register | Existing `Create review` and `Clear filters` entry points remain | Existing clickable inspection remains primary | Existing row actions unchanged | None added by this feature | Existing single CTA pattern remains | Existing `Refresh review`, `Publish review`, `Export executive pack`, `Archive review` remain | N/A | Existing audit model unchanged | This feature clarifies existence vs readiness vs publication truth |
|
|
||||||
| Review-pack list/detail | Existing review-pack resource | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | None added by this feature | Existing CTA unchanged | Existing actions unchanged | N/A | Existing audit model unchanged | Pack truth and provenance messaging change without adding new actions |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Governance Artifact Truth State**: The operator-facing envelope that explains whether an artifact exists, whether it is usable, whether it is current, and what to do next.
|
|
||||||
- **Artifact Fidelity or Completeness State**: The dimension that explains how complete, trustworthy, or degraded the artifact content is without conflating that with execution outcome or readiness.
|
|
||||||
- **Publication Readiness State**: The dimension that explains whether a review or review-derived pack is suitable for stakeholder publication or export.
|
|
||||||
- **Artifact Support Maturity Signal**: Diagnostic-only information about renderer or interpretation capability that must not replace the primary artifact truth state.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-158-001**: In the first implementation slice, 100% of adopted artifact surfaces show artifact existence, fidelity or completeness, and readiness as separate meanings whenever more than one of those dimensions is relevant.
|
|
||||||
- **SC-158-002**: In focused regression coverage, 100% of false-green baseline examples are rendered as non-success artifact states unless explicitly marked as non-usable historical traces.
|
|
||||||
- **SC-158-003**: In focused regression coverage, 100% of adopted evidence and review examples with stale, partial, or blocked conditions include a next action or an explicit `No action needed` marker.
|
|
||||||
- **SC-158-004**: In a manual review set of 12 curated artifact cases across baseline, evidence, review, and review-pack surfaces, operators can correctly answer whether the artifact exists, whether it is trustworthy, and whether action is required in at least 11 of 12 cases from one inspection step.
|
|
||||||
- **SC-158-005**: In focused authorization regression coverage, 100% of canonical evidence and review summaries suppress unauthorized tenant labels, counts, and truth-state filter values.
|
|
||||||
- **SC-158-006**: In the first implementation slice, no adopted artifact surface uses a renderer limitation or support-tier fact as the primary warning or error signal.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Spec 156 provides the shared outcome vocabulary, and Spec 157 provides the humanized cause wording that this feature consumes.
|
|
||||||
- Existing baseline, evidence, review, and review-pack artifact models are stable enough that this slice can focus on truthful presentation rather than reworking domain ownership.
|
|
||||||
- The first rollout is intentionally bounded to governance artifacts and their immediate run summaries, not to all product status systems.
|
|
||||||
- Historical artifacts remain valuable even when newer artifacts are more complete, so the truth model must explain staleness or partiality without making historical records unintelligible.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Spec 118 - Baseline Drift Engine
|
|
||||||
- Spec 153 - Evidence Domain Foundation
|
|
||||||
- Spec 155 - Tenant Review Layer
|
|
||||||
- Spec 156 - Operator Outcome Taxonomy and Cross-Domain State Separation
|
|
||||||
- Spec 157 - Operator Reason Code Translation and Humanization Contract
|
|
||||||
- Existing baseline snapshot, evidence snapshot, tenant review, review-pack, and operation-run surfaces in the current admin panel
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Extending provider-backed action preflight or dispatch gating
|
|
||||||
- Performing a broad restore lifecycle semantics cleanup outside artifact truth that already appears on adopted governance surfaces
|
|
||||||
- Redefining every platform-wide `OperationRun` outcome
|
|
||||||
- Redesigning the visual system or component library
|
|
||||||
- Creating new artifact families or new long-running operation families
|
|
||||||
|
|
||||||
## Final Direction
|
|
||||||
|
|
||||||
This spec makes the outcome taxonomy and reason translation foundations visible in TenantPilot's most trust-sensitive product surfaces: baselines, evidence, reviews, and review packs. It focuses on the governance chain that customers actually evaluate in demos, audits, and day-to-day review work. The goal is not merely cleaner wording, but a stricter product guarantee that operators can tell whether an artifact exists, whether it is trustworthy, whether it is current, whether it is publishable, and what to do next.
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
# Tasks: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/158-artifact-truth-semantics/`
|
|
||||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/artifact-truth.openapi.yaml, quickstart.md
|
|
||||||
|
|
||||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior and operator-visible semantics across existing Laravel/Filament surfaces.
|
|
||||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Establish setup scaffolding, curated validation cases, and failing test shells for artifact-truth work.
|
|
||||||
|
|
||||||
- [X] T001 Create curated artifact-truth fixture builders and case datasets in `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php` and `tests/Feature/Monitoring/ArtifactTruthManualCasesDataset.php`
|
|
||||||
- [X] T002 [P] Create failing test shells for the shared truth layer and adopted surfaces in `tests/Unit/Badges/GovernanceArtifactTruthTest.php`, `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`, `tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php`, `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewUiContractTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
|
||||||
- [X] T003 [P] Capture the 12-case manual validation matrix and first-slice verification targets in `specs/158-artifact-truth-semantics/quickstart.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Build the shared truth model and cross-cutting helpers that every story depends on.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
|
||||||
|
|
||||||
- [X] T004 Implement the `ArtifactTruthEnvelope`, `ArtifactTruthDimension`, and `ArtifactTruthCause` read-model DTOs in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php`, `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthDimension.php`, and `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php`
|
|
||||||
- [X] T005 Implement family-specific truth resolution and next-action composition in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
|
|
||||||
- [X] T006 [P] Extend shared taxonomy and badge registration for artifact-truth axes in `app/Support/Badges/OperatorOutcomeTaxonomy.php`, `app/Support/Badges/BadgeCatalog.php`, and `app/Support/Badges/BadgeDomain.php`
|
|
||||||
- [X] T007 [P] Wire centralized reason translation into artifact-truth presentation in `app/Support/ReasonTranslation/ReasonPresenter.php` and `app/Support/ReasonTranslation/ReasonTranslator.php`
|
|
||||||
- [X] T008 [P] Add canonical artifact/run navigation helpers and a conditional summary/context enrichment seam for the truth layer in `app/Support/OperationRunLinks.php`, `app/Support/OperationCatalog.php`, `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, and `app/Models/OperationRun.php`
|
|
||||||
- [X] T009 [P] Add foundational unit coverage for truth-envelope mapping and badge remaps in `tests/Unit/Badges/GovernanceArtifactTruthTest.php`, `tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php`, and `tests/Unit/TenantReview/TenantReviewBadgeTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Shared truth foundation is ready; user story work can now proceed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Distinguish Real Artifact Truth From Cosmetic Success (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Make baseline, evidence, review, and review-pack surfaces clearly distinguish artifact existence from trustworthy usability.
|
|
||||||
|
|
||||||
**Independent Test**: Prepare degraded and healthy examples for each artifact family and verify that one inspection step answers whether a trustworthy artifact exists.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [X] T010 [P] [US1] Add false-green baseline list/detail and artifact-targeted run-detail regressions in `tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php`, `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`, `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
|
|
||||||
- [X] T011 [P] [US1] Add truthful artifact-existence feature coverage for evidence, reviews, and review packs in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [X] T012 [US1] Apply baseline artifact-existence and trust messaging to list/detail surfaces in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`
|
|
||||||
- [X] T013 [US1] Apply evidence artifact-existence and trust messaging to tenant evidence list/detail surfaces in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource/Pages/ListEvidenceSnapshots.php`, and `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
|
||||||
- [X] T014 [US1] Apply review existence-versus-publishability messaging to tenant review list/detail surfaces in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/TenantReviewResource/Pages/ListTenantReviews.php`, and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
|
||||||
- [X] T015 [US1] Apply review-pack existence-versus-trustworthy-output messaging to pack list/detail surfaces in `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
|
||||||
- [X] T016 [US1] Add artifact-produced and artifact-usable summaries to canonical run detail in `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is complete when each adopted surface clearly distinguishes “artifact exists” from “artifact is trustworthy and usable.”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Understand Fidelity And Readiness Without Decoding Diagnostics (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Separate completeness, freshness, support limitations, and publication readiness so operators understand the degraded dimension and required action.
|
|
||||||
|
|
||||||
**Independent Test**: Review mixed-condition examples and confirm that primary vs diagnostic state, blocker guidance, and “No action needed” semantics are rendered correctly.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [X] T017 [P] [US2] Add mixed-dimension and diagnostic-boundary tests in `tests/Unit/Badges/GovernanceArtifactTruthTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
|
||||||
- [X] T018 [P] [US2] Add blocker-guidance, evidence empty-state, historical-artifact, and explicit no-action-needed regressions in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [X] T019 [US2] Separate primary versus diagnostic baseline fidelity semantics in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Support/Badges/Domains/BaselineSnapshotFidelityBadge.php`, and `app/Services/Baselines/SnapshotRendering/FidelityState.php`
|
|
||||||
- [X] T020 [US2] Separate evidence completeness, freshness, metadata-only, and reference-only semantics in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Pages/Monitoring/EvidenceOverview.php`, and `app/Models/EvidenceSnapshot.php`
|
|
||||||
- [X] T021 [US2] Separate review completeness, publication readiness, and blocker guidance in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Models/TenantReview.php`, and `app/Services/TenantReviews/TenantReviewReadinessGate.php`
|
|
||||||
- [X] T022 [US2] Separate review-pack lifecycle from provenance and readiness semantics in `app/Filament/Resources/ReviewPackResource.php`, `app/Models/ReviewPack.php`, and `app/Support/Badges/Domains/ReviewPackStatusBadge.php`
|
|
||||||
- [X] T023 [US2] Add explicit next-step and “No action needed” handling to the shared truth layer and run detail in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `app/Support/ReasonTranslation/ReasonPresenter.php`, and `app/Filament/Resources/OperationRunResource.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is complete when degraded states are dimension-specific, diagnostics stay secondary, and operator follow-up guidance is explicit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Reuse One Truth Model Across The Governance Chain (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Keep equivalent artifact states semantically consistent across baseline, evidence, review, review-pack, and canonical workspace summaries.
|
|
||||||
|
|
||||||
**Independent Test**: Compare equivalent artifact conditions across the adopted surfaces and verify that labels, severity, and count/filter behavior remain consistent and tenant-safe.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [X] T024 [P] [US3] Add cross-surface vocabulary consistency coverage in `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, `tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php`, and `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
|
|
||||||
- [X] T025 [P] [US3] Add explicit positive and negative canonical authorization tests for truth labels, counts, and filter values in `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php`, and `tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [X] T026 [US3] Normalize shared artifact vocabulary and semantic labels across badge domains in `app/Support/Badges/OperatorOutcomeTaxonomy.php`, `app/Support/Badges/Domains/EvidenceCompletenessBadge.php`, `app/Support/Badges/Domains/TenantReviewCompletenessStateBadge.php`, `app/Support/Badges/Domains/TenantReviewStatusBadge.php`, and `app/Support/Badges/Domains/ReviewPackStatusBadge.php`
|
|
||||||
- [X] T027 [US3] Apply the shared truth vocabulary to canonical evidence and review summaries without leaking unauthorized tenant state in `app/Filament/Pages/Monitoring/EvidenceOverview.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Services/TenantReviews/TenantReviewRegisterService.php`, and `app/Support/Filament/CanonicalAdminTenantFilterState.php`
|
|
||||||
- [X] T028 [US3] Align related run and artifact navigation with the shared truth model in `app/Support/OperationRunLinks.php`, `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `app/Filament/Resources/ReviewPackResource.php`
|
|
||||||
- [X] T029 [US3] Apply semantic migration away from overloaded labels in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 3 is complete when equivalent artifact states read consistently across all adopted governance surfaces and canonical summaries remain entitlement-safe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Final regression coverage, guard validation, and formatting.
|
|
||||||
|
|
||||||
- [X] T030 [P] Run and stabilize focused artifact-truth regression suites in `tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php`, `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, `tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
|
|
||||||
- [X] T031 [P] Run and stabilize guard and authorization regressions in `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, and `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`
|
|
||||||
- [X] T032 Run formatting, complete the 12-case quickstart validation checklist, and review affected list surfaces against `docs/product/standards/list-surface-review-checklist.md` for `specs/158-artifact-truth-semantics/quickstart.md` and the modified PHP files under `app/` and `tests/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Phase 1: Setup**: No dependencies; start immediately.
|
|
||||||
- **Phase 2: Foundational**: Depends on Phase 1; blocks all user stories.
|
|
||||||
- **Phase 3: User Story 1**: Depends on Phase 2.
|
|
||||||
- **Phase 4: User Story 2**: Depends on Phase 2 and is best executed after User Story 1 because it refines the same surfaces and helpers.
|
|
||||||
- **Phase 5: User Story 3**: Depends on Phase 2 and is best executed after User Stories 1 and 2 because it normalizes the completed semantics across surfaces.
|
|
||||||
- **Phase 6: Polish**: Depends on all desired user stories being complete.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **US1 (P1)**: Primary MVP slice; no user-story dependency after foundational work.
|
|
||||||
- **US2 (P1)**: Logically depends on the shared truth layer and overlaps the same surfaces as US1, so sequence after US1 to reduce merge and semantic conflicts.
|
|
||||||
- **US3 (P2)**: Depends on the shared truth layer and the adopted semantics from US1 and US2 so it can normalize them across canonical and tenant surfaces.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Tests should be added first and observed failing before implementation.
|
|
||||||
- Shared presenter or badge changes come before surface adoption.
|
|
||||||
- Resource/page changes come before canonical run-detail polish.
|
|
||||||
- Canonical RBAC regression coverage must pass before the story is considered complete.
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- `T002`, `T003` can run in parallel during setup after the fixture dataset exists.
|
|
||||||
- `T006`, `T007`, `T008`, `T009` can run in parallel during foundational work once the support classes exist.
|
|
||||||
- Test tasks marked `[P]` within each story can run in parallel.
|
|
||||||
- `T012` through `T015` can be split across developers after `T005` through `T008` are complete, but `T016` should follow once family-specific truth semantics are available.
|
|
||||||
- `T030` and `T031` can run in parallel during final validation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Parallel test authoring
|
|
||||||
T010 tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
|
|
||||||
T011 tests/Feature/Evidence/EvidenceSnapshotResourceTest.php + tests/Feature/TenantReview/TenantReviewLifecycleTest.php + tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
|
||||||
|
|
||||||
# Parallel surface adoption after shared truth foundation is ready
|
|
||||||
T012 app/Filament/Resources/BaselineSnapshotResource.php
|
|
||||||
T013 app/Filament/Resources/EvidenceSnapshotResource.php
|
|
||||||
T014 app/Filament/Resources/TenantReviewResource.php
|
|
||||||
T015 app/Filament/Resources/ReviewPackResource.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel Example: User Story 2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Parallel diagnostic/readiness coverage
|
|
||||||
T017 tests/Unit/Badges/GovernanceArtifactTruthTest.php + tests/Feature/Evidence/EvidenceOverviewPageTest.php + tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
|
||||||
T018 tests/Feature/Evidence/EvidenceSnapshotResourceTest.php + tests/Feature/Evidence/EvidenceOverviewPageTest.php + tests/Feature/TenantReview/TenantReviewUiContractTest.php + tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
|
|
||||||
|
|
||||||
# Parallel semantic refinement by domain
|
|
||||||
T019 app/Support/Badges/Domains/BaselineSnapshotFidelityBadge.php
|
|
||||||
T020 app/Models/EvidenceSnapshot.php + app/Filament/Pages/Monitoring/EvidenceOverview.php
|
|
||||||
T021 app/Models/TenantReview.php + app/Services/TenantReviews/TenantReviewReadinessGate.php
|
|
||||||
T022 app/Models/ReviewPack.php + app/Support/Badges/Domains/ReviewPackStatusBadge.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel Example: User Story 3
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Parallel canonical safety and vocabulary checks
|
|
||||||
T024 tests/Feature/Evidence/EvidenceOverviewPageTest.php + tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
|
||||||
T025 tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php + tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php
|
|
||||||
|
|
||||||
# Parallel cross-surface normalization
|
|
||||||
T026 app/Support/Badges/OperatorOutcomeTaxonomy.php
|
|
||||||
T027 app/Filament/Pages/Monitoring/EvidenceOverview.php + app/Filament/Pages/Reviews/ReviewRegister.php
|
|
||||||
T028 app/Support/OperationRunLinks.php
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### MVP First (User Story 1 Only)
|
|
||||||
|
|
||||||
1. Complete Phase 1: Setup.
|
|
||||||
2. Complete Phase 2: Foundational shared truth support.
|
|
||||||
3. Complete Phase 3: User Story 1.
|
|
||||||
4. **STOP and VALIDATE**: Run the User Story 1 regression tasks and confirm that artifact existence vs trust semantics are clear on all four artifact families plus run detail.
|
|
||||||
5. Demo the bounded trust slice before refining diagnostics and cross-surface normalization.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Setup + Foundational → shared truth layer ready.
|
|
||||||
2. Add US1 → artifact trust becomes explicit across core surfaces.
|
|
||||||
3. Add US2 → fidelity, freshness, readiness, and diagnostics become dimensionally correct.
|
|
||||||
4. Add US3 → vocabulary and canonical tenant-safe summaries become consistent across the governance chain.
|
|
||||||
5. Finish with guard coverage, focused tests, and formatting.
|
|
||||||
|
|
||||||
### Parallel Team Strategy
|
|
||||||
|
|
||||||
1. One developer owns Phase 2 shared truth presenter and badge foundation.
|
|
||||||
2. After foundation is ready, split surface adoption by family:
|
|
||||||
- Developer A: baseline + run detail
|
|
||||||
- Developer B: evidence + evidence overview
|
|
||||||
- Developer C: tenant review + review register + review packs
|
|
||||||
3. Recombine in US3 for vocabulary normalization and canonical safety validation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `[P]` tasks touch separate files or can be executed independently.
|
|
||||||
- Story labels map tasks directly to spec user stories for traceability.
|
|
||||||
- This task list intentionally avoids provider dispatch gating, restore cleanup, and broad operation-status redesign.
|
|
||||||
- Existing destructive actions remain in scope only for truthful messaging, confirmation preservation, and authorization regression coverage.
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
|
||||||
use App\Support\RbacReason;
|
|
||||||
use App\Support\ReasonTranslation\ReasonTranslator;
|
|
||||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
|
||||||
|
|
||||||
it('keeps adopted operator labels free from raw internal reason codes', function (): void {
|
|
||||||
$translator = app(ReasonTranslator::class);
|
|
||||||
$reasonCodes = [
|
|
||||||
ExecutionDenialReasonCode::MissingCapability->value,
|
|
||||||
ProviderReasonCodes::ProviderConsentMissing,
|
|
||||||
TenantOperabilityReasonCode::RememberedContextStale->value,
|
|
||||||
RbacReason::ManualAssignmentRequired->value,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($reasonCodes as $reasonCode) {
|
|
||||||
$envelope = $translator->translate($reasonCode);
|
|
||||||
|
|
||||||
expect($envelope)->not->toBeNull()
|
|
||||||
->and($envelope?->operatorLabel)->not->toBe($reasonCode)
|
|
||||||
->and($envelope?->operatorLabel)->not->toContain('_');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the canonical operator vocabulary for adopted reason families', function (): void {
|
|
||||||
expect(ExecutionDenialReasonCode::MissingCapability->toReasonResolutionEnvelope()->operatorLabel)->toBe('Permission required')
|
|
||||||
->and(app(ReasonTranslator::class)->translate(ProviderReasonCodes::ProviderPermissionDenied)?->operatorLabel)->toBe('Permission denied')
|
|
||||||
->and(TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope()->guidanceText())->toBe('No action needed.')
|
|
||||||
->and(RbacReason::ManualAssignmentRequired->toReasonResolutionEnvelope()->operatorLabel)->toBe('Manual role assignment required');
|
|
||||||
});
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
|
|
||||||
it('shows translated guidance to entitled viewers on canonical operation run pages', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'type' => 'inventory_sync',
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Blocked->value,
|
|
||||||
'context' => [
|
|
||||||
'reason_code' => 'missing_capability',
|
|
||||||
'execution_legitimacy' => [
|
|
||||||
'reason_code' => 'missing_capability',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'failure_summary' => [[
|
|
||||||
'code' => 'operation.blocked',
|
|
||||||
'reason_code' => 'missing_capability',
|
|
||||||
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
|
|
||||||
]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
|
||||||
])
|
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Permission required')
|
|
||||||
->assertSee('The initiating actor no longer has the capability required for this queued run.')
|
|
||||||
->assertSee('Review workspace or tenant access before retrying.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns not found before any translated guidance can leak to non-members', function (): void {
|
|
||||||
$workspaceTenant = Tenant::factory()->create();
|
|
||||||
[$owner, $visibleTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
|
|
||||||
$hiddenTenant = Tenant::factory()->for($visibleTenant->workspace)->create();
|
|
||||||
createUserWithTenant(tenant: $hiddenTenant, user: $owner, role: 'owner');
|
|
||||||
|
|
||||||
$outsider = \App\Models\User::factory()->create();
|
|
||||||
|
|
||||||
createUserWithTenant(tenant: $visibleTenant, user: $outsider, role: 'owner');
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => (int) $hiddenTenant->getKey(),
|
|
||||||
'workspace_id' => (int) $hiddenTenant->workspace_id,
|
|
||||||
'type' => 'inventory_sync',
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Blocked->value,
|
|
||||||
'context' => [
|
|
||||||
'reason_code' => 'missing_capability',
|
|
||||||
],
|
|
||||||
'failure_summary' => [[
|
|
||||||
'code' => 'operation.blocked',
|
|
||||||
'reason_code' => 'missing_capability',
|
|
||||||
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
|
|
||||||
]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($outsider)
|
|
||||||
->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $hiddenTenant->workspace_id,
|
|
||||||
])
|
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Concerns;
|
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\ReviewPackStatus;
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
|
|
||||||
trait BuildsGovernanceArtifactTruthFixtures
|
|
||||||
{
|
|
||||||
protected function makeArtifactTruthEvidenceSnapshot(
|
|
||||||
Tenant $tenant,
|
|
||||||
array $snapshotOverrides = [],
|
|
||||||
array $summaryOverrides = [],
|
|
||||||
): EvidenceSnapshot {
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
|
||||||
'summary' => array_replace_recursive([
|
|
||||||
'dimension_count' => 5,
|
|
||||||
'finding_count' => 2,
|
|
||||||
'report_count' => 2,
|
|
||||||
'operation_count' => 2,
|
|
||||||
'missing_dimensions' => 0,
|
|
||||||
'stale_dimensions' => 0,
|
|
||||||
], $summaryOverrides),
|
|
||||||
'fingerprint' => hash('sha256', 'evidence-'.$tenant->getKey().'-'.microtime()),
|
|
||||||
'generated_at' => now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return EvidenceSnapshot::query()->create(array_replace($defaults, $snapshotOverrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function makeArtifactTruthReview(
|
|
||||||
Tenant $tenant,
|
|
||||||
User $user,
|
|
||||||
?EvidenceSnapshot $snapshot = null,
|
|
||||||
array $reviewOverrides = [],
|
|
||||||
array $summaryOverrides = [],
|
|
||||||
): TenantReview {
|
|
||||||
$snapshot ??= $this->makeArtifactTruthEvidenceSnapshot($tenant);
|
|
||||||
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'status' => TenantReviewStatus::Ready->value,
|
|
||||||
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
|
||||||
'summary' => array_replace_recursive([
|
|
||||||
'publish_blockers' => [],
|
|
||||||
'section_state_counts' => [
|
|
||||||
'complete' => 6,
|
|
||||||
'partial' => 0,
|
|
||||||
'missing' => 0,
|
|
||||||
'stale' => 0,
|
|
||||||
],
|
|
||||||
'finding_count' => 2,
|
|
||||||
'report_count' => 2,
|
|
||||||
'operation_count' => 2,
|
|
||||||
'section_count' => 6,
|
|
||||||
], $summaryOverrides),
|
|
||||||
'fingerprint' => hash('sha256', 'review-'.$tenant->getKey().'-'.microtime()),
|
|
||||||
'generated_at' => now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return TenantReview::query()->create(array_replace($defaults, $reviewOverrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function makeArtifactTruthReviewPack(
|
|
||||||
Tenant $tenant,
|
|
||||||
User $user,
|
|
||||||
?EvidenceSnapshot $snapshot = null,
|
|
||||||
?TenantReview $review = null,
|
|
||||||
array $packOverrides = [],
|
|
||||||
array $summaryOverrides = [],
|
|
||||||
): ReviewPack {
|
|
||||||
$snapshot ??= $this->makeArtifactTruthEvidenceSnapshot($tenant);
|
|
||||||
$review ??= $this->makeArtifactTruthReview($tenant, $user, $snapshot);
|
|
||||||
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'status' => ReviewPackStatus::Ready->value,
|
|
||||||
'summary' => array_replace_recursive([
|
|
||||||
'review_status' => $review->status,
|
|
||||||
'review_completeness_state' => $review->completeness_state,
|
|
||||||
'finding_count' => 2,
|
|
||||||
'report_count' => 2,
|
|
||||||
'operation_count' => 2,
|
|
||||||
'evidence_resolution' => [
|
|
||||||
'outcome' => 'resolved',
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
],
|
|
||||||
], $summaryOverrides),
|
|
||||||
'fingerprint' => hash('sha256', 'pack-'.$tenant->getKey().'-'.microtime()),
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
'file_path' => 'review-packs/'.microtime(true).'.zip',
|
|
||||||
'sha256' => hash('sha256', 'pack-file-'.$tenant->getKey().'-'.microtime()),
|
|
||||||
'file_size' => 1024,
|
|
||||||
'generated_at' => now(),
|
|
||||||
'expires_at' => now()->addDays(30),
|
|
||||||
];
|
|
||||||
|
|
||||||
return ReviewPack::query()->create(array_replace($defaults, $packOverrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function makeArtifactTruthRun(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $type,
|
|
||||||
array $context = [],
|
|
||||||
array $attributes = [],
|
|
||||||
): OperationRun {
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'type' => $type,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'initiator_name' => 'Artifact Truth Test',
|
|
||||||
'context' => $context,
|
|
||||||
'summary_counts' => [],
|
|
||||||
'failure_summary' => [],
|
|
||||||
'started_at' => now()->subMinute(),
|
|
||||||
'completed_at' => now(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return OperationRun::factory()->create(array_replace($defaults, $attributes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -45,7 +45,6 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
->get(route('admin.evidence.overview'))
|
->get(route('admin.evidence.overview'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Artifact truth')
|
|
||||||
->assertSee($tenantA->name)
|
->assertSee($tenantA->name)
|
||||||
->assertSee($tenantB->name)
|
->assertSee($tenantB->name)
|
||||||
->assertDontSee($foreignWorkspaceTenant->name);
|
->assertDontSee($foreignWorkspaceTenant->name);
|
||||||
@ -69,10 +68,8 @@
|
|||||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||||
|
|
||||||
$snapshots = [];
|
|
||||||
|
|
||||||
foreach ([[$tenantA, EvidenceCompletenessState::Complete->value], [$tenantB, EvidenceCompletenessState::Partial->value]] as [$tenant, $state]) {
|
foreach ([[$tenantA, EvidenceCompletenessState::Complete->value], [$tenantB, EvidenceCompletenessState::Partial->value]] as [$tenant, $state]) {
|
||||||
$snapshots[(int) $tenant->getKey()] = EvidenceSnapshot::query()->create([
|
EvidenceSnapshot::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
@ -88,6 +85,6 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
->get(route('admin.evidence.overview', ['tenant_id' => (int) $tenantB->getKey()]))
|
->get(route('admin.evidence.overview', ['tenant_id' => (int) $tenantB->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantB->getKey()]], tenant: $tenantB), false)
|
->assertSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantB))
|
||||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA), false);
|
->assertDontSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantA));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -147,31 +147,6 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
->assertActionVisible('expire_snapshot');
|
->assertActionVisible('expire_snapshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
|
|
||||||
$snapshot = EvidenceSnapshot::query()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'completeness_state' => EvidenceCompletenessState::Partial->value,
|
|
||||||
'summary' => [
|
|
||||||
'dimension_count' => 5,
|
|
||||||
'missing_dimensions' => 2,
|
|
||||||
'stale_dimensions' => 0,
|
|
||||||
],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Artifact truth')
|
|
||||||
->assertSee('Partial')
|
|
||||||
->assertSee('Refresh evidence before using this snapshot');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
@ -48,8 +48,7 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured policy types', 'Technical detail'])
|
->assertSeeInOrder(['Coverage summary', 'Captured policy types', 'Technical detail'])
|
||||||
->assertSee('Reference only')
|
|
||||||
->assertSee('Inventory metadata')
|
->assertSee('Inventory metadata')
|
||||||
->assertSee('Metadata-only evidence was captured for this item.')
|
->assertSee('Metadata-only evidence was captured for this item.')
|
||||||
->assertSee('Only inventory metadata was available.');
|
->assertSee('Only inventory metadata was available.');
|
||||||
|
|||||||
@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
Gate::define(Capabilities::EVIDENCE_VIEW, fn (User $actor, Tenant $tenant): bool => (int) $tenant->getKey() === (int) $tenantA->getKey());
|
Gate::define(Capabilities::EVIDENCE_VIEW, fn (User $actor, Tenant $tenant): bool => (int) $tenant->getKey() === (int) $tenantA->getKey());
|
||||||
|
|
||||||
$allowedSnapshot = EvidenceSnapshot::query()->create([
|
EvidenceSnapshot::query()->create([
|
||||||
'tenant_id' => (int) $tenantA->getKey(),
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
@ -91,7 +91,7 @@
|
|||||||
'generated_at' => now(),
|
'generated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$deniedSnapshot = EvidenceSnapshot::query()->create([
|
EvidenceSnapshot::query()->create([
|
||||||
'tenant_id' => (int) $tenantDenied->getKey(),
|
'tenant_id' => (int) $tenantDenied->getKey(),
|
||||||
'workspace_id' => (int) $tenantDenied->workspace_id,
|
'workspace_id' => (int) $tenantDenied->workspace_id,
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
@ -104,6 +104,6 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
->get(route('admin.evidence.overview'))
|
->get(route('admin.evidence.overview'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA))
|
->assertSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantA))
|
||||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied));
|
->assertDontSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantDenied));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
|
|
||||||
dataset('artifactTruthManualCases', [
|
|
||||||
'healthy baseline artifact' => [BadgeDomain::GovernanceArtifactContent, 'trusted'],
|
|
||||||
'false-green baseline artifact' => [BadgeDomain::GovernanceArtifactExistence, 'created_but_not_usable'],
|
|
||||||
'historical baseline trace' => [BadgeDomain::GovernanceArtifactExistence, 'historical_only'],
|
|
||||||
'healthy evidence snapshot' => [BadgeDomain::GovernanceArtifactContent, 'trusted'],
|
|
||||||
'partial evidence snapshot' => [BadgeDomain::GovernanceArtifactContent, 'partial'],
|
|
||||||
'stale evidence snapshot' => [BadgeDomain::GovernanceArtifactFreshness, 'stale'],
|
|
||||||
'internal tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'internal_only'],
|
|
||||||
'blocked tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked'],
|
|
||||||
'publishable tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'publishable'],
|
|
||||||
'historical review pack' => [BadgeDomain::GovernanceArtifactExistence, 'historical_only'],
|
|
||||||
'blocked review pack' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked'],
|
|
||||||
'artifact requires action' => [BadgeDomain::GovernanceArtifactActionability, 'required'],
|
|
||||||
]);
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
|
||||||
|
|
||||||
it('shows artifact truth for artifact-targeted runs when the produced evidence snapshot is degraded', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
|
|
||||||
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
|
|
||||||
|
|
||||||
$this->makeArtifactTruthEvidenceSnapshot(
|
|
||||||
tenant: $tenant,
|
|
||||||
snapshotOverrides: [
|
|
||||||
'operation_run_id' => (int) $run->getKey(),
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'completeness_state' => EvidenceCompletenessState::Partial->value,
|
|
||||||
],
|
|
||||||
summaryOverrides: [
|
|
||||||
'missing_dimensions' => 2,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
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('Partial')
|
|
||||||
->assertSee('Refresh evidence before using this snapshot');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows missing-artifact guidance when a blocked artifact run never produced a record', 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,
|
|
||||||
],
|
|
||||||
attributes: [
|
|
||||||
'outcome' => 'blocked',
|
|
||||||
'failure_summary' => [
|
|
||||||
['reason_code' => 'review_missing_sections', 'message' => 'The review basis is incomplete.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
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('Artifact not usable')
|
|
||||||
->assertSee('Inspect the blocked run details before retrying');
|
|
||||||
});
|
|
||||||
@ -42,8 +42,6 @@
|
|||||||
expect($finalized->status)->toBe(OperationRunStatus::Completed->value)
|
expect($finalized->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
->and($finalized->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
->and($finalized->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||||
->and($finalized->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
|
->and($finalized->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderCredentialMissing)
|
||||||
->and(data_get($finalized->context, 'reason_translation.operator_label'))->toBe('Credentials missing')
|
|
||||||
->and(data_get($finalized->context, 'reason_translation.short_explanation'))->toContain('credentials required to authenticate')
|
|
||||||
->and($finalized->context['next_steps'] ?? [])->toBe([
|
->and($finalized->context['next_steps'] ?? [])->toBe([
|
||||||
['label' => 'Update Credentials', 'url' => '/admin/tenants/demo/provider-connections'],
|
['label' => 'Update Credentials', 'url' => '/admin/tenants/demo/provider-connections'],
|
||||||
])
|
])
|
||||||
|
|||||||
@ -163,11 +163,6 @@
|
|||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
expect($notification)->not->toBeNull()
|
||||||
->and(data_get($notification?->data, 'reason_translation.operator_label'))->toBe('Execution prerequisite changed')
|
|
||||||
->and(data_get($notification?->data, 'diagnostic_reason_code'))->toBe('execution_prerequisite_invalid')
|
|
||||||
->and($notification->data['body'] ?? null)->toContain('Execution prerequisite changed')
|
|
||||||
->and($notification->data['body'] ?? null)->toContain('queued execution prerequisites are no longer satisfied')
|
|
||||||
->and($notification->data['body'] ?? null)->not->toContain('execution_prerequisite_invalid')
|
|
||||||
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run));
|
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -53,8 +53,8 @@
|
|||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
expect($notification)->not->toBeNull()
|
||||||
->and($notification->data['body'] ?? null)->toContain('Permission required')
|
->and($notification->data['body'] ?? null)->toContain('Blocked by prerequisite.')
|
||||||
->and($notification->data['body'] ?? null)->toContain('capability required for this queued run')
|
->and($notification->data['body'] ?? null)->toContain('required capability')
|
||||||
->and($notification->data['body'] ?? null)->toContain('Review workspace or tenant access before retrying.')
|
->and($notification->data['body'] ?? null)->toContain('Review the blocked prerequisite before retrying.')
|
||||||
->and($notification->data['body'] ?? null)->toContain('Total: 2');
|
->and($notification->data['body'] ?? null)->toContain('Total: 2');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -207,10 +207,9 @@
|
|||||||
->assertSee('Blocked reason')
|
->assertSee('Blocked reason')
|
||||||
->assertSee('Blocked detail')
|
->assertSee('Blocked detail')
|
||||||
->assertSee('Execution legitimacy revalidation')
|
->assertSee('Execution legitimacy revalidation')
|
||||||
->assertSee('Permission required')
|
|
||||||
->assertSee('missing_capability')
|
->assertSee('missing_capability')
|
||||||
->assertSee('capability required for this queued run')
|
->assertSee('required capability')
|
||||||
->assertSee('Review workspace or tenant access before retrying.');
|
->assertSee('Review the blocked prerequisite before retrying.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {
|
it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {
|
||||||
|
|||||||
@ -313,6 +313,5 @@ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array
|
|||||||
$result = app(RbacHealthService::class)->check($tenant);
|
$result = app(RbacHealthService::class)->check($tenant);
|
||||||
|
|
||||||
expect($result['status'])->toBe('missing')
|
expect($result['status'])->toBe('missing')
|
||||||
->and($result['reason'])->toBe(RbacReason::AssignmentMissing->value)
|
->and($result['reason'])->toBe(RbacReason::AssignmentMissing->value);
|
||||||
->and(data_get($result, 'reason_translation.operator_label'))->toBe('RBAC assignment missing');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -50,9 +50,7 @@
|
|||||||
expect($notifications)->not->toBeEmpty();
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
|
||||||
$last = $notifications->last();
|
$last = $notifications->last();
|
||||||
expect((string) ($last['body'] ?? ''))->toContain('Dedicated credentials required')
|
expect((string) ($last['body'] ?? ''))->toContain(ProviderReasonCodes::DedicatedCredentialMissing);
|
||||||
->and((string) ($last['body'] ?? ''))->toContain('dedicated credentials are configured')
|
|
||||||
->and((string) ($last['body'] ?? ''))->not->toContain(ProviderReasonCodes::DedicatedCredentialMissing);
|
|
||||||
|
|
||||||
$labels = collect($last['actions'] ?? [])->pluck('label')->values()->all();
|
$labels = collect($last['actions'] ?? [])->pluck('label')->values()->all();
|
||||||
expect($labels)->toContain('Manage Provider Connections');
|
expect($labels)->toContain('Manage Provider Connections');
|
||||||
@ -90,9 +88,7 @@
|
|||||||
|
|
||||||
$last = $notifications->last();
|
$last = $notifications->last();
|
||||||
expect((string) ($last['title'] ?? ''))->toContain('Verification blocked');
|
expect((string) ($last['title'] ?? ''))->toContain('Verification blocked');
|
||||||
expect((string) ($last['body'] ?? ''))->toContain('Provider connection required')
|
expect((string) ($last['body'] ?? ''))->toContain(ProviderReasonCodes::ProviderConnectionMissing);
|
||||||
->and((string) ($last['body'] ?? ''))->toContain('usable provider connection')
|
|
||||||
->and((string) ($last['body'] ?? ''))->not->toContain(ProviderReasonCodes::ProviderConnectionMissing);
|
|
||||||
|
|
||||||
$labels = collect($last['actions'] ?? [])->pluck('label')->values()->all();
|
$labels = collect($last['actions'] ?? [])->pluck('label')->values()->all();
|
||||||
expect($labels)->toContain('Manage Provider Connections');
|
expect($labels)->toContain('Manage Provider Connections');
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
it('renders humanized RBAC reasons while keeping the diagnostic code in tenant governance details', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'rbac_status' => 'manual_assignment_required',
|
|
||||||
'rbac_status_reason' => 'manual_assignment_required',
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(route('filament.admin.resources.tenants.view', array_merge(
|
|
||||||
filamentTenantRouteParams($tenant),
|
|
||||||
['record' => $tenant]
|
|
||||||
)))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Manual role assignment required')
|
|
||||||
->assertSee('This tenant requires a manual Intune RBAC role assignment outside the automated API path.')
|
|
||||||
->assertSee('manual_assignment_required');
|
|
||||||
});
|
|
||||||
@ -323,49 +323,10 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Artifact truth')
|
|
||||||
->assertSee('Publishable')
|
|
||||||
->assertSee('#'.$snapshot->getKey())
|
->assertSee('#'.$snapshot->getKey())
|
||||||
->assertSee('resolved');
|
->assertSee('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows blocked publication truth when the source review is no longer publishable', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
$snapshot = seedReviewPackEvidence($tenant);
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
|
|
||||||
$review->update([
|
|
||||||
'status' => 'draft',
|
|
||||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
|
||||||
'publish_blockers' => ['Review the missing approval note before publication.'],
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$pack = ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'summary' => [
|
|
||||||
'review_status' => 'draft',
|
|
||||||
'review_completeness_state' => 'complete',
|
|
||||||
'evidence_resolution' => [
|
|
||||||
'outcome' => 'resolved',
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Blocked')
|
|
||||||
->assertSee('Open the source review before sharing this pack');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows download header action on view page for a ready pack', function (): void {
|
it('shows download header action on view page for a ready pack', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
@ -59,9 +59,6 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Artifact truth')
|
|
||||||
->assertSee('Publishable')
|
|
||||||
->assertSee('No action needed')
|
|
||||||
->assertSee('#'.$review->getKey())
|
->assertSee('#'.$review->getKey())
|
||||||
->assertSee('Review status');
|
->assertSee('Review status');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Services\TenantReviews\TenantReviewReadinessGate;
|
use App\Services\TenantReviews\TenantReviewReadinessGate;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
|
|
||||||
it('blocks publication when required review sections are missing from the anchored evidence basis', function (): void {
|
it('blocks publication when required review sections are missing from the anchored evidence basis', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -22,13 +21,6 @@
|
|||||||
expect(app(TenantReviewReadinessGate::class)->canPublish($review))->toBeFalse()
|
expect(app(TenantReviewReadinessGate::class)->canPublish($review))->toBeFalse()
|
||||||
->and($review->publishBlockers())->not->toBeEmpty();
|
->and($review->publishBlockers())->not->toBeEmpty();
|
||||||
|
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
|
|
||||||
|
|
||||||
expect($truth->artifactExistence)->toBe('created')
|
|
||||||
->and($truth->publicationReadiness)->toBe('blocked')
|
|
||||||
->and($truth->primaryLabel)->toBe('Blocked')
|
|
||||||
->and($truth->nextStepText())->toBe('Resolve the review blockers before publication');
|
|
||||||
|
|
||||||
expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user))
|
expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user))
|
||||||
->toThrow(\InvalidArgumentException::class);
|
->toThrow(\InvalidArgumentException::class);
|
||||||
});
|
});
|
||||||
@ -44,17 +36,9 @@
|
|||||||
->and($published->published_by_user_id)->toBe((int) $user->getKey())
|
->and($published->published_by_user_id)->toBe((int) $user->getKey())
|
||||||
->and($publishedAt)->not->toBeNull();
|
->and($publishedAt)->not->toBeNull();
|
||||||
|
|
||||||
$publishedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($published);
|
|
||||||
|
|
||||||
$archived = app(TenantReviewLifecycleService::class)->archive($published, $user);
|
$archived = app(TenantReviewLifecycleService::class)->archive($published, $user);
|
||||||
$archivedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($archived);
|
|
||||||
|
|
||||||
expect($archived->status)->toBe(TenantReviewStatus::Archived->value)
|
expect($archived->status)->toBe(TenantReviewStatus::Archived->value)
|
||||||
->and($archived->archived_at)->not->toBeNull()
|
->and($archived->archived_at)->not->toBeNull()
|
||||||
->and($archived->published_at?->toIso8601String())->toBe($publishedAt)
|
->and($archived->published_at?->toIso8601String())->toBe($publishedAt);
|
||||||
->and($publishedTruth->publicationReadiness)->toBe('publishable')
|
|
||||||
->and($publishedTruth->nextStepText())->toBe('No action needed')
|
|
||||||
->and($archivedTruth->artifactExistence)->toBe('historical_only')
|
|
||||||
->and($archivedTruth->publicationReadiness)->toBe('internal_only')
|
|
||||||
->and($archivedTruth->nextStepText())->toBe('No action needed');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,18 +18,7 @@
|
|||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||||
|
|
||||||
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||||
$reviewB = composeTenantReviewForTest(
|
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||||
$tenantB,
|
|
||||||
$user,
|
|
||||||
seedTenantReviewEvidence(
|
|
||||||
tenant: $tenantB,
|
|
||||||
permissionPayload: [
|
|
||||||
'required_count' => 11,
|
|
||||||
'granted_count' => 7,
|
|
||||||
],
|
|
||||||
operationRunCount: 0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
setAdminPanelContext();
|
setAdminPanelContext();
|
||||||
@ -42,10 +31,7 @@
|
|||||||
->test(ReviewRegister::class)
|
->test(ReviewRegister::class)
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||||
->assertCanSeeTableRecords([$reviewB])
|
->assertCanSeeTableRecords([$reviewB])
|
||||||
->assertCanNotSeeTableRecords([$reviewA])
|
->assertCanNotSeeTableRecords([$reviewA]);
|
||||||
->assertSee('Blocked')
|
|
||||||
->assertSee('Resolve the review blockers before publication')
|
|
||||||
->assertDontSee('Publishable');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefilters the review register from a tenant query parameter and accepts external tenant identifiers', function (): void {
|
it('prefilters the review register from a tenant query parameter and accepts external tenant identifiers', function (): void {
|
||||||
@ -58,18 +44,7 @@
|
|||||||
]);
|
]);
|
||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||||
|
|
||||||
$reviewA = composeTenantReviewForTest(
|
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||||
$tenantA,
|
|
||||||
$user,
|
|
||||||
seedTenantReviewEvidence(
|
|
||||||
tenant: $tenantA,
|
|
||||||
permissionPayload: [
|
|
||||||
'required_count' => 11,
|
|
||||||
'granted_count' => 7,
|
|
||||||
],
|
|
||||||
operationRunCount: 0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -80,10 +55,7 @@
|
|||||||
->test(ReviewRegister::class)
|
->test(ReviewRegister::class)
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||||
->assertCanSeeTableRecords([$reviewA])
|
->assertCanSeeTableRecords([$reviewA])
|
||||||
->assertCanNotSeeTableRecords([$reviewB])
|
->assertCanNotSeeTableRecords([$reviewB]);
|
||||||
->assertSee('Blocked')
|
|
||||||
->assertSee('Resolve the review blockers before publication')
|
|
||||||
->assertDontSee('Publishable');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scopes canonical tenant filter options to entitled tenants only', function (): void {
|
it('scopes canonical tenant filter options to entitled tenants only', function (): void {
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('returns 404 for users outside the active workspace on the canonical review register', function (): void {
|
it('returns 404 for users outside the active workspace on the canonical review register', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
@ -46,40 +45,3 @@
|
|||||||
->get(ReviewRegister::getUrl(panel: 'admin'))
|
->get(ReviewRegister::getUrl(panel: 'admin'))
|
||||||
->assertOk();
|
->assertOk();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows artifact-truth rows only for entitled tenants on the canonical review register', function (): void {
|
|
||||||
$tenantAllowed = Tenant::factory()->create(['name' => 'Allowed Tenant']);
|
|
||||||
[$user, $tenantAllowed] = createUserWithTenant(tenant: $tenantAllowed, role: 'readonly');
|
|
||||||
|
|
||||||
$allowedReview = composeTenantReviewForTest(
|
|
||||||
$tenantAllowed,
|
|
||||||
$user,
|
|
||||||
seedTenantReviewEvidence(
|
|
||||||
tenant: $tenantAllowed,
|
|
||||||
permissionPayload: [
|
|
||||||
'required_count' => 11,
|
|
||||||
'granted_count' => 7,
|
|
||||||
],
|
|
||||||
operationRunCount: 0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$tenantDenied = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantAllowed->workspace_id,
|
|
||||||
'name' => 'Denied Tenant',
|
|
||||||
]);
|
|
||||||
[$otherOwner, $tenantDenied] = createUserWithTenant(tenant: $tenantDenied, role: 'owner');
|
|
||||||
$deniedReview = composeTenantReviewForTest($tenantDenied, $otherOwner);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantAllowed->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(ReviewRegister::class)
|
|
||||||
->assertCanSeeTableRecords([$allowedReview])
|
|
||||||
->assertCanNotSeeTableRecords([$deniedReview])
|
|
||||||
->assertSee('Blocked')
|
|
||||||
->assertSee('Resolve the review blockers before publication')
|
|
||||||
->assertDontSee('Denied Tenant');
|
|
||||||
});
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user