Compare commits
2 Commits
dev
...
157-reason
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cffee3c5a | ||
|
|
53c4543cbd |
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -97,6 +97,7 @@ ## 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -116,8 +117,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 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`
|
- 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 -->
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
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;
|
||||||
@ -169,21 +170,16 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||||
$reasonCode = data_get($context, 'reason_code');
|
$lines = $reasonEnvelope?->toBodyLines() ?? [
|
||||||
|
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
||||||
$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' => sprintf('%s Reason code: %s. %s', $message, $reasonCode, $guidance),
|
'body' => implode(' ', $lines),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2882,9 +2882,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
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;
|
||||||
@ -469,8 +470,13 @@ private static function blockedExecutionReasonCode(OperationRun $record): ?strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||||
|
|
||||||
|
if ($reasonEnvelope !== null) {
|
||||||
|
return $reasonEnvelope->operatorLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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');
|
||||||
@ -484,6 +490,12 @@ 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,9 +824,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -921,9 +924,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -1015,9 +1021,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
|
|||||||
@ -278,9 +278,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -647,9 +650,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -758,9 +764,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
|
|||||||
@ -608,9 +608,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
@ -908,8 +911,20 @@ 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('Reason'),
|
->label('Diagnostic code')
|
||||||
|
->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,9 +178,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -134,9 +134,12 @@ 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("Blocked by provider configuration ({$reasonCode}).")
|
->body(implode("\n", $bodyLines))
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
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;
|
||||||
@ -44,6 +45,14 @@ public function toDatabase(object $notifiable): array
|
|||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $notification->getDatabaseMessage();
|
$message = $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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,12 @@ public function __construct(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{status:string,reason:?string,used_artifacts:bool}
|
* @return array{
|
||||||
|
* 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
|
||||||
{
|
{
|
||||||
@ -105,10 +110,19 @@ public function check(Tenant $tenant): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{status:string,reason:?string,used_artifacts:bool}
|
* @return array{
|
||||||
|
* 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,
|
||||||
@ -119,6 +133,7 @@ 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,6 +22,12 @@
|
|||||||
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;
|
||||||
@ -34,6 +40,7 @@ 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
|
||||||
@ -487,6 +494,16 @@ 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();
|
||||||
}
|
}
|
||||||
@ -721,6 +738,13 @@ 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([
|
||||||
@ -943,6 +967,76 @@ 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,6 +8,7 @@
|
|||||||
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;
|
||||||
@ -217,6 +218,11 @@ 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>
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -43,4 +46,85 @@ 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,6 +7,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -95,8 +96,14 @@ 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.',
|
||||||
@ -117,6 +124,12 @@ 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);
|
||||||
@ -128,6 +141,7 @@ 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' => [
|
||||||
@ -142,12 +156,12 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
],
|
],
|
||||||
'blocked' => [
|
'blocked' => [
|
||||||
'titleSuffix' => 'blocked by prerequisite',
|
'titleSuffix' => 'blocked by prerequisite',
|
||||||
'body' => 'Blocked by prerequisite.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
||||||
'status' => 'warning',
|
'status' => 'warning',
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
'titleSuffix' => 'execution failed',
|
'titleSuffix' => 'execution failed',
|
||||||
'body' => 'Execution failed.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
||||||
'status' => 'danger',
|
'status' => 'danger',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@ -204,4 +218,9 @@ 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,16 +38,12 @@ 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 (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,11 +81,11 @@ public static function normalizeReasonCode(string $candidate): string
|
|||||||
default => $candidate,
|
default => $candidate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
|
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heuristic normalization for ad-hoc codes used across jobs/services.
|
// Heuristic normalization for ad-hoc inputs is bounded fallback behavior only.
|
||||||
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
||||||
return ProviderReasonCodes::RateLimited;
|
return ProviderReasonCodes::RateLimited;
|
||||||
}
|
}
|
||||||
@ -121,6 +117,22 @@ 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,6 +4,8 @@
|
|||||||
|
|
||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
|
use App\Support\ReasonTranslation\ReasonTranslator;
|
||||||
|
|
||||||
final class SummaryCountsNormalizer
|
final class SummaryCountsNormalizer
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@ -84,6 +86,22 @@ 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,91 +2,23 @@
|
|||||||
|
|
||||||
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\Links\RequiredPermissionsLinks;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
return match ($reasonCode) {
|
$envelope = $this->reasonPresenter->forProviderReason($tenant, $reasonCode, $connection, 'helper_copy');
|
||||||
ProviderReasonCodes::ProviderConnectionMissing,
|
|
||||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
return $envelope?->toLegacyNextSteps() ?? [];
|
||||||
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'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
364
app/Support/Providers/ProviderReasonTranslator.php
Normal file
364
app/Support/Providers/ProviderReasonTranslator.php
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
<?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,6 +2,9 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -14,4 +17,81 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
112
app/Support/ReasonTranslation/FallbackReasonTranslator.php
Normal file
112
app/Support/ReasonTranslation/FallbackReasonTranslator.php
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<?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.')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/Support/ReasonTranslation/NextStepOption.php
Normal file
153
app/Support/ReasonTranslation/NextStepOption.php
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
164
app/Support/ReasonTranslation/ReasonPresenter.php
Normal file
164
app/Support/ReasonTranslation/ReasonPresenter.php
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?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 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
199
app/Support/ReasonTranslation/ReasonResolutionEnvelope.php
Normal file
199
app/Support/ReasonTranslation/ReasonResolutionEnvelope.php
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<?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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Support/ReasonTranslation/ReasonTranslator.php
Normal file
74
app/Support/ReasonTranslation/ReasonTranslator.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?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 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 === 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,6 +4,9 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -16,4 +19,89 @@ 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
specs/157-reason-code-translation/checklists/requirements.md
Normal file
34
specs/157-reason-code-translation/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# 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.
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
# 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.
|
||||||
@ -0,0 +1,201 @@
|
|||||||
|
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
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
153
specs/157-reason-code-translation/data-model.md
Normal file
153
specs/157-reason-code-translation/data-model.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
# 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.
|
||||||
174
specs/157-reason-code-translation/plan.md
Normal file
174
specs/157-reason-code-translation/plan.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# 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 |
|
||||||
110
specs/157-reason-code-translation/quickstart.md
Normal file
110
specs/157-reason-code-translation/quickstart.md
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# 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.
|
||||||
100
specs/157-reason-code-translation/research.md
Normal file
100
specs/157-reason-code-translation/research.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# 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.
|
||||||
190
specs/157-reason-code-translation/spec.md
Normal file
190
specs/157-reason-code-translation/spec.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
# 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.
|
||||||
208
specs/157-reason-code-translation/tasks.md
Normal file
208
specs/157-reason-code-translation/tasks.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# 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.
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
@ -42,6 +42,8 @@
|
|||||||
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,6 +163,11 @@
|
|||||||
$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('Blocked by prerequisite.')
|
->and($notification->data['body'] ?? null)->toContain('Permission required')
|
||||||
->and($notification->data['body'] ?? null)->toContain('required capability')
|
->and($notification->data['body'] ?? null)->toContain('capability required for this queued run')
|
||||||
->and($notification->data['body'] ?? null)->toContain('Review the blocked prerequisite before retrying.')
|
->and($notification->data['body'] ?? null)->toContain('Review workspace or tenant access before retrying.')
|
||||||
->and($notification->data['body'] ?? null)->toContain('Total: 2');
|
->and($notification->data['body'] ?? null)->toContain('Total: 2');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -207,9 +207,10 @@
|
|||||||
->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('required capability')
|
->assertSee('capability required for this queued run')
|
||||||
->assertSee('Review the blocked prerequisite before retrying.');
|
->assertSee('Review workspace or tenant access 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,5 +313,6 @@ 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,7 +50,9 @@
|
|||||||
expect($notifications)->not->toBeEmpty();
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
|
||||||
$last = $notifications->last();
|
$last = $notifications->last();
|
||||||
expect((string) ($last['body'] ?? ''))->toContain(ProviderReasonCodes::DedicatedCredentialMissing);
|
expect((string) ($last['body'] ?? ''))->toContain('Dedicated credentials required')
|
||||||
|
->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');
|
||||||
@ -88,7 +90,9 @@
|
|||||||
|
|
||||||
$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(ProviderReasonCodes::ProviderConnectionMissing);
|
expect((string) ($last['body'] ?? ''))->toContain('Provider connection required')
|
||||||
|
->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');
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
@ -57,6 +57,9 @@
|
|||||||
pest()->extend(Tests\TestCase::class)
|
pest()->extend(Tests\TestCase::class)
|
||||||
->in('Unit');
|
->in('Unit');
|
||||||
|
|
||||||
|
pest()->extend(Tests\TestCase::class)
|
||||||
|
->in('Architecture');
|
||||||
|
|
||||||
pest()->extend(Tests\TestCase::class)
|
pest()->extend(Tests\TestCase::class)
|
||||||
->in('Deprecation');
|
->in('Deprecation');
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,12 @@
|
|||||||
expect(RunFailureSanitizer::normalizeReasonCode('500'))->toBe(ProviderReasonCodes::NetworkUnreachable);
|
expect(RunFailureSanitizer::normalizeReasonCode('500'))->toBe(ProviderReasonCodes::NetworkUnreachable);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('distinguishes structured operator reason codes from fallback-only inputs', function (): void {
|
||||||
|
expect(RunFailureSanitizer::isStructuredOperatorReasonCode('missing_capability'))->toBeTrue()
|
||||||
|
->and(RunFailureSanitizer::isStructuredOperatorReasonCode('provider_connection_missing'))->toBeTrue()
|
||||||
|
->and(RunFailureSanitizer::isStructuredOperatorReasonCode('foundation.capture_failed'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
it('redacts common secret patterns and forbidden substrings', function (): void {
|
it('redacts common secret patterns and forbidden substrings', function (): void {
|
||||||
$message = 'Authorization: Bearer super-secret-token access_token=abc refresh_token=def client_secret=ghi password=jkl';
|
$message = 'Authorization: Bearer super-secret-token access_token=abc refresh_token=def client_secret=ghi password=jkl';
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||||
|
|
||||||
|
it('keeps execution denial diagnostics stable while translating the operator label', function (): void {
|
||||||
|
$envelope = ExecutionDenialReasonCode::MissingCapability->toReasonResolutionEnvelope();
|
||||||
|
|
||||||
|
expect($envelope->internalCode)->toBe(ExecutionDenialReasonCode::MissingCapability->value)
|
||||||
|
->and($envelope->diagnosticCode())->toBe(ExecutionDenialReasonCode::MissingCapability->value)
|
||||||
|
->and($envelope->operatorLabel)->toBe('Permission required')
|
||||||
|
->and($envelope->shortExplanation)->toContain('capability required for this queued run')
|
||||||
|
->and($envelope->guidanceText())->toBe('Next step: Review workspace or tenant access before retrying.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies tenant-operability execution denials as retryable when the tenant can recover', function (): void {
|
||||||
|
$envelope = ExecutionDenialReasonCode::TenantNotOperable->toReasonResolutionEnvelope();
|
||||||
|
|
||||||
|
expect($envelope->actionability)->toBe('retryable_transient')
|
||||||
|
->and($envelope->guidanceText())->toBe('Next step: Review tenant readiness before retrying.');
|
||||||
|
});
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('translates provider reasons into labels, explanations, and next-step links', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->graphTenantId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$envelope = app(ReasonPresenter::class)->forProviderReason(
|
||||||
|
tenant: $tenant,
|
||||||
|
reasonCode: ProviderReasonCodes::DedicatedCredentialMissing,
|
||||||
|
connection: $connection,
|
||||||
|
surface: 'helper_copy',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($envelope)->not->toBeNull()
|
||||||
|
->and($envelope?->operatorLabel)->toBe('Dedicated credentials required')
|
||||||
|
->and($envelope?->shortExplanation)->toContain('dedicated credentials are configured')
|
||||||
|
->and($envelope?->toLegacyNextSteps()[0]['label'] ?? null)->toBe('Manage dedicated connection')
|
||||||
|
->and($envelope?->toLegacyNextSteps()[0]['url'] ?? null)->toContain('/provider-connections/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a bounded provider fallback for untranslated extension reasons', function (): void {
|
||||||
|
$envelope = app(\App\Support\Providers\ProviderReasonTranslator::class)->translate('ext.multiple_defaults_detected');
|
||||||
|
|
||||||
|
expect($envelope)->not->toBeNull()
|
||||||
|
->and($envelope?->operatorLabel)->toBe('Provider configuration needs review')
|
||||||
|
->and($envelope?->diagnosticCode())->toBe('ext.multiple_defaults_detected')
|
||||||
|
->and($envelope?->guidanceText())->toBe('Next step: Review the provider connection before retrying.');
|
||||||
|
});
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\RbacReason;
|
||||||
|
|
||||||
|
it('translates manual RBAC assignment reasons into operator guidance', function (): void {
|
||||||
|
$envelope = RbacReason::ManualAssignmentRequired->toReasonResolutionEnvelope();
|
||||||
|
|
||||||
|
expect($envelope->operatorLabel)->toBe('Manual role assignment required')
|
||||||
|
->and($envelope->actionability)->toBe('prerequisite_missing')
|
||||||
|
->and($envelope->shortExplanation)->toContain('manual Intune RBAC role assignment')
|
||||||
|
->and($envelope->guidanceText())->toBe('Next step: Complete the Intune role assignment manually, then refresh RBAC status.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks unsupported RBAC API cases as diagnostic-only operator states', function (): void {
|
||||||
|
$envelope = RbacReason::UnsupportedApi->toReasonResolutionEnvelope();
|
||||||
|
|
||||||
|
expect($envelope->actionability)->toBe('non_actionable')
|
||||||
|
->and($envelope->operatorLabel)->toBe('RBAC API unsupported')
|
||||||
|
->and($envelope->guidanceText())->toBe('No action needed.');
|
||||||
|
});
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\ReasonTranslation\FallbackReasonTranslator;
|
||||||
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
|
||||||
|
it('renders body lines and legacy next steps from the shared envelope', function (): void {
|
||||||
|
$envelope = new ReasonResolutionEnvelope(
|
||||||
|
internalCode: 'provider_consent_missing',
|
||||||
|
operatorLabel: 'Admin consent required',
|
||||||
|
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
|
||||||
|
actionability: 'prerequisite_missing',
|
||||||
|
nextSteps: [
|
||||||
|
NextStepOption::link('Grant admin consent', 'https://example.test/admin-consent'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($envelope->toBodyLines())->toBe([
|
||||||
|
'Admin consent required',
|
||||||
|
'The provider connection cannot continue until admin consent is granted.',
|
||||||
|
'Next step: Grant admin consent.',
|
||||||
|
])->and($envelope->toLegacyNextSteps())->toBe([
|
||||||
|
[
|
||||||
|
'label' => 'Grant admin consent',
|
||||||
|
'url' => 'https://example.test/admin-consent',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds understandable fallback translations without exposing raw codes as operator labels', function (): void {
|
||||||
|
$envelope = app(FallbackReasonTranslator::class)->translate('custom_retry_timeout');
|
||||||
|
|
||||||
|
expect($envelope)->not->toBeNull()
|
||||||
|
->and($envelope?->operatorLabel)->toBe('Custom Retry Timeout')
|
||||||
|
->and($envelope?->operatorLabel)->not->toBe('custom_retry_timeout')
|
||||||
|
->and($envelope?->shortExplanation)->toContain('transient dependency issue')
|
||||||
|
->and($envelope?->guidanceText())->toBe('Next step: Retry after the dependency recovers.');
|
||||||
|
});
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||||
|
|
||||||
|
it('marks already archived tenant states as non-actionable while preserving diagnostics', function (): void {
|
||||||
|
$envelope = TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope();
|
||||||
|
|
||||||
|
expect($envelope->operatorLabel)->toBe('Tenant already archived')
|
||||||
|
->and($envelope->actionability)->toBe('non_actionable')
|
||||||
|
->and($envelope->guidanceText())->toBe('No action needed.')
|
||||||
|
->and($envelope->diagnosticCode())->toBe(TenantOperabilityReasonCode::TenantAlreadyArchived->value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates capability denials for tenant-operability outcomes', function (): void {
|
||||||
|
$envelope = TenantOperabilityReasonCode::MissingCapability->toReasonResolutionEnvelope();
|
||||||
|
|
||||||
|
expect($envelope->operatorLabel)->toBe('Permission required')
|
||||||
|
->and($envelope->shortExplanation)->toContain('missing the capability')
|
||||||
|
->and($envelope->guidanceText())->toBe('Next step: Ask a tenant Owner to grant the required capability.');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user