feat(onboarding): align verify-step hierarchy; contextual-help dark-mode callout
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m46s

This commit is contained in:
Ahmed Darrazi 2026-04-27 02:05:41 +02:00
parent 9f5d3293c5
commit 80bd9e3087
18 changed files with 2455 additions and 52 deletions

View File

@ -4,6 +4,7 @@
namespace App\Filament\Pages\Workspaces; namespace App\Filament\Pages\Workspaces;
use BackedEnum;
use App\Exceptions\Onboarding\OnboardingDraftConflictException; use App\Exceptions\Onboarding\OnboardingDraftConflictException;
use App\Exceptions\Onboarding\OnboardingDraftImmutableException; use App\Exceptions\Onboarding\OnboardingDraftImmutableException;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
@ -51,6 +52,7 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -994,6 +996,7 @@ private function routeBoundReadinessSchema(): array
} }
$payload = $this->onboardingReadinessPayload($draft); $payload = $this->onboardingReadinessPayload($draft);
$primaryNextAction = $this->readinessPrimaryNextActionComponent($payload, 'route_bound_readiness');
$schema = [ $schema = [
Section::make('Onboarding readiness') Section::make('Onboarding readiness')
@ -1001,7 +1004,7 @@ private function routeBoundReadinessSchema(): array
->compact() ->compact()
->columns(2) ->columns(2)
->schema([ ->schema([
Text::make('Current checkpoint') Text::make('Step')
->color('gray'), ->color('gray'),
Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—') Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—')
->badge() ->badge()
@ -1021,9 +1024,7 @@ private function routeBoundReadinessSchema(): array
Text::make($payload['freshness']['note']), Text::make($payload['freshness']['note']),
Text::make('Primary next action') Text::make('Primary next action')
->color('gray'), ->color('gray'),
Text::make($payload['next_action']['label']) $primaryNextAction,
->badge()
->color($this->readinessNextActionColor($payload['next_action']['kind'])),
]), ]),
]; ];
@ -1064,8 +1065,14 @@ private function draftCompactReadinessSchema(TenantOnboardingSession $draft): ar
private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array
{ {
$links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : []; $links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : [];
$assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : [];
$showAssist = (bool) ($assist['is_visible'] ?? false);
$permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : [];
$requiredPermissionsUrl = is_string($permissions['required_permissions_url'] ?? null)
? $permissions['required_permissions_url']
: null;
if ($links === []) { if ($links === [] && ! ($showAssist && $requiredPermissionsUrl !== null && $requiredPermissionsUrl !== '')) {
return []; return [];
} }
@ -1089,13 +1096,20 @@ private function readinessSupportingEvidenceSchema(array $payload, string $keyPr
->url($url); ->url($url);
} }
if ($showAssist && $requiredPermissionsUrl !== null && $requiredPermissionsUrl !== '') {
$actions[] = Action::make($keyPrefix.'_required_permissions_assist')
->label('View required permissions')
->color('gray')
->url($requiredPermissionsUrl);
}
if ($actions === []) { if ($actions === []) {
return []; return [];
} }
return [ return [
Section::make('Supporting evidence') Section::make('Supporting evidence')
->description('Open canonical operation detail when deeper diagnostics are needed.') ->description('Open canonical operation detail or secondary permission evidence when deeper diagnostics are needed.')
->compact() ->compact()
->schema([ ->schema([
SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'), SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'),
@ -1115,14 +1129,16 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke
return []; return [];
} }
if ((bool) ($payload['verification']['has_report'] ?? false)) {
return [];
}
$counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : []; $counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : [];
$missingApplication = (int) ($counts['missing_application'] ?? 0); $missingApplication = (int) ($counts['missing_application'] ?? 0);
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0); $missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
$errors = (int) ($counts['error'] ?? 0); $errors = (int) ($counts['error'] ?? 0);
$assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : [];
$isVisible = (bool) ($assist['is_visible'] ?? false);
if ($missingApplication + $missingDelegated + $errors === 0 && ! $isVisible) { if ($missingApplication + $missingDelegated + $errors === 0) {
return []; return [];
} }
@ -1177,7 +1193,7 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke
* draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string}, * draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string},
* checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string}, * checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string},
* provider_summary: array<string, mixed>|null, * provider_summary: array<string, mixed>|null,
* verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, matches_selected_connection: bool|null, overall: string|null}, * verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, has_report: bool, matches_selected_connection: bool|null, overall: string|null},
* verification_assist: array{is_visible: bool, reason: string}, * verification_assist: array{is_visible: bool, reason: string},
* permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null, * permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null,
* freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string}, * freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string},
@ -1218,6 +1234,9 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
$permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null; $permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null;
$verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null; $verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null;
$verificationReport = is_array($verificationReport) ? $verificationReport : null; $verificationReport = is_array($verificationReport) ? $verificationReport : null;
$verificationPrimaryReasonCode = $verificationReport !== null
? app(ContextualHelpResolver::class)->primaryReasonCodeFromVerificationReport($verificationReport)
: null;
$permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [ $permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [
'last_refreshed_at' => null, 'last_refreshed_at' => null,
'is_stale' => true, 'is_stale' => true,
@ -1237,6 +1256,9 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
verificationMismatch: $verificationMismatch, verificationMismatch: $verificationMismatch,
); );
$reasonCode = is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null;
$blockingReasonCode = is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null;
return [ return [
'draft' => [ 'draft' => [
'id' => (int) $draft->getKey(), 'id' => (int) $draft->getKey(),
@ -1263,6 +1285,7 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
? OperationRunLinks::tenantlessView($verificationRun) ? OperationRunLinks::tenantlessView($verificationRun)
: null, : null,
'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value, 'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value,
'has_report' => $verificationReport !== null,
'matches_selected_connection' => $verificationMatchesSelectedConnection, 'matches_selected_connection' => $verificationMatchesSelectedConnection,
'overall' => $verificationRun instanceof OperationRun 'overall' => $verificationRun instanceof OperationRun
? $this->readinessVerificationOverall($verificationRun, $verificationReport) ? $this->readinessVerificationOverall($verificationRun, $verificationReport)
@ -1286,8 +1309,8 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
), ),
], ],
'blocker' => [ 'blocker' => [
'reason_code' => is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null, 'reason_code' => $reasonCode,
'blocking_reason_code' => is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null, 'blocking_reason_code' => $blockingReasonCode,
'operator_summary' => $readinessSummary, 'operator_summary' => $readinessSummary,
], ],
'next_action' => $this->readinessNextAction( 'next_action' => $this->readinessNextAction(
@ -1297,6 +1320,7 @@ private function onboardingReadinessPayload(TenantOnboardingSession $draft): arr
verificationRun: $verificationRun, verificationRun: $verificationRun,
verificationStatus: $verificationStatus, verificationStatus: $verificationStatus,
permissions: $permissions, permissions: $permissions,
blockerReasonCode: $verificationPrimaryReasonCode ?? $blockingReasonCode ?? $reasonCode,
connectionRecentlyUpdated: $connectionRecentlyUpdated, connectionRecentlyUpdated: $connectionRecentlyUpdated,
verificationMismatch: $verificationMismatch, verificationMismatch: $verificationMismatch,
supportingLinks: $supportingLinks, supportingLinks: $supportingLinks,
@ -1374,6 +1398,35 @@ private function readinessNextActionColor(string $kind): string
}; };
} }
/**
* @param array<string, mixed> $payload
*/
private function readinessPrimaryNextActionComponent(array $payload, string $keyPrefix): \Filament\Schemas\Components\Component
{
$nextAction = is_array($payload['next_action'] ?? null) ? $payload['next_action'] : [];
$label = is_string($nextAction['label'] ?? null) && trim((string) $nextAction['label']) !== ''
? trim((string) $nextAction['label'])
: 'Continue onboarding';
$kind = is_string($nextAction['kind'] ?? null) ? $nextAction['kind'] : 'gray';
$url = is_string($nextAction['url'] ?? null) && trim((string) $nextAction['url']) !== ''
? trim((string) $nextAction['url'])
: null;
if ($url !== null) {
return SchemaActions::make([
Action::make($keyPrefix.'_primary_next_action')
->label($label)
->color($this->readinessNextActionColor($kind))
->url($url)
->openUrlInNewTab(str_starts_with($url, 'http://') || str_starts_with($url, 'https://')),
])->key($keyPrefix.'_primary_next_action');
}
return Text::make($label)
->badge()
->color($this->readinessNextActionColor($kind));
}
private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection
{ {
$state = is_array($draft->state) ? $draft->state : []; $state = is_array($draft->state) ? $draft->state : [];
@ -1407,8 +1460,8 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr
return [ return [
'provider' => (string) $connection->provider, 'provider' => (string) $connection->provider,
'target_scope' => [], 'target_scope' => [],
'consent_state' => (string) $connection->consent_status, 'consent_state' => $this->stringValue($connection->consent_status),
'verification_state' => (string) $connection->verification_status, 'verification_state' => $this->stringValue($connection->verification_status),
'readiness_summary' => 'Target scope needs review', 'readiness_summary' => 'Target scope needs review',
'target_scope_summary' => 'Target scope needs review', 'target_scope_summary' => 'Target scope needs review',
'contextual_identity_line' => null, 'contextual_identity_line' => null,
@ -1614,6 +1667,7 @@ private function readinessNextAction(
?OperationRun $verificationRun, ?OperationRun $verificationRun,
string $verificationStatus, string $verificationStatus,
?array $permissions, ?array $permissions,
?string $blockerReasonCode,
bool $connectionRecentlyUpdated, bool $connectionRecentlyUpdated,
bool $verificationMismatch, bool $verificationMismatch,
array $supportingLinks, array $supportingLinks,
@ -1639,7 +1693,7 @@ private function readinessNextAction(
if ($consentState !== ProviderConsentStatus::Granted->value) { if ($consentState !== ProviderConsentStatus::Granted->value) {
return $this->readinessAction( return $this->readinessAction(
label: 'Grant consent', label: 'Grant admin consent',
kind: 'grant_consent', kind: 'grant_consent',
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null, url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
); );
@ -1647,6 +1701,18 @@ private function readinessNextAction(
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null; $permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
if (in_array($blockerReasonCode, [
ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked,
], true)) {
return $this->readinessAction(
label: 'Grant admin consent',
kind: 'grant_consent',
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
);
}
if ($permissionOverall === VerificationReportOverall::Blocked->value) { if ($permissionOverall === VerificationReportOverall::Blocked->value) {
return $this->readinessAction( return $this->readinessAction(
label: 'Review permissions', label: 'Review permissions',
@ -2777,6 +2843,7 @@ private function verificationReportViewData(): array
'acknowledgements' => [], 'acknowledgements' => [],
'surface' => [], 'surface' => [],
'redactionNotes' => [], 'redactionNotes' => [],
'contextualHelp' => null,
'assistVisibility' => $assistVisibility, 'assistVisibility' => $assistVisibility,
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist', 'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails', 'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
@ -2786,6 +2853,7 @@ private function verificationReportViewData(): array
$report = VerificationReportViewer::report($run); $report = VerificationReportViewer::report($run);
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null; $fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
$contextualHelp = is_array($report) ? $this->verificationContextualHelp($report, $run) : null;
$changeIndicator = VerificationReportChangeIndicator::forRun($run); $changeIndicator = VerificationReportChangeIndicator::forRun($run);
$previousRunUrl = $this->verificationPreviousRunUrl($changeIndicator); $previousRunUrl = $this->verificationPreviousRunUrl($changeIndicator);
@ -2872,6 +2940,7 @@ private function verificationReportViewData(): array
'acknowledgements' => $acknowledgements, 'acknowledgements' => $acknowledgements,
'surface' => $surface, 'surface' => $surface,
'redactionNotes' => VerificationReportViewer::redactionNotes($report), 'redactionNotes' => VerificationReportViewer::redactionNotes($report),
'contextualHelp' => $contextualHelp,
'assistVisibility' => $assistVisibility, 'assistVisibility' => $assistVisibility,
'assistActionName' => 'wizardVerificationRequiredPermissionsAssist', 'assistActionName' => 'wizardVerificationRequiredPermissionsAssist',
'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails', 'technicalDetailsActionName' => 'wizardVerificationTechnicalDetails',
@ -2879,6 +2948,40 @@ private function verificationReportViewData(): array
]; ];
} }
/**
* @param array<string, mixed> $verificationReport
* @return array<string, mixed>|null
*/
private function verificationContextualHelp(array $verificationReport, OperationRun $run): ?array
{
$tenant = $this->managedTenant;
if (! $tenant instanceof Tenant) {
return null;
}
$resolver = app(ContextualHelpResolver::class);
$reasonCode = $resolver->primaryReasonCodeFromVerificationReport($verificationReport);
$topicKey = $resolver->topicKeyForOnboardingVerification(
reasonCode: $reasonCode,
isVerificationStale: $this->verificationRunIsStaleForSelectedConnection(),
verificationOverall: is_string(data_get($verificationReport, 'summary.overall'))
? (string) data_get($verificationReport, 'summary.overall')
: null,
runOutcome: is_string($run->outcome) ? (string) $run->outcome : null,
);
if ($topicKey === null) {
return null;
}
return $resolver->tryResolve($topicKey, [
'tenant' => $tenant,
'reason_code' => $reasonCode,
'surface' => 'onboarding',
]);
}
public function wizardVerificationRequiredPermissionsAssistAction(): Action public function wizardVerificationRequiredPermissionsAssistAction(): Action
{ {
return Action::make('wizardVerificationRequiredPermissionsAssist') return Action::make('wizardVerificationRequiredPermissionsAssist')
@ -4507,6 +4610,19 @@ private function completionSummaryConnectionSummary(): string
return sprintf('%s - %s', $label, $detail); return sprintf('%s - %s', $label, $detail);
} }
private function stringValue(mixed $value): string
{
if ($value instanceof BackedEnum) {
return (string) $value->value;
}
if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) {
return (string) $value;
}
return '';
}
private function completionSummaryVerificationDetail(): string private function completionSummaryVerificationDetail(): string
{ {
$counts = $this->verificationReportCounts(); $counts = $this->verificationReportCounts();

View File

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductKnowledge;
use App\Support\Links\RequiredPermissionsLinks;
use InvalidArgumentException;
final class ContextualHelpCatalog
{
public const string ADMIN_CONSENT_REQUIRED = 'admin-consent-required';
public const string REQUIRED_PERMISSIONS_MISSING = 'required-permissions-missing';
public const string CONNECTION_UNHEALTHY = 'connection-unhealthy';
public const string VERIFICATION_STALE = 'verification-stale';
public const string VERIFICATION_FAILED = 'verification-failed';
public const string DIAGNOSTIC_EVIDENCE_INCOMPLETE = 'diagnostic-evidence-incomplete';
public const string RETRYABLE_PROVIDER_FAILURE = 'retryable-provider-failure';
public const string MANUAL_HANDOFF_REQUIRED = 'manual-handoff-required';
public const string LINK_RESOLVER_ADMIN_CONSENT_PRIMARY = 'admin_consent_primary';
public const string LINK_RESOLVER_REQUIRED_PERMISSIONS = 'required_permissions';
/**
* @return list<string>
*/
public function keys(): array
{
return array_keys($this->definitions());
}
/**
* @return array<string, array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url?: string,
* resolver?: string
* }>
* }>
*/
public function definitions(): array
{
return [
self::ADMIN_CONSENT_REQUIRED => [
'topic_key' => self::ADMIN_CONSENT_REQUIRED,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Admin consent required',
'short_explanation' => 'This workflow is blocked until admin consent is granted for the current provider connection.',
'troubleshooting_steps' => [
'Grant admin consent for the current provider connection.',
'Re-run verification or reopen support diagnostics after consent completes.',
],
'safe_next_action' => 'Grant admin consent and re-run verification.',
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
'docs_links' => [
[
'label' => 'Grant admin consent',
'kind' => 'action',
'resolver' => self::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
],
[
'label' => 'Admin consent guide',
'kind' => 'docs',
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
],
],
],
self::REQUIRED_PERMISSIONS_MISSING => [
'topic_key' => self::REQUIRED_PERMISSIONS_MISSING,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Required permissions missing',
'short_explanation' => 'The provider app is missing one or more required permissions for the current workflow.',
'troubleshooting_steps' => [
'Review the required permissions matrix for the current tenant.',
'Refresh verification after the missing permissions are granted.',
],
'safe_next_action' => 'Open required permissions and confirm the missing grants.',
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
'docs_links' => [
[
'label' => 'Open required permissions',
'kind' => 'action',
'resolver' => self::LINK_RESOLVER_REQUIRED_PERMISSIONS,
],
],
],
self::CONNECTION_UNHEALTHY => [
'topic_key' => self::CONNECTION_UNHEALTHY,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Provider connection needs review',
'short_explanation' => 'The provider connection is degraded or unavailable, so the current result cannot be treated as healthy.',
'troubleshooting_steps' => [
'Review the latest provider connection health signal.',
'Retry the workflow after the provider connection is healthy again.',
],
'safe_next_action' => 'Review the provider connection before retrying.',
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
'docs_links' => [],
],
self::VERIFICATION_STALE => [
'topic_key' => self::VERIFICATION_STALE,
'surface_families' => ['onboarding'],
'headline' => 'Verification result is stale',
'short_explanation' => 'The most recent verification result is too old or mismatched to trust for the current onboarding decision.',
'troubleshooting_steps' => [
'Run verification again for the currently selected provider connection.',
'Use the refreshed result before continuing onboarding.',
],
'safe_next_action' => 'Refresh verification before continuing onboarding.',
'glossary_terms' => ['tenant', 'workspace', 'verification'],
'docs_links' => [],
],
self::VERIFICATION_FAILED => [
'topic_key' => self::VERIFICATION_FAILED,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Verification failed',
'short_explanation' => 'The latest verification run did not produce a decision-grade result for the current tenant context.',
'troubleshooting_steps' => [
'Review the blocking reason before retrying verification.',
'Confirm the prerequisite is fixed, then run verification again.',
],
'safe_next_action' => 'Review the blocking reason and retry verification.',
'glossary_terms' => ['tenant', 'workspace', 'verification'],
'docs_links' => [],
],
self::DIAGNOSTIC_EVIDENCE_INCOMPLETE => [
'topic_key' => self::DIAGNOSTIC_EVIDENCE_INCOMPLETE,
'surface_families' => ['support_diagnostics'],
'headline' => 'Diagnostic evidence is incomplete',
'short_explanation' => 'Support diagnostics can summarize the issue, but the available evidence is not complete enough for a final conclusion.',
'troubleshooting_steps' => [
'Review the available evidence and supporting references in the current support context.',
'Collect a fresh verification or operation result before making a final decision.',
],
'safe_next_action' => 'Collect a fresher or more complete diagnostic signal.',
'glossary_terms' => ['support diagnostics', 'evidence', 'workspace'],
'docs_links' => [],
],
self::RETRYABLE_PROVIDER_FAILURE => [
'topic_key' => self::RETRYABLE_PROVIDER_FAILURE,
'surface_families' => ['support_diagnostics'],
'headline' => 'Provider failure looks retryable',
'short_explanation' => 'The current provider issue appears temporary, so the next safe step is to retry once the dependency recovers.',
'troubleshooting_steps' => [
'Confirm the provider dependency has recovered.',
'Retry the workflow after the provider-side issue clears.',
],
'safe_next_action' => 'Retry after the provider dependency recovers.',
'glossary_terms' => ['support diagnostics', 'provider connection', 'workspace'],
'docs_links' => [],
],
self::MANUAL_HANDOFF_REQUIRED => [
'topic_key' => self::MANUAL_HANDOFF_REQUIRED,
'surface_families' => ['support_diagnostics'],
'headline' => 'Manual support handoff required',
'short_explanation' => 'TenantPilot can summarize the current issue, but a human support handoff is still required for the next step.',
'troubleshooting_steps' => [
'Review the current references before handing off the case.',
'Capture the safe next step and the supporting evidence for the receiving operator.',
],
'safe_next_action' => 'Hand off the case with the current diagnostic summary and supporting references.',
'glossary_terms' => ['support diagnostics', 'tenant', 'workspace'],
'docs_links' => [],
],
];
}
/**
* @return array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url?: string,
* resolver?: string
* }>
* }
*/
public function definition(string $topicKey): array
{
$definition = $this->definitions()[trim($topicKey)] ?? null;
if (! is_array($definition)) {
throw new InvalidArgumentException('Unknown contextual help topic');
}
return $definition;
}
/**
* @return array{
* version: int,
* topic_count: int,
* topics: list<array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>
* }>
* }
*/
public function knowledgeSource(): array
{
$topics = array_values(array_map(
fn (array $definition): array => [
'topic_key' => $definition['topic_key'],
'surface_families' => $definition['surface_families'],
'headline' => $definition['headline'],
'short_explanation' => $definition['short_explanation'],
'troubleshooting_steps' => $definition['troubleshooting_steps'],
'safe_next_action' => $definition['safe_next_action'],
'glossary_terms' => $definition['glossary_terms'],
'docs_links' => array_values(array_map(
static fn (array $link): array => [
'label' => (string) $link['label'],
'kind' => (string) ($link['kind'] ?? 'url'),
'url' => isset($link['url']) && is_string($link['url']) ? $link['url'] : null,
'resolver' => isset($link['resolver']) && is_string($link['resolver']) ? $link['resolver'] : null,
],
$definition['docs_links'],
)),
],
$this->definitions(),
));
return [
'version' => 1,
'topic_count' => count($topics),
'topics' => $topics,
];
}
}

View File

@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductKnowledge;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use InvalidArgumentException;
final class ContextualHelpResolver
{
public function __construct(
private readonly ContextualHelpCatalog $catalog,
private readonly PlatformVocabularyGlossary $glossary,
private readonly ReasonPresenter $reasonPresenter,
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
) {}
/**
* @param array<string, mixed> $context
* @return array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>,
* canonical_terms: list<string>,
* reason_label: ?string,
* diagnostic_code: ?string,
* operator_summary: ?array{
* primaryReason: string,
* nextActionText: string,
* diagnosticsAvailable: bool,
* diagnosticsSummary: ?string
* }
* }
*/
public function resolve(string $topicKey, array $context = []): array
{
$definition = $this->catalog->definition($topicKey);
$reasonEnvelope = $this->providerReasonEnvelope($context);
$operatorSummary = $this->operatorSummary($context);
return [
'topic_key' => $definition['topic_key'],
'surface_families' => $definition['surface_families'],
'headline' => $this->firstNonEmpty(
$this->stringOrNull($context['headline'] ?? null),
$definition['headline'],
),
'short_explanation' => $this->firstNonEmpty(
$this->stringOrNull($context['short_explanation'] ?? null),
$this->reasonPresenter->shortExplanation($reasonEnvelope),
$definition['short_explanation'],
),
'troubleshooting_steps' => $this->troubleshootingSteps($definition['troubleshooting_steps'], $context),
'safe_next_action' => $this->firstNonEmpty(
$this->stringOrNull($context['safe_next_action'] ?? null),
$operatorSummary['nextActionText'] ?? null,
$definition['safe_next_action'],
),
'docs_links' => $this->resolveLinks($definition['docs_links'], $context),
'canonical_terms' => $this->canonicalTerms($definition['glossary_terms']),
'reason_label' => $this->reasonPresenter->primaryLabel($reasonEnvelope),
'diagnostic_code' => $this->reasonPresenter->diagnosticCode($reasonEnvelope),
'operator_summary' => $operatorSummary,
];
}
/**
* @param array<string, mixed> $context
* @return array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>,
* canonical_terms: list<string>,
* reason_label: ?string,
* diagnostic_code: ?string,
* operator_summary: ?array{
* primaryReason: string,
* nextActionText: string,
* diagnosticsAvailable: bool,
* diagnosticsSummary: ?string
* }
* }|null
*/
public function tryResolve(?string $topicKey, array $context = []): ?array
{
if (! is_string($topicKey) || trim($topicKey) === '') {
return null;
}
try {
return $this->resolve($topicKey, $context);
} catch (InvalidArgumentException) {
return null;
}
}
/**
* @return array{
* version: int,
* topic_count: int,
* topics: list<array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>
* }>
* }
*/
public function knowledgeSource(): array
{
return $this->catalog->knowledgeSource();
}
/**
* @param array<string, mixed>|null $verificationReport
*/
public function primaryReasonCodeFromVerificationReport(?array $verificationReport): ?string
{
foreach ($this->relevantChecks($verificationReport) as $check) {
$reasonCode = $check['reason_code'] ?? null;
if (is_string($reasonCode) && trim($reasonCode) !== '') {
return trim($reasonCode);
}
}
return null;
}
public function topicKeyForOnboardingVerification(
?string $reasonCode,
bool $isVerificationStale,
?string $verificationOverall,
?string $runOutcome,
): ?string {
if ($isVerificationStale) {
return ContextualHelpCatalog::VERIFICATION_STALE;
}
$reasonTopicKey = $this->onboardingTopicKeyForReason($reasonCode);
if ($reasonTopicKey !== null) {
return $reasonTopicKey;
}
return $this->isVerificationFailure($verificationOverall, $runOutcome)
? ContextualHelpCatalog::VERIFICATION_FAILED
: null;
}
public function topicKeyForSupportDiagnostics(
?string $reasonCode,
bool $hasIncompleteEvidence,
?string $runOutcome,
): ?string {
$reasonTopicKey = $this->supportDiagnosticsTopicKeyForReason($reasonCode);
if ($reasonTopicKey !== null) {
return $reasonTopicKey;
}
if (is_string($reasonCode) && trim($reasonCode) !== '') {
return $reasonCode === ProviderReasonCodes::UnknownError
? ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED
: null;
}
if ($this->isVerificationFailure(null, $runOutcome)) {
return ContextualHelpCatalog::VERIFICATION_FAILED;
}
return $hasIncompleteEvidence
? ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE
: null;
}
/**
* @param array<string, mixed> $context
*/
private function providerReasonEnvelope(array $context): ?ReasonResolutionEnvelope
{
$tenant = $context['tenant'] ?? null;
$reasonCode = $context['reason_code'] ?? null;
$connection = $context['connection'] ?? null;
$surface = $this->stringOrNull($context['surface'] ?? null) ?? 'detail';
if (! $tenant instanceof Tenant || ! is_string($reasonCode) || trim($reasonCode) === '') {
return null;
}
return $this->reasonPresenter->forProviderReason(
tenant: $tenant,
reasonCode: trim($reasonCode),
connection: $connection instanceof ProviderConnection ? $connection : null,
surface: $surface,
);
}
/**
* @param array<string, mixed> $context
* @return array{
* primaryReason: string,
* nextActionText: string,
* diagnosticsAvailable: bool,
* diagnosticsSummary: ?string
* }|null
*/
private function operatorSummary(array $context): ?array
{
$truth = $context['artifact_truth'] ?? null;
if (! $truth instanceof ArtifactTruthEnvelope) {
return null;
}
return $this->operatorExplanationBuilder->compressionSummaryInputs($truth);
}
/**
* @param array<string, mixed>|null $verificationReport
* @return list<array<string, mixed>>
*/
private function relevantChecks(?array $verificationReport): array
{
$checks = is_array($verificationReport['checks'] ?? null)
? $verificationReport['checks']
: [];
return array_values(array_filter($checks, static function (mixed $check): bool {
if (! is_array($check)) {
return false;
}
$status = $check['status'] ?? null;
return is_string($status)
&& ! in_array($status, ['pass', 'skip', 'running'], true);
}));
}
private function onboardingTopicKeyForReason(?string $reasonCode): ?string
{
return match ($reasonCode) {
ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::IntuneRbacPermissionMissing => ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING,
ProviderReasonCodes::ProviderConnectionMissing,
ProviderReasonCodes::ProviderConnectionInvalid,
ProviderReasonCodes::ProviderCredentialMissing,
ProviderReasonCodes::ProviderCredentialInvalid,
ProviderReasonCodes::ProviderConnectionTypeInvalid,
ProviderReasonCodes::DedicatedCredentialMissing,
ProviderReasonCodes::DedicatedCredentialInvalid,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderConnectionReviewRequired,
ProviderReasonCodes::ProviderBindingUnsupported,
ProviderReasonCodes::TenantTargetMismatch,
ProviderReasonCodes::PlatformIdentityMissing,
ProviderReasonCodes::PlatformIdentityIncomplete,
ProviderReasonCodes::IntuneRbacNotConfigured,
ProviderReasonCodes::IntuneRbacUnhealthy,
ProviderReasonCodes::IntuneRbacStale => ContextualHelpCatalog::CONNECTION_UNHEALTHY,
default => null,
};
}
private function supportDiagnosticsTopicKeyForReason(?string $reasonCode): ?string
{
if ($reasonCode === ProviderReasonCodes::ProviderPermissionRefreshFailed
|| $reasonCode === ProviderReasonCodes::NetworkUnreachable
|| $reasonCode === ProviderReasonCodes::RateLimited) {
return ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE;
}
return $this->onboardingTopicKeyForReason($reasonCode);
}
/**
* @param list<string> $defaultSteps
* @param array<string, mixed> $context
* @return list<string>
*/
private function troubleshootingSteps(array $defaultSteps, array $context): array
{
$contextSteps = $context['troubleshooting_steps'] ?? [];
if (! is_array($contextSteps)) {
return $defaultSteps;
}
$normalizedContextSteps = array_values(array_filter(array_map(
fn (mixed $step): ?string => $this->stringOrNull($step),
$contextSteps,
)));
if ($normalizedContextSteps === []) {
return $defaultSteps;
}
return array_values(array_unique([...$defaultSteps, ...$normalizedContextSteps]));
}
/**
* @param list<array{label: string, kind: string, url?: string, resolver?: string}> $links
* @param array<string, mixed> $context
* @return list<array{label: string, kind: string, url: ?string, resolver: ?string}>
*/
private function resolveLinks(array $links, array $context): array
{
return array_values(array_filter(array_map(
fn (array $link): ?array => $this->resolveLink($link, $context['tenant'] ?? null),
$links,
)));
}
/**
* @param array{label: string, kind: string, url?: string, resolver?: string} $link
* @return array{label: string, kind: string, url: ?string, resolver: ?string}|null
*/
private function resolveLink(array $link, mixed $tenant): ?array
{
$label = $this->stringOrNull($link['label'] ?? null);
if ($label === null) {
return null;
}
$resolved = [
'label' => $label,
'kind' => $this->stringOrNull($link['kind'] ?? null) ?? 'url',
'url' => $this->stringOrNull($link['url'] ?? null),
'resolver' => $this->stringOrNull($link['resolver'] ?? null),
];
if (! $tenant instanceof Tenant || $resolved['resolver'] === null) {
return $resolved;
}
$resolved['url'] = match ($resolved['resolver']) {
ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS => RequiredPermissionsLinks::requiredPermissions($tenant),
default => $resolved['url'],
};
return $resolved;
}
/**
* @param list<string> $terms
* @return list<string>
*/
private function canonicalTerms(array $terms): array
{
return array_values(array_unique(array_filter(array_map(function (string $term): ?string {
$canonical = $this->glossary->canonicalName($term);
return $canonical !== null ? trim($canonical) : $this->stringOrNull($term);
}, $terms))));
}
private function isVerificationFailure(?string $verificationOverall, ?string $runOutcome): bool
{
return in_array($verificationOverall, ['blocked', 'needs_attention'], true)
|| in_array($runOutcome, ['failed', 'blocked', 'partially_succeeded'], true);
}
private function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function firstNonEmpty(?string ...$values): string
{
foreach ($values as $value) {
if ($value !== null && trim($value) !== '') {
return $value;
}
}
return '';
}
}

View File

@ -22,6 +22,7 @@
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder; use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderReasonTranslator; use App\Support\Providers\ProviderReasonTranslator;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary; use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
@ -47,6 +48,7 @@ public function __construct(
private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder, private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder,
private readonly ProviderReasonTranslator $providerReasonTranslator, private readonly ProviderReasonTranslator $providerReasonTranslator,
private readonly RelatedNavigationResolver $relatedNavigationResolver, private readonly RelatedNavigationResolver $relatedNavigationResolver,
private readonly ContextualHelpResolver $contextualHelpResolver,
) {} ) {}
/** /**
@ -69,6 +71,7 @@ public function forTenant(Tenant $tenant, ?User $actor = null): array
contextType: 'tenant', contextType: 'tenant',
workspace: $workspace, workspace: $workspace,
tenant: $tenant, tenant: $tenant,
providerConnection: $providerConnection,
operationRun: $operationRun, operationRun: $operationRun,
headline: 'Support diagnostics for '.$tenant->name, headline: 'Support diagnostics for '.$tenant->name,
dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings), dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings),
@ -109,6 +112,7 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array
contextType: 'operation_run', contextType: 'operation_run',
workspace: $workspace, workspace: $workspace,
tenant: $tenant, tenant: $tenant,
providerConnection: $providerConnection,
operationRun: $run, operationRun: $run,
headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics', headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics',
dominantIssue: (string) data_get( dominantIssue: (string) data_get(
@ -137,6 +141,7 @@ private function bundle(
string $contextType, string $contextType,
?Workspace $workspace, ?Workspace $workspace,
?Tenant $tenant, ?Tenant $tenant,
?ProviderConnection $providerConnection,
?OperationRun $operationRun, ?OperationRun $operationRun,
string $headline, string $headline,
string $dominantIssue, string $dominantIssue,
@ -144,6 +149,7 @@ private function bundle(
): array { ): array {
$sections = $this->sortSections($sections); $sections = $this->sortSections($sections);
$redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers(); $redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers();
$contextualHelp = $this->contextualHelp($tenant, $providerConnection, $operationRun, $sections);
return [ return [
'context_type' => $contextType, 'context_type' => $contextType,
@ -173,6 +179,7 @@ private function bundle(
'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(), 'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(),
'generated_from' => 'derived_existing_truth', 'generated_from' => 'derived_existing_truth',
], ],
'contextual_help' => $contextualHelp,
'sections' => $sections, 'sections' => $sections,
'redaction' => [ 'redaction' => [
'mode' => 'default_redacted', 'mode' => 'default_redacted',
@ -185,6 +192,60 @@ private function bundle(
]; ];
} }
/**
* @param list<array<string, mixed>> $sections
* @return array<string, mixed>|null
*/
private function contextualHelp(
?Tenant $tenant,
?ProviderConnection $providerConnection,
?OperationRun $operationRun,
array $sections,
): ?array {
if (! $tenant instanceof Tenant) {
return null;
}
$reasonCode = $this->supportDiagnosticReasonCode($providerConnection, $operationRun);
$topicKey = $this->contextualHelpResolver->topicKeyForSupportDiagnostics(
reasonCode: $reasonCode,
hasIncompleteEvidence: $this->completenessNote($sections) !== null,
runOutcome: $operationRun instanceof OperationRun && is_string($operationRun->outcome)
? (string) $operationRun->outcome
: null,
);
if ($topicKey === null) {
return null;
}
return $this->contextualHelpResolver->tryResolve($topicKey, [
'tenant' => $tenant,
'connection' => $providerConnection,
'reason_code' => $reasonCode,
'surface' => 'support_diagnostics',
]);
}
private function supportDiagnosticReasonCode(?ProviderConnection $providerConnection, ?OperationRun $operationRun): ?string
{
$providerReasonCode = is_string($providerConnection?->last_error_reason_code)
? trim((string) $providerConnection->last_error_reason_code)
: '';
if ($providerReasonCode !== '') {
return $providerReasonCode;
}
$failureReasonCode = data_get($operationRun?->failure_summary, '0.reason_code');
if (is_string($failureReasonCode) && trim($failureReasonCode) !== '') {
return trim($failureReasonCode);
}
return null;
}
private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection
{ {
return ProviderConnection::query() return ProviderConnection::query()

View File

@ -0,0 +1,80 @@
@php
$help = is_array($help ?? null) ? $help : [];
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
? (string) ($help['headline'])
: 'Contextual help';
$reasonLabel = is_string($help['reason_label'] ?? null) && trim((string) ($help['reason_label'] ?? '')) !== ''
? (string) $help['reason_label']
: null;
$showReasonLabel = $reasonLabel !== null && trim(mb_strtolower($reasonLabel)) !== trim(mb_strtolower($headline));
@endphp
@if ($help !== [])
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/80" data-testid="contextual-help-block">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" size="sm">
Contextual help
</x-filament::badge>
@if ($showReasonLabel)
<x-filament::badge color="gray" size="sm">
{{ $reasonLabel }}
</x-filament::badge>
@endif
</div>
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $headline }}
</div>
<div class="text-sm text-gray-700 dark:text-gray-200">
{{ (string) ($help['short_explanation'] ?? '') }}
</div>
</div>
@if (is_string($help['safe_next_action'] ?? null) && trim((string) ($help['safe_next_action'] ?? '')) !== '')
<x-filament::callout
color="info"
heading="Safe next action"
:description="(string) $help['safe_next_action']"
/>
@endif
@if ($steps !== [])
<ul class="list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
@foreach ($steps as $step)
@if (is_string($step) && trim($step) !== '')
<li>{{ $step }}</li>
@endif
@endforeach
</ul>
@endif
@if ($links !== [])
<div class="flex flex-wrap gap-2">
@foreach ($links as $link)
@php
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
? (string) $link['url']
: null;
@endphp
@if ($linkUrl)
<x-filament::button
tag="a"
:href="$linkUrl"
size="sm"
color="primary"
>
{{ (string) ($link['label'] ?? 'Open') }}
</x-filament::button>
@endif
@endforeach
</div>
@endif
</div>
</div>
@endif

View File

@ -6,6 +6,7 @@
$redactionNotes = is_array($redactionNotes ?? null) $redactionNotes = is_array($redactionNotes ?? null)
? array_values(array_filter($redactionNotes, 'is_string')) ? array_values(array_filter($redactionNotes, 'is_string'))
: []; : [];
$contextualHelp = is_array($contextualHelp ?? null) ? $contextualHelp : null;
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : []; $assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== '' $assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
? trim((string) $assistActionName) ? trim((string) $assistActionName)
@ -14,12 +15,6 @@
? trim((string) $technicalDetailsActionName) ? trim((string) $technicalDetailsActionName)
: 'wizardVerificationTechnicalDetails'; : 'wizardVerificationTechnicalDetails';
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false); $showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
$assistDescription = match ($assistReason) {
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
default => 'Review required permissions without leaving onboarding.',
};
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null; $completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
$completedAtLabel = null; $completedAtLabel = null;
@ -52,7 +47,7 @@
<x-dynamic-component :component="$fieldWrapperView" :field="$field"> <x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4"> <div class="space-y-4">
<x-filament::section <x-filament::section
heading="Verification report" heading="Stored verification details"
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'" :description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'"
> >
@if ($runState === 'no_run') @if ($runState === 'no_run')
@ -113,28 +108,8 @@
</x-filament::button> </x-filament::button>
</div> </div>
@if ($showAssist) @if ($contextualHelp !== null)
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 shadow-sm dark:border-warning-700 dark:bg-warning-950/40"> @include('filament.components.product-knowledge.contextual-help', ['help' => $contextualHelp])
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-warning-950 dark:text-warning-50">
Required permissions assist
</div>
<div class="text-sm text-warning-900 dark:text-warning-100">
{{ $assistDescription }}
</div>
</div>
<x-filament::button
size="sm"
color="warning"
data-testid="verification-assist-trigger"
wire:click="mountAction('{{ $assistActionName }}')"
>
View required permissions
</x-filament::button>
</div>
</div>
@endif @endif
@include('filament.components.verification-report-viewer', [ @include('filament.components.verification-report-viewer', [

View File

@ -66,6 +66,10 @@
</dl> </dl>
</x-filament::section> </x-filament::section>
@if (is_array($bundle['contextual_help'] ?? null))
@include('filament.components.product-knowledge.contextual-help', ['help' => $bundle['contextual_help']])
@endif
@if ($notes !== []) @if ($notes !== [])
<x-filament::section <x-filament::section
heading="Support notes" heading="Support notes"
@ -160,3 +164,4 @@
@endforeach @endforeach
</div> </div>
</div> </div>

View File

@ -1063,7 +1063,8 @@ function createManagedReadinessBlockerDraft(string $state): array
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful() ->assertSuccessful()
->assertSee('Onboarding readiness') ->assertSee('Onboarding readiness')
->assertSee('Current checkpoint') ->assertSee('Step')
->assertDontSee('Current checkpoint')
->assertSee('Verify access') ->assertSee('Verify access')
->assertSee('Verification has not run yet') ->assertSee('Verification has not run yet')
->assertSee('Provider connection') ->assertSee('Provider connection')
@ -1165,28 +1166,46 @@ function createManagedReadinessBlockerDraft(string $state): array
->assertSee($summary) ->assertSee($summary)
->assertSee($nextAction); ->assertSee($nextAction);
})->with([ })->with([
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant consent'], 'missing consent' => ['missing_consent', 'Provider consent required', 'Grant admin consent'],
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant consent'], 'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant admin consent'],
'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'], 'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'],
'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'], 'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'],
]); ]);
it('keeps permission gap diagnostics provider-owned while top-level readiness stays neutral', function (): void { it('keeps permission gap detail out of the top-level page once a verification report is present', function (): void {
[$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap'); [$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap');
$response = $this->actingAs($user) $response = $this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()])) ->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful() ->assertSuccessful()
->assertSee('Permission or consent blocker needs attention') ->assertSee('Permission or consent blocker needs attention')
->assertSee('Permission diagnostics') ->assertDontSee('Permission diagnostics')
->assertSee('Missing application permissions') ->assertSee('Supporting evidence')
->assertSee('View required permissions')
->assertSee('Review permissions'); ->assertSee('Review permissions');
if (is_string($missingKey) && $missingKey !== '') {
$response->assertDontSee($missingKey);
}
$response->assertDontSee('Microsoft Graph readiness');
});
it('shows permission diagnostics as a fallback when no verification report is present', function (): void {
[$user, $draft] = createManagedReadinessBlockerDraft('missing_consent');
$tenant = $draft->tenant()->firstOrFail();
$missingKey = seedManagedReadinessPermissions($tenant);
$response = $this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Permission diagnostics')
->assertDontSee('Supporting evidence');
if (is_string($missingKey) && $missingKey !== '') { if (is_string($missingKey) && $missingKey !== '') {
$response->assertSee($missingKey); $response->assertSee($missingKey);
} }
$response->assertDontSee('Microsoft Graph readiness');
}); });
it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void { it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void {

View File

@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: User, 1: Tenant, 2: TenantOnboardingSession}
*/
function createProductKnowledgeOnboardingDraft(string $state, string $workspaceRole = 'owner', string $tenantRole = 'owner'): array
{
$tenant = Tenant::factory()->onboarding()->create();
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: $tenantRole,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
$verificationConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Verification connection',
'is_default' => true,
'consent_status' => 'granted',
]);
$selectedConnection = $verificationConnection;
$checks = [];
$outcome = OperationRunOutcome::Blocked->value;
if ($state === 'admin_consent') {
$checks[] = [
'key' => 'permissions.admin_consent',
'title' => 'Admin consent',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'message' => 'Admin consent is required before verification can proceed.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'required_permissions') {
$checks[] = [
'key' => 'permissions.required',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'Missing required application permissions.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'connection_unhealthy') {
$checks[] = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderAuthFailed,
'message' => 'Stored provider credentials are no longer valid.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'verification_stale') {
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'dummy',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Currently selected connection',
'is_default' => false,
'consent_status' => 'granted',
]);
$checks[] = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
];
$outcome = OperationRunOutcome::Succeeded->value;
} elseif ($state === 'verification_failed') {
$checks[] = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => '',
'message' => 'Verification failed after the prerequisite checks ran.',
'evidence' => [],
'next_steps' => [],
];
$outcome = OperationRunOutcome::Failed->value;
}
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => $outcome,
'context' => [
'provider_connection_id' => (int) $verificationConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', $checks),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'entra_tenant_id' => (string) $tenant->tenant_id,
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $selectedConnection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$user, $tenant, $draft];
}
it('renders onboarding contextual help for each in-scope verification topic', function (
string $state,
string $headline,
string $safeNextAction,
?string $linkLabel,
): void {
[$user, , $draft] = createProductKnowledgeOnboardingDraft($state);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $user->last_workspace_id])
->followingRedirects()
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$response->assertSuccessful()
->assertSee('Verification report')
->assertSee('Stored verification details')
->assertSee($headline)
->assertDontSee('Permission diagnostics')
->assertSee($safeNextAction);
$dom = new \DOMDocument();
@$dom->loadHTML($response->getContent());
$xpath = new \DOMXPath($dom);
$headlineNodes = $xpath->query(sprintf(
'//*[@data-testid="contextual-help-block"]//*[normalize-space(text())="%s"]',
$headline,
));
$storedVerificationDetailsHeadings = $xpath->query(
'//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Stored verification details"]',
);
$verificationReportHeadings = $xpath->query(
'//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Verification report"]',
);
expect($headlineNodes?->length)->toBe(1);
expect($storedVerificationDetailsHeadings?->length)->toBe(1);
expect($verificationReportHeadings?->length)->toBeLessThanOrEqual(1);
if ($state === 'admin_consent') {
$primaryNextActionNode = $xpath->query(
'//*[normalize-space(text())="Primary next action"]/following::*[(self::a or self::button) and normalize-space(text())!=""][1]',
);
expect(trim((string) $primaryNextActionNode?->item(0)?->textContent))->toContain('Grant admin consent');
}
if ($linkLabel !== null) {
$response->assertSee($linkLabel);
}
})->with([
'admin consent required' => [
'admin_consent',
'Admin consent required',
'Grant admin consent and re-run verification.',
'Grant admin consent',
],
'required permissions missing' => [
'required_permissions',
'Required permissions missing',
'Open required permissions and confirm the missing grants.',
'Open required permissions',
],
'connection unhealthy' => [
'connection_unhealthy',
'Provider connection needs review',
'Review the provider connection before retrying.',
null,
],
'verification stale' => [
'verification_stale',
'Verification result is stale',
'Refresh verification before continuing onboarding.',
null,
],
'verification failed' => [
'verification_failed',
'Verification failed',
'Review the blocking reason and retry verification.',
null,
],
]);
it('keeps onboarding contextual help deny-as-not-found for workspace members outside the tenant scope', function (): void {
[$authorizedUser, $tenant, $draft] = createProductKnowledgeOnboardingDraft('admin_consent');
$workspace = $tenant->workspace()->firstOrFail();
$outOfScopeUser = User::factory()->create();
WorkspaceMembership::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $outOfScopeUser->getKey(),
'role' => 'owner',
]);
$this->actingAs($outOfScopeUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function productKnowledgeSupportDiagnosticsTenantAuthorizationComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function productKnowledgeSupportDiagnosticsOperationAuthorizationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('keeps tenant support diagnostics contextual help deny-as-not-found for workspace members without tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
it('returns forbidden for entitled run viewers without support diagnostics capability when requesting the contextual-help bundle', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run)
->assertActionVisible('openSupportDiagnostics')
->assertActionDisabled('openSupportDiagnostics')
->call('operationRunSupportDiagnosticBundle')
->assertForbidden();
});
it('omits support diagnostics contextual help when the dominant issue does not map to a catalog topic', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Fallback Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$connection = ProviderConnection::factory()
->withCredential()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Fallback connection',
'last_error_reason_code' => 'ext.support.manual_lookup_needed',
'last_health_check_at' => now()->subMinutes(5),
]);
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'completed_at' => now()->subMinutes(3),
]);
productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run)
->mountAction('openSupportDiagnostics')
->assertMountedActionModalDontSee('Contextual help')
->assertMountedActionModalDontSee('ext.support.manual_lookup_needed');
});
it('keeps operation-run support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function productKnowledgeTenantSupportDiagnosticsComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function productKnowledgeOperationSupportDiagnosticsComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
/**
* @return array{0: User, 1: Tenant, 2: OperationRun}
*/
function createProductKnowledgeSupportDiagnosticScenario(string $state): array
{
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$reasonCode = match ($state) {
'admin_consent' => ProviderReasonCodes::ProviderConsentMissing,
'required_permissions' => ProviderReasonCodes::ProviderPermissionMissing,
'connection_unhealthy' => ProviderReasonCodes::ProviderAuthFailed,
'retryable_provider_failure' => ProviderReasonCodes::RateLimited,
'manual_handoff_required' => ProviderReasonCodes::UnknownError,
default => null,
};
$connection = $reasonCode !== null
? ProviderConnection::factory()->withCredential()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Contoso Microsoft connection',
'verification_status' => $reasonCode === ProviderReasonCodes::UnknownError
? ProviderVerificationStatus::Blocked->value
: ProviderVerificationStatus::Healthy->value,
'last_error_reason_code' => $reasonCode,
'last_health_check_at' => now()->subMinutes(15),
])
: null;
$runOutcome = match ($state) {
'verification_failed', 'manual_handoff_required' => OperationRunOutcome::Failed->value,
default => OperationRunOutcome::Succeeded->value,
};
$failureSummary = match ($state) {
'verification_failed' => [[
'message' => 'The operation failed and needs follow-up.',
]],
'manual_handoff_required' => [[
'message' => 'A human support handoff is required for the next step.',
'reason_code' => ProviderReasonCodes::UnknownError,
]],
default => [],
};
$context = [];
if ($connection instanceof ProviderConnection) {
$context['provider_connection_id'] = (int) $connection->getKey();
}
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => $runOutcome,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'context' => $context,
'failure_summary' => $failureSummary,
'completed_at' => now()->subMinutes(10),
]);
return [$user, $tenant, $run];
}
it('renders shared product knowledge in tenant support diagnostics', function (
string $state,
string $headline,
string $safeNextAction,
?string $linkLabel,
): void {
[$user, $tenant] = createProductKnowledgeSupportDiagnosticScenario($state);
$component = productKnowledgeTenantSupportDiagnosticsComponent($user, $tenant);
$component->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Contextual help')
->assertMountedActionModalSee($headline)
->assertMountedActionModalSee($safeNextAction);
if ($linkLabel !== null) {
$component->assertMountedActionModalSee($linkLabel);
}
})->with([
'tenant admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'],
'tenant required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'],
'tenant connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null],
'tenant verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null],
'tenant diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null],
'tenant retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null],
'tenant manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null],
]);
it('renders shared product knowledge in operation support diagnostics', function (
string $state,
string $headline,
string $safeNextAction,
?string $linkLabel,
): void {
[$user, , $run] = createProductKnowledgeSupportDiagnosticScenario($state);
$component = productKnowledgeOperationSupportDiagnosticsComponent($user, $run);
$component->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Contextual help')
->assertMountedActionModalSee($headline)
->assertMountedActionModalSee($safeNextAction);
if ($linkLabel !== null) {
$component->assertMountedActionModalSee($linkLabel);
}
})->with([
'operation admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'],
'operation required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'],
'operation connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null],
'operation verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null],
'operation diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null],
'operation retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null],
'operation manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null],
]);

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
it('exposes the locked first-slice topic catalog and safe knowledge source', function (): void {
$catalog = new ContextualHelpCatalog();
expect($catalog->keys())->toBe([
ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING,
ContextualHelpCatalog::CONNECTION_UNHEALTHY,
ContextualHelpCatalog::VERIFICATION_STALE,
ContextualHelpCatalog::VERIFICATION_FAILED,
ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE,
ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE,
ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED,
])->and($catalog->definition(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([
'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Admin consent required',
'safe_next_action' => 'Grant admin consent and re-run verification.',
]);
$knowledgeSource = $catalog->knowledgeSource();
$topicsByKey = collect($knowledgeSource['topics'])->keyBy('topic_key');
expect($knowledgeSource)->toMatchArray([
'version' => 1,
'topic_count' => 8,
])->and($topicsByKey->keys()->all())->toBe($catalog->keys())
->and($topicsByKey->get(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([
'headline' => 'Admin consent required',
'docs_links' => [
[
'label' => 'Grant admin consent',
'kind' => 'action',
'url' => null,
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
],
[
'label' => 'Admin consent guide',
'kind' => 'docs',
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
'resolver' => null,
],
],
]);
});
it('rejects unknown contextual help topics', function (): void {
$catalog = new ContextualHelpCatalog();
expect(fn (): array => $catalog->definition('unknown-topic'))
->toThrow(InvalidArgumentException::class, 'Unknown contextual help topic');
});
it('keeps every machine-readable topic on the approved metadata surface only', function (): void {
$knowledgeSource = app(\App\Support\ProductKnowledge\ContextualHelpResolver::class)->knowledgeSource();
$allowedResolvers = [
null,
ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
];
foreach ($knowledgeSource['topics'] as $topic) {
expect(array_keys($topic))->toBe([
'topic_key',
'surface_families',
'headline',
'short_explanation',
'troubleshooting_steps',
'safe_next_action',
'glossary_terms',
'docs_links',
]);
foreach ($topic['docs_links'] as $link) {
expect(array_keys($link))->toBe(['label', 'kind', 'url', 'resolver'])
->and($link['resolver'])->toBeIn($allowedResolvers);
}
}
});

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderReasonCodes;
it('returns null for blank or unknown topic keys through the fallback contract', function (): void {
$resolver = app(ContextualHelpResolver::class);
expect($resolver->tryResolve(null))->toBeNull()
->and($resolver->tryResolve(''))->toBeNull()
->and($resolver->tryResolve('unknown-topic'))->toBeNull();
});
it('keeps dynamic link metadata but no tenant-specific url when tenant context is unavailable', function (): void {
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING);
expect($payload['docs_links'][0])->toMatchArray([
'label' => 'Open required permissions',
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
'url' => null,
]);
});
it('exposes a safe machine-readable knowledge source without tenant or secret fields', function (): void {
$knowledgeSource = app(ContextualHelpResolver::class)->knowledgeSource();
$encoded = json_encode($knowledgeSource, JSON_THROW_ON_ERROR);
expect($encoded)->not->toContain('tenant_id')
->not->toContain('provider_connection_id')
->not->toContain('raw_response_body')
->not->toContain('credential')
->and($knowledgeSource['topic_count'])->toBe(8);
});
it('keeps explicit unmapped support-diagnostics reason codes on the null fallback path', function (): void {
$resolver = app(ContextualHelpResolver::class);
expect($resolver->topicKeyForSupportDiagnostics(
reasonCode: 'ext.support.manual_lookup_needed',
hasIncompleteEvidence: true,
runOutcome: null,
))->toBeNull()
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: ProviderReasonCodes::UnknownError,
hasIncompleteEvidence: true,
runOutcome: null,
))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED);
});

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OperationRunOutcome;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function contextualHelpTruthEnvelope(Tenant $tenant): ArtifactTruthEnvelope
{
return new ArtifactTruthEnvelope(
artifactFamily: 'support_diagnostics',
artifactKey: 'tenant_support_diagnostics',
workspaceId: (int) $tenant->workspace_id,
tenantId: (int) $tenant->getKey(),
executionOutcome: 'blocked',
artifactExistence: 'created',
contentState: 'partial',
freshnessState: 'current',
publicationReadiness: null,
supportState: 'supported',
actionability: 'required',
primaryLabel: 'Verification blocked',
primaryExplanation: 'Verification cannot continue until the prerequisite is resolved.',
diagnosticLabel: 'Admin consent required',
nextActionLabel: 'Retry verification',
nextActionUrl: null,
relatedRunId: null,
relatedArtifactUrl: null,
);
}
it('resolves contextual help with reason translation, operator summary, and tenant-aware links', function (): void {
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, [
'tenant' => $tenant,
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'artifact_truth' => contextualHelpTruthEnvelope($tenant),
]);
expect($payload)->toMatchArray([
'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
'headline' => 'Admin consent required',
'short_explanation' => 'The provider connection cannot continue until admin consent is granted.',
'safe_next_action' => 'Retry verification',
'reason_label' => 'Admin consent required',
'diagnostic_code' => ProviderReasonCodes::ProviderConsentMissing,
])->and($payload['docs_links'][0])->toMatchArray([
'label' => 'Grant admin consent',
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
])->and($payload['operator_summary'])->toMatchArray([
'nextActionText' => 'Retry verification',
'diagnosticsAvailable' => true,
]);
});
it('resolves required permissions links against the current tenant', function (): void {
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING, [
'tenant' => $tenant,
]);
expect($payload['docs_links'][0])->toMatchArray([
'label' => 'Open required permissions',
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
]);
});
it('maps support diagnostics topics from shared reason and outcome signals', function (): void {
$resolver = app(ContextualHelpResolver::class);
expect($resolver->topicKeyForSupportDiagnostics(
reasonCode: ProviderReasonCodes::RateLimited,
hasIncompleteEvidence: false,
runOutcome: null,
))->toBe(ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE)
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: ProviderReasonCodes::UnknownError,
hasIncompleteEvidence: false,
runOutcome: OperationRunOutcome::Failed->value,
))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED)
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: null,
hasIncompleteEvidence: false,
runOutcome: OperationRunOutcome::Failed->value,
))->toBe(ContextualHelpCatalog::VERIFICATION_FAILED)
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: null,
hasIncompleteEvidence: true,
runOutcome: null,
))->toBe(ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE);
});

View File

@ -0,0 +1,42 @@
# Specification Quality Checklist: Product Knowledge & Contextual Help
**Purpose**: Validate specification completeness and quality before proceeding to implementation planning
**Created**: 2026-04-26
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Business value and operator outcomes stay explicit
- [x] Implementation anchors are intentional and bounded to existing repo surfaces
- [x] Runtime-governance sections are present for an implementation-ready spec package
- [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] Acceptance scenarios are defined for the primary user journeys
- [x] Edge cases are identified
- [x] Scope is clearly bounded to onboarding and support-diagnostic surface families plus one internal machine-readable knowledge source deliverable
- [x] Dependencies and assumptions are identified
## Feature Readiness
- [x] The first slice is small enough for a bounded implementation loop
- [x] The plan identifies the concrete repo surfaces likely to change
- [x] The tasks are ordered, testable, and grouped by user story
- [x] No unresolved product question blocks safe implementation of the first slice
## Governance Readiness
- [x] No new persistence is introduced without justification
- [x] Provider-boundary handling and glossary reuse are explicit
- [x] Existing RBAC and tenant/workspace isolation remain authoritative
- [x] Operator-facing surface changes include the required UI contract sections
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, no destructive-action additions, and no asset-strategy changes are explicit in the package
## Notes
- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, and `tasks.md`.
- The active slice stays bounded to one code-owned help catalog, one resolver, two adopted surface families, and one safe machine-readable knowledge source.

View File

@ -0,0 +1,199 @@
# Implementation Plan: Product Knowledge & Contextual Help
**Branch**: `244-product-knowledge-contextual-help` | **Date**: 2026-04-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Add a bounded `ProductKnowledge` support namespace with one code-owned contextual-help catalog and one resolver that derive help payloads from existing glossary, reason-translation, operator-explanation, and docs-link helpers.
- Adopt that resolver on two existing high-value surfaces only: `ManagedTenantOnboardingWizard` and the support-diagnostic bundle used by tenant and operation-context previews.
- Expose the same catalog as a safe machine-readable knowledge source for later internal AI/support use, while keeping the slice read-only, non-persistent, Livewire v4-compatible, and free of panel/provider/global-search/asset changes.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `SupportDiagnosticBundleBuilder`, `RequiredPermissionsLinks`, `ProviderReasonTranslator`, and `ManagedTenantOnboardingWizard`
**Storage**: N/A - no new database or persisted product-knowledge truth
**Testing**: Pest unit + feature tests only
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Sail-backed Laravel admin panel under `/admin` and existing support-diagnostic previews in tenant and operation contexts
**Project Type**: web
**Performance Goals**: in-memory topic lookup only, no new remote calls during render, and no extra persistence or background work for the first slice
**Constraints**: no new database table, no public docs site, no chatbot, no localization overhaul, no new global-search resource, no panel provider changes, no new Filament assets, and no direct feature-level AI execution
**Scale/Scope**: 8 canonical first-slice help topics across onboarding and support diagnostics, 1 code-owned catalog, 1 resolver, 1 machine-readable knowledge source, and focused adoption on 2 existing operator surface families
## First-Slice Topic Inventory
The implementation is locked to these eight canonical topic keys for the first slice:
- `admin-consent-required`
- `required-permissions-missing`
- `connection-unhealthy`
- `verification-stale`
- `verification-failed`
- `diagnostic-evidence-incomplete`
- `retryable-provider-failure`
- `manual-handoff-required`
Any change to this topic inventory requires an explicit spec update before implementation expands or swaps the slice.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament + shared diagnostics data
- **Shared-family relevance**: status messaging, docs links, troubleshooting guidance, support-diagnostic summaries
- **State layers in scope**: page, workflow step, detail reveal, action preview, diagnostic section detail
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, `App\Support\Governance\PlatformVocabularyGlossary`, `App\Support\ReasonTranslation\ReasonPresenter`, `App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder`, `App\Support\Providers\ProviderReasonTranslator`, and `App\Support\Links\RequiredPermissionsLinks`
- **Shared abstractions reused**: glossary classification, reason envelopes, operator-explanation patterns, support-diagnostic section assembly, and existing provider docs-link helpers
- **New abstraction introduced? why?**: one bounded contextual-help catalog plus one resolver are justified because the repo has truthful status and glossary primitives already, but it has no shared product-knowledge layer with stable topic keys, troubleshooting guidance, or machine-readable knowledge source
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions explain current state, but not reusable contextual help. They are sufficient as inputs and insufficient as the final cross-surface help contract.
- **Bounded deviation / spread control**: no page-local help registries, no second glossary, and no product-knowledge persistence. Provider-specific remediation remains bounded to provider-owned topic entries and existing link helpers only.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: N/A
- **Delegated UX behaviors**: N/A
- **Surface-owned behavior kept local**: N/A
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: `RequiredPermissionsLinks`, provider-specific consent/permission guidance, and `ProviderReasonTranslator`-backed help topics
- **Platform-core seams**: contextual-help topic IDs, glossary mapping, onboarding help rendering, support-diagnostic help rendering, and the machine-readable catalog export
- **Neutral platform terms / contracts preserved**: contextual help, help topic, troubleshooting guidance, next action, support diagnostics, readiness guidance
- **Retained provider-specific semantics and why**: Microsoft admin consent and required-permissions steps remain provider-specific because those remediation paths are concrete current-release truth rather than speculative portability work
- **Bounded extraction or follow-up path**: `document-in-feature` for future localization compatibility; no broader follow-up is required for the first slice
## Constitution Check
*GATE: Must pass before implementation begins. Re-check after design changes.*
- Inventory-first / snapshots-second: PASS - contextual help is derived from existing truth only and does not become a new system-of-record
- Read/write separation: PASS - the slice is read-only guidance only and introduces no new mutation flow
- Graph contract path: PASS - the feature adds no new Microsoft Graph calls
- RBAC-UX / workspace isolation / tenant isolation: PASS - existing onboarding, tenant, and operation-view entitlements stay authoritative and contextual help resolves only after host-surface authorization succeeds
- Shared pattern reuse / `XCUT-001`: PASS - the design explicitly extends glossary, reason, operator-explanation, support-diagnostic, and existing link helpers instead of adding local help prose paths
- Proportionality / `PROP-001` and `ABSTR-001`: PASS - one bounded catalog and one resolver are the narrowest reusable shape that avoids page-local drift
- Persisted truth / `PERSIST-001`: PASS - no new persistence is introduced
- UI semantics / `UI-SEM-001`: PASS - the feature adds progressive disclosure help only and does not replace the host surface's truth model
- Filament-native UI / `UI-FIL-001`: PASS - onboarding and preview hosts remain native Filament/shared surfaces; no bespoke status cards or asset changes are planned
- Livewire v4 / Filament v5: PASS - the feature remains fully within the existing Filament v5 + Livewire v4 stack and requires no provider registration changes beyond the current `bootstrap/providers.php` location
- Global search rule: N/A - no new resource or global-search configuration is introduced
- Destructive actions: PASS - no new destructive action is introduced; existing confirmations remain unchanged
- Asset strategy: PASS - no new global or on-demand assets are planned, so `filament:assets` deployment behavior is unchanged
- Test governance / `TEST-GOV-001`: PASS - proof remains in focused unit + feature tests only
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for catalog shape, resolver behavior, and fallback/export safety; Feature for onboarding help rendering, support-diagnostic help rendering, and authorization-safe degradation
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and view-model oriented; browser automation would duplicate what focused unit and feature tests can already prove
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, onboarding draft, tenant, provider connection, operation run, and support-diagnostic fixtures; avoid new browser or provider-emulator defaults
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native-filament relief for onboarding plus one monitoring-state-page regression for the operation-context support-diagnostic host
- **Closing validation and reviewer handoff**: reviewers should verify registry-backed help only, progressive disclosure, glossary alignment, authorization-safe link resolution, graceful fallback on missing topics, and zero panel/provider/asset/global-search drift
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
- **Review-stop questions**: did the implementation add page-local help prose, new persistence, or new AI execution; do missing topics fail gracefully; do help links stay entitlement-safe?
- **Escalation path**: `reject-or-split` if the implementation grows into a public docs platform, localization rewrite, or AI execution feature; otherwise changes stay inside this feature
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the first slice is intentionally narrow and can land independently before broader localization, support, or AI work
## Project Structure
### Documentation (this feature)
```text
specs/244-product-knowledge-contextual-help/
├── checklists/
│ └── requirements.md
├── spec.md
├── plan.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── Operations/TenantlessOperationRunViewer.php
│ │ │ ├── TenantDashboard.php
│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php
│ │ └── Support/
│ ├── Support/
│ │ ├── Governance/PlatformVocabularyGlossary.php
│ │ ├── Links/RequiredPermissionsLinks.php
│ │ ├── ProductKnowledge/
│ │ │ ├── ContextualHelpCatalog.php
│ │ │ └── ContextualHelpResolver.php
│ │ ├── ReasonTranslation/ReasonPresenter.php
│ │ ├── SupportDiagnostics/SupportDiagnosticBundleBuilder.php
│ │ └── Ui/OperatorExplanation/OperatorExplanationBuilder.php
│ └── Services/
└── tests/
├── Unit/Support/ProductKnowledge/
│ ├── ContextualHelpCatalogTest.php
│ ├── ContextualHelpResolverTest.php
│ └── ContextualHelpFallbackTest.php
└── Feature/
├── Onboarding/ProductKnowledgeOnboardingHelpTest.php
└── SupportDiagnostics/
├── ProductKnowledgeAuthorizationTest.php
└── ProductKnowledgeSupportDiagnosticHelpTest.php
```
**Structure Decision**: Single Laravel web application. The implementation adds one small support namespace and adopts it on existing onboarding and support-diagnostic surfaces only.
## Complexity Tracking
No constitution violations are required. The only new structure is the explicitly justified code-owned contextual-help catalog plus resolver.
## Proportionality Review
- **Current operator problem**: operators still need founder explanation or scattered docs to interpret onboarding blockers and support-diagnostic dominant issues safely
- **Existing structure is insufficient because**: the repo has truthful glossary, reason, and diagnostic primitives but no versioned, reusable product-knowledge layer
- **Narrowest correct implementation**: one code-owned catalog plus one resolver reused by onboarding and support diagnostics only
- **Ownership cost created**: topic keys, docs-link mappings, fallback behavior, and focused tests
- **Alternative intentionally rejected**: page-local prose, public docs platform, CMS/editor, or AI execution layer
- **Release truth**: current-release truth
## Rollout & Risk Controls
- Start with onboarding guidance and support diagnostics only. Any third adoption surface requires explicit scope review.
- Keep help blocks progressive and subordinate to the host surface's existing status or diagnostic truth.
- Use only approved docs-link helpers or stable URLs for the first slice. No free-text or user-authored help content is allowed.
- Keep the machine-readable knowledge source internal and code-owned. No runtime AI invocation or customer-facing knowledge export is part of this slice.
## Implementation Outline
- Add `App\Support\ProductKnowledge\ContextualHelpCatalog` and `ContextualHelpResolver` as the single shared path for first-slice help topics.
- Integrate onboarding help topic selection inside `ManagedTenantOnboardingWizard` using the existing readiness, permission, and verification signals already present on the page.
- Integrate contextual help into `SupportDiagnosticBundleBuilder` so tenant and operation-context previews render the same help payload from the same topic keys.
- Expose a safe machine-readable knowledge-source method from the catalog or resolver and add focused unit + feature coverage for rendering, authorization, and fallback.
## Constitution Check (Post-Design)
Re-check result: PASS. The plan stays bounded to one code-owned catalog and one resolver, reuses existing glossary/reason/support primitives, adds no new persistence, keeps Filament v5 / Livewire v4 unchanged, leaves provider registration in `bootstrap/providers.php` untouched, introduces no global-search or asset changes, and keeps proof in narrow unit + feature coverage only.

View File

@ -0,0 +1,283 @@
# Feature Specification: Product Knowledge & Contextual Help
**Feature Branch**: `244-product-knowledge-contextual-help`
**Created**: 2026-04-26
**Status**: Ready for implementation
**Input**: User description: "Promote the roadmap-fit candidate Product Knowledge & Contextual Help as a narrow, implementation-ready slice that introduces a code-owned contextual help contract for operator-facing guidance on existing onboarding and diagnostics surfaces, reuses glossary and reason-translation foundations, and stops before AI, chatbot, or public docs platform scope."
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Operators still need founder explanation when onboarding blockers, support-diagnostic summaries, or reason-translated states explain what happened but not the safest next step, the relevant documentation, or the surrounding product meaning.
- **Today's failure**: Tenant onboarding and support-oriented diagnostic surfaces already expose truthful status and reason signals, but help remains scattered across local copy, existing docs knowledge, or founder memory. That slows onboarding, increases support load, and leaves later AI-assisted support without a trusted product-knowledge source.
- **User-visible improvement**: Operators see contextual help on two high-value existing surfaces with canonical terminology, troubleshooting hints, and documentation links that match the current issue without replacing the underlying truth or opening raw diagnostics first.
- **Smallest enterprise-capable version**: Add one code-owned contextual help catalog plus one resolver that reuses the existing glossary, reason-translation, operator-explanation, and required-permissions link helpers for two adoption surfaces only: the managed-tenant onboarding workflow and the support-diagnostic bundle in tenant and operation contexts.
- **Explicit non-goals**: No public docs site, no AI chatbot, no broad CMS/editor workflow, no complete localization overhaul, no customer-facing help center, no rewrite of every operator surface, and no new persisted product-knowledge table.
- **Permanent complexity imported**: One bounded `ProductKnowledge` support namespace, one catalog of stable help topic keys, one resolver/presenter path, one machine-readable source export for later AI/support use, and focused unit plus feature tests.
- **Why now**: The repo already has `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `ManagedTenantOnboardingWizard`, and `SupportDiagnosticBundleBuilder`. Product Knowledge is the smallest next slice that makes onboarding and support less founder-dependent while preparing a safe knowledge source for later AI-assisted support.
- **Why not local**: Local page copy would duplicate glossary and reason semantics, drift across onboarding and diagnostics surfaces, and fail to produce one reviewable, versioned, machine-readable product-knowledge source.
- **Approval class**: Workflow Compression
- **Red flags triggered**: New meta-infrastructure and a foundation-sounding theme. Defense: the slice stays bounded to two existing adoption surfaces, introduces no new persistence, and reuses existing glossary/reason/support primitives rather than inventing a generic knowledge platform.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/onboarding`
- `/admin/onboarding/{onboardingDraft}`
- existing tenant support-diagnostics entry points on `/admin/t/{tenant}`
- existing canonical operation detail support-diagnostics entry points on `/admin/operations/{run}`
- **Data Ownership**:
- No new database table or persisted product-knowledge entity is introduced.
- The contextual help catalog remains code-owned, reviewable, and versioned in the repository.
- Source truth remains on `PlatformVocabularyGlossary`, `ReasonResolutionEnvelope`, `OperatorExplanationPattern`, `SupportDiagnosticBundleBuilder`, and existing route/link helpers.
- Any machine-readable knowledge source exported by the feature is derived from the code-owned catalog and MUST exclude customer content, provider payloads, and secrets.
- **RBAC**:
- This slice introduces no new capability family.
- Existing onboarding authorization remains authoritative for `/admin/onboarding` and the managed-tenant onboarding draft flow.
- Existing support-diagnostics and operation-view entitlement checks remain authoritative for tenant and operation diagnostic entry points.
- Non-members or wrong-scope actors continue to receive 404. In-scope actors lacking the existing capability continue to receive 403. Help resolution never runs before those scope checks pass.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical list or queue. It annotates existing onboarding and diagnostic detail surfaces only.
- **Explicit entitlement checks preventing cross-tenant leakage**: Contextual help is resolved only after the host surface has already resolved workspace and tenant entitlement. Help topics may reference only routes, documents, and next steps the current actor is already entitled to see.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, supporting docs links, troubleshooting guidance, support-diagnostic summaries, onboarding next-step guidance
- **Systems touched**: `ManagedTenantOnboardingWizard`, `SupportDiagnosticBundleBuilder`, `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, `ProviderReasonTranslator`, and `RequiredPermissionsLinks`
- **Existing pattern(s) to extend**: canonical glossary terms, reason-translation envelopes, operator-explanation summaries, support-diagnostic section assembly, and existing required-permissions/admin-consent link helpers
- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\Governance\PlatformVocabularyGlossary`, `App\Support\ReasonTranslation\ReasonPresenter`, `App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, and `App\Support\Links\RequiredPermissionsLinks`
- **Why the existing shared path is sufficient or insufficient**: existing shared paths already provide truthful labels, diagnostic summaries, glossary boundaries, and remediation links, but they do not provide one reviewable cross-surface product-knowledge layer with stable help topic keys, progressive disclosure copy, or a machine-readable knowledge source.
- **Allowed deviation and why**: provider-specific consent and required-permissions guidance may remain inside provider-owned help topics because the concrete remediation path is still Microsoft-specific in the current release.
- **Consistency impact**: topic keys, help headings, glossary nouns, troubleshooting steps, and docs links must stay aligned across onboarding and support diagnostics so the same state does not produce competing explanations.
- **Review focus**: reviewers must block page-local contextual-help prose that bypasses the shared catalog and must confirm that help copy stays derived from glossary/reason/support truth rather than becoming a second semantic source of truth.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: N/A - the slice annotates existing onboarding and support-diagnostic surfaces only and does not change how runs are started, linked, or messaged.
- **Delegated start/completion UX behaviors**: N/A
- **Local surface-owned behavior that remains**: N/A
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**: provider permission/consent guidance, provider reason translation reuse, glossary-backed terminology, support-diagnostic guidance, and documentation link resolution
- **Neutral platform terms preserved or introduced**: contextual help, help topic, troubleshooting guidance, next action, support diagnostics, readiness, operator guidance
- **Provider-specific semantics retained and why**: Microsoft admin consent and required-permissions guidance remain provider-owned because those remediation steps still require exact provider terminology and URLs in the current release.
- **Why this does not deepen provider coupling accidentally**: provider-specific help remains attached to provider-owned topics and existing provider link helpers. The top-level catalog, topic IDs, glossary references, and host-surface contracts remain platform-neutral.
- **Follow-up path**: `document-in-feature` for the Platform Localization v1 dependency boundary; no follow-up spec is required for the bounded first slice itself.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Managed tenant onboarding workflow | yes | Native Filament + shared primitives | status messaging, docs links, readiness guidance | page, workflow step, detail reveal | no | Adds contextual help beside existing readiness and verification signals only |
| Tenant dashboard support-diagnostic preview | yes | Native Filament action + shared diagnostics bundle | diagnostic summaries, docs links, troubleshooting guidance | action preview, diagnostic section detail | no | Help enriches the existing derived bundle instead of creating a second support surface |
| Operation detail support-diagnostic preview | yes | Native Filament detail action + shared diagnostics bundle | diagnostic summaries, docs links, troubleshooting guidance | detail, action preview, diagnostic section detail | no | Reuses the same help-resolution path as tenant diagnostics with operation-context inputs |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Managed tenant onboarding workflow | Primary Decision Surface | Decide what blocker to resolve next so onboarding can continue safely | current blocker meaning, one help headline, one safe next step, and one supporting docs link when relevant | full verification detail, provider-specific evidence, operation detail, and raw diagnostics | Primary because the operator is already in the guided setup workflow and needs help in that context | Follows the identify-connect-verify-complete onboarding workflow | Removes the need to switch to founder memory or separate documentation to interpret the blocker |
| Tenant dashboard support-diagnostic preview | Secondary Context Surface | Decide how to troubleshoot or escalate a tenant issue from one support-safe summary | dominant issue meaning, contextual help headline, troubleshooting hints, and safe next step | full bundle sections, related records, and diagnostic evidence | Secondary because support diagnostics remain a follow-up to tenant work, not the primary workflow | Follows tenant troubleshooting and escalation flow | Reduces cross-page reconstruction and repeated explanation work |
| Operation detail support-diagnostic preview | Tertiary Evidence / Diagnostics Surface | Decide what the current run outcome means before drilling deeper or escalating | run summary meaning, contextual help headline, troubleshooting hints, and safe next step | canonical run detail, related records, and provider diagnostics | Tertiary because the surface is already evidence-first and the help layer should remain progressive disclosure | Follows monitoring and support drill-in flow | Makes the existing diagnostic surface more self-explanatory without turning it into a new queue |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Managed tenant onboarding workflow | Workflow / Guided action entry | Guided onboarding / readiness workflow | Resolve the blocker or continue to the next checkpoint | In-page readiness and contextual-help section on the current draft route | forbidden | Supporting docs and diagnostics stay inside the section reveal | Existing destructive draft actions remain in the header only | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context, linked tenant, provider readiness summary, help topic scope | Onboarding / Onboarding guidance | Blocker meaning, safe next step, and supporting docs link where applicable | guided-workflow exception already inherent to the onboarding wizard |
| Tenant dashboard support-diagnostic preview | Dashboard / Overview / Actions | Tenant troubleshooting support entry point | Open support diagnostics and follow the documented next troubleshooting step | Explicit support-diagnostics action opens the read-only preview | forbidden | Related record links and docs links remain inside the preview | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | Active workspace, active tenant, help topic scope, bundle freshness | Support diagnostics / Diagnostic help | Dominant issue meaning, troubleshooting guidance, and supporting docs links | dashboard-action entry point only; help remains read-only |
| Operation detail support-diagnostic preview | Record / Detail / Actions | Canonical diagnostic detail support entry point | Open support diagnostics and follow the documented next troubleshooting step | Existing operation detail plus one explicit support-diagnostics action | forbidden | Related record links and docs links remain inside the preview | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, tenant context when present, help topic scope, bundle freshness | Support diagnostics / Diagnostic help | Dominant issue meaning, troubleshooting guidance, and supporting docs links | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Managed tenant onboarding workflow | Workspace operator managing tenant setup | Decide what the current blocker means and what action to take next | Guided workflow | What does this blocker mean, and what should I do next? | readiness headline, blocker explanation, one safe next action, one docs link where relevant, and current checkpoint context | provider-specific evidence, full verification report, operation detail, low-level identifiers | readiness, data freshness, provider health, operator actionability | Existing onboarding actions keep their current scope; the help layer itself is read-only | Continue onboarding, open docs link, open supporting diagnostics | Existing cancel/delete draft actions remain unchanged |
| Tenant dashboard support-diagnostic preview | Support-capable tenant operator or manager | Decide whether to troubleshoot, hand off, or escalate a tenant issue | Read-only preview | What does the current tenant issue mean, and which documented next step is safest? | dominant issue explanation, contextual help headline, troubleshooting steps, and docs links | full support bundle sections, related records, provider diagnostics, audit references | execution outcome, provider health, findings pressure, guidance actionability | none | Open support diagnostics, open docs link, open related records | none |
| Operation detail support-diagnostic preview | Support-capable operator | Decide whether to troubleshoot, hand off, or escalate a run-centered issue | Read-only preview | What does this run outcome mean, and which documented next step applies? | dominant issue explanation, contextual help headline, troubleshooting steps, and docs links | full support bundle sections, run detail, provider diagnostics, audit references | execution outcome, trustworthiness, guidance actionability | none | Open support diagnostics, open docs link, open related records | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: yes, one bounded contextual-help topic catalog for the first slice
- **Current operator problem**: operators still need founder explanation or external notes to interpret onboarding blockers and support-diagnostic dominant issues safely.
- **Existing structure is insufficient because**: glossary and reason translation explain current state, but they do not provide one reusable, versioned, cross-surface product-knowledge layer with troubleshooting hints, docs links, or a machine-readable knowledge source.
- **Narrowest correct implementation**: one code-owned contextual-help catalog plus one resolver for onboarding and support diagnostics only, reusing existing glossary/reason/support primitives and avoiding persistence, CMS tooling, or AI execution.
- **Ownership cost**: maintain help topic keys, docs-link mappings, glossary alignment, fallback handling, and focused unit plus feature tests.
- **Alternative intentionally rejected**: page-local help copy was rejected as drift-prone, and a full public-docs/help-center platform was rejected as broader than current-release truth.
- **Release truth**: current-release truth
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: unit tests prove the bounded catalog, topic resolution, glossary linkage, and fallback behavior. Feature tests prove the two adopted surfaces render contextual help only after existing authorization succeeds and do so without introducing new routes, persistence, or browser-only behavior.
- **New or expanded test families**: one focused `ProductKnowledge` unit family and targeted feature coverage for onboarding help rendering, support-diagnostic help rendering, and authorization-safe fallback behavior.
- **Fixture / helper cost impact**: low-to-moderate. Reuse existing onboarding draft, tenant, workspace, provider connection, operation run, and support-diagnostic fixtures. No new browser harness, provider emulator, or heavy-governance lane is required.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, monitoring-state-page
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the onboarding wizard; the operation detail adoption also needs one monitoring-state-page regression because the contextual help is rendered from the support-diagnostic path on a monitoring-oriented surface.
- **Reviewer handoff**: reviewers must confirm that contextual help remains registry-backed, progressive, and entitlement-safe; that missing help topics fail predictably; and that help copy does not become a second source of truth or change operation/onboarding authorization semantics.
- **Budget / baseline / trend impact**: low increase in narrow unit and feature coverage only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
## First-Slice Topic Inventory *(mandatory for implementation lock-in)*
The first slice is locked to the following eight canonical help topic keys. Adding or replacing a first-slice topic requires an explicit spec update.
| Topic Key | Intended Surface Families | Primary Trigger | Shared Truth Reused |
|---|---|---|---|
| `admin-consent-required` | onboarding, support diagnostics | provider readiness or dominant issue indicates admin consent is still required | `RequiredPermissionsLinks`, glossary terms, reason translation |
| `required-permissions-missing` | onboarding, support diagnostics | provider readiness or dominant issue indicates required permissions are missing or incomplete | `RequiredPermissionsLinks`, glossary terms, reason translation |
| `connection-unhealthy` | onboarding, support diagnostics | provider connection health is degraded or disconnected | operator explanation, reason translation, diagnostic summary |
| `verification-stale` | onboarding | verification has not been refreshed recently enough to trust readiness | onboarding verification state, glossary terms |
| `verification-failed` | onboarding, support diagnostics | verification or readiness checks completed with a failing result | operator explanation, reason translation |
| `diagnostic-evidence-incomplete` | support diagnostics | the bundle cannot prove a dominant issue with high confidence because evidence is incomplete | diagnostic bundle summary, glossary terms |
| `retryable-provider-failure` | support diagnostics | support diagnostics indicate a provider-side failure that is safe to retry or re-check | reason translation, operator explanation |
| `manual-handoff-required` | support diagnostics | the system can summarize the problem but requires a human support handoff or explicit escalation path | diagnostic bundle summary, glossary terms, approved docs links |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Explain Onboarding Blockers In Context (Priority: P1)
As a workspace operator, I want onboarding blockers to show contextual help with canonical terminology, safe next steps, and supporting docs links so I can continue onboarding without founder intervention.
**Why this priority**: This is the most immediate operator-facing support reduction in the roadmap cluster and reuses the repo's existing onboarding and permission diagnostics foundations.
**Independent Test**: Open onboarding drafts that are blocked by missing consent, missing permissions, unhealthy provider connection, or stale verification and verify that the wizard shows registry-backed contextual help without changing the existing readiness truth.
**Acceptance Scenarios**:
1. **Given** an authorized operator opens an onboarding draft blocked by missing admin consent, **When** the readiness step renders, **Then** the workflow shows a contextual help headline, one safe next step, and an admin-consent docs/action link derived from the shared help registry.
2. **Given** an authorized operator opens an onboarding draft blocked by missing permissions or stale verification, **When** the readiness step renders, **Then** the workflow shows glossary-aligned contextual help that explains the blocker without replacing the existing verification or provider-truth sections.
3. **Given** the current user is not entitled to the onboarding scope, **When** they attempt to access the draft, **Then** the system still returns 404 or 403 according to existing rules and reveals no contextual-help details.
---
### User Story 2 - Reuse The Same Product Knowledge In Support Diagnostics (Priority: P1)
As a support-capable operator, I want tenant and operation support-diagnostic previews to show the same contextual help language and troubleshooting guidance so support cases stop depending on ad-hoc explanation.
**Why this priority**: The second high-value surface proves the knowledge layer is genuinely reusable and not just onboarding-local prose.
**Independent Test**: Open tenant-context and operation-context support diagnostics for the same dominant issue and verify that the preview renders the same registry-backed help topic, troubleshooting hints, and supporting docs links.
**Acceptance Scenarios**:
1. **Given** a tenant support-diagnostic bundle resolves a dominant issue such as missing permissions or an unhealthy connection, **When** the preview renders, **Then** it includes registry-backed contextual help aligned with the dominant issue and leaves the existing diagnostic sections intact.
2. **Given** an operation-context support-diagnostic bundle resolves the same dominant issue, **When** the preview renders, **Then** it uses the same help topic key and glossary-aligned language rather than a second local explanation dialect.
3. **Given** the dominant issue has no configured help topic in the first slice, **When** the preview renders, **Then** the bundle degrades gracefully without exceptions or raw unresolved keys.
4. **Given** a user lacks the existing support-diagnostic entitlement for the tenant or operation scope, **When** they attempt to open the preview, **Then** the host surface preserves the current 404/403 behavior and reveals no contextual-help payload.
---
### User Story 3 - Provide A Safe Machine-Readable Knowledge Source (Priority: P2)
As the product owner, I want the first-slice help catalog to expose a machine-readable knowledge source so later AI-assisted support can reuse trusted product knowledge without scraping UI prose or customer data.
**Why this priority**: This keeps the first slice aligned with later AI-adjacent work without forcing AI execution or broad platform scope into the current implementation.
**Independent Test**: Resolve the first-slice catalog into a machine-readable knowledge source and verify that it contains only topic metadata, glossary-aligned text, and allowed docs links, with no tenant-specific data or secrets.
**Acceptance Scenarios**:
1. **Given** the code-owned help catalog, **When** the machine-readable knowledge source is exported for internal product use, **Then** it contains only stable topic keys, headings, troubleshooting steps, and allowed links.
2. **Given** a help topic references existing route or docs helpers, **When** the machine-readable knowledge source is built, **Then** the exported representation contains only safe link metadata and never includes tenant-specific provider payloads, secrets, or free-text customer notes.
### Edge Cases
- A host surface may resolve a reason or dominant issue that has no mapped help topic in the first slice; the UI must fail predictably and preserve the underlying truth without showing raw topic keys.
- A provider-owned topic may have both an internal product route and an external Microsoft docs link; the surfaced links must stay ordered and entitlement-safe.
- The same help topic may appear on onboarding and support diagnostics; the wording must remain stable even if the surrounding surface framing differs.
- Progressive disclosure must keep the help block subordinate to the surface's primary truth so the product does not imply the help copy is itself the source of truth.
- Localization remains out of scope for the first slice; help topics must stay ready for later localization without introducing a second vocabulary layer now.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Graph call path and no new tenant-changing action. It adds a read-only contextual-help layer on top of existing onboarding and support-diagnostic truths. Existing write, queue, and audit semantics remain unchanged.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice introduces one new bounded abstraction because current-release operator workflows now need a reusable help layer. No new persistence, new state family, or generic knowledge platform is introduced.
**Constitution alignment (XCUT-001):** This slice is cross-cutting across onboarding and support diagnostics. It must reuse the existing glossary, reason-translation, operator-explanation, and support-diagnostic bundle paths rather than introducing page-local help dialects.
**Constitution alignment (PROV-001):** Provider-specific remediation remains bounded to provider-owned topics and existing docs-link helpers. Platform-core help topics remain provider-neutral.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature lanes only. No browser or heavy-governance family is justified.
**Constitution alignment (RBAC-UX):** Existing scope and capability checks remain authoritative. Help resolution must not widen access, leak hidden remediation destinations, or replace 404/403 semantics.
**Constitution alignment (OPS-UX):** The feature does not create or change `OperationRun` start, completion, notification, or link semantics.
**Constitution alignment (BADGE-001):** The feature introduces no new badge domain. If existing badge or status labels appear inside help, they must be reused from existing catalog-backed semantics.
**Constitution alignment (UI-FIL-001):** Operator-facing help must use native Filament or shared diagnostic primitives on adopted surfaces. No ad-hoc status cards or new local status language are allowed.
**Constitution alignment (UI-NAMING-001):** Help headlines, troubleshooting hints, docs links, and surrounding UI copy must preserve the same canonical vocabulary already used by reason translation, onboarding readiness, and support diagnostics.
### Functional Requirements
- **FR-244-001**: The system MUST define one code-owned contextual-help catalog for the bounded first slice.
- **FR-244-002**: The catalog MUST use stable help topic keys and remain reviewable and versioned in the repository.
- **FR-244-003**: The contextual-help resolver MUST reuse `PlatformVocabularyGlossary`, reason-translation outputs, operator-explanation outputs, and existing docs-link helpers instead of duplicating those semantics.
- **FR-244-004**: The managed-tenant onboarding workflow MUST render registry-backed contextual help for the first-slice blocker families when a matching help topic exists.
- **FR-244-005**: Tenant-context and operation-context support-diagnostic previews MUST render registry-backed contextual help for the first-slice dominant-issue families when a matching help topic exists.
- **FR-244-006**: Contextual help MUST remain progressive disclosure and MUST NOT replace the host surface's primary truth sections.
- **FR-244-007**: Each help topic MUST support a bounded shape containing a headline, short explanation, troubleshooting steps, safe next action, and zero or more supporting docs links.
- **FR-244-008**: Help copy MUST reuse canonical glossary and reason-translation vocabulary and MUST NOT invent conflicting synonyms for onboarding, diagnostics, evidence, drift, support, or operation outcomes.
- **FR-244-009**: Provider-specific help MUST remain bounded to provider-owned topics and existing provider link helpers.
- **FR-244-010**: Missing or invalid help topics MUST degrade gracefully without exceptions, broken UI state, or raw unresolved topic keys.
- **FR-244-011**: The feature MUST expose a machine-readable knowledge source safe for future internal AI/support use without tenant-specific data, provider payloads, or secrets.
- **FR-244-012**: The first slice MUST NOT introduce a public documentation site, chatbot, CMS/editor, new database table, or customer-facing help center.
- **FR-244-013**: Contextual help MUST not change existing onboarding, support-diagnostic, or authorization behavior.
- **FR-244-014**: The feature MUST include regression coverage for onboarding help rendering, support-diagnostic help rendering, and missing-topic fallback behavior.
- **FR-244-015**: The feature MUST include at least one positive and one negative authorization regression proving that contextual help never leaks hidden scope or destinations.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Managed tenant onboarding workflow | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Existing header actions remain unchanged | N/A | none added by this feature | none | existing empty/start state unchanged | N/A | existing onboarding actions unchanged | no | Adds a read-only contextual-help block only; no new destructive or mutating action |
| Tenant support-diagnostic preview host | `apps/platform/app/Filament/Pages/TenantDashboard.php` | Existing support-diagnostics action unchanged | N/A | none added by this feature | none | none | N/A | N/A | no | Help is rendered inside the preview content returned by the shared bundle builder |
| Operation detail support-diagnostic preview host | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Existing support-diagnostics action unchanged | N/A | none added by this feature | none | none | existing run actions unchanged | N/A | no | Monitoring/detail action hierarchy remains unchanged; help annotates preview content only |
### Key Entities *(include if feature involves data)*
- **Contextual Help Topic**: A code-owned, versioned help entry identified by a stable topic key and containing bounded product guidance only.
- **Contextual Help Resolution**: A derived help payload built from catalog entries plus existing glossary, reason, operator-explanation, and docs-link inputs.
- **Machine-Readable Knowledge Source**: A safe export of the code-owned help catalog for future internal AI/support consumption.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-244-001**: The first implementation slice renders registry-backed contextual help on at least two critical surfaces: the managed-tenant onboarding workflow and support-diagnostic previews.
- **SC-244-002**: In focused regression coverage, 100% of in-scope first-slice blocker and dominant-issue scenarios either render a matching help topic or degrade gracefully without errors or raw unresolved keys.
- **SC-244-003**: The machine-readable knowledge source contains only code-owned topic metadata and approved links, with 0 tenant-specific records, raw provider payloads, or secrets in regression coverage.
- **SC-244-004**: The adopted surfaces continue to use existing authorization semantics unchanged, with contextual help visible only after the host surface's existing entitlement checks succeed.

View File

@ -0,0 +1,134 @@
---
description: "Task list for Product Knowledge & Contextual Help"
---
# Tasks: Product Knowledge & Contextual Help
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/244-product-knowledge-contextual-help/checklists/requirements.md` (required)
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only.
**Operations**: This slice must not alter existing `OperationRun` start, completion, notification, or link UX.
**RBAC**: Existing onboarding, tenant, and support-diagnostic entitlement checks remain authoritative. No new capability family is introduced.
**Organization**: Tasks are grouped by user story so onboarding guidance, support-diagnostic guidance, and the internal machine-readable knowledge source remain independently deliverable.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare the bounded product-knowledge namespace and the narrow validation surfaces.
- [x] T001 Create the feature-local support namespace and test directories under `apps/platform/app/Support/ProductKnowledge/`, `apps/platform/tests/Unit/Support/ProductKnowledge/`, `apps/platform/tests/Feature/Onboarding/`, and `apps/platform/tests/Feature/SupportDiagnostics/`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the single shared contextual-help catalog and resolver before touching onboarding or support-diagnostic surfaces.
**Checkpoint**: One bounded product-knowledge path exists before any host surface adopts it.
- [x] T002 Create the code-owned first-slice topic catalog in `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`
- [x] T003 Create the shared resolver in `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` so help payloads are derived from `PlatformVocabularyGlossary`, `ReasonPresenter`, `OperatorExplanationBuilder`, and `RequiredPermissionsLinks`
- [x] T004 Define the minimal machine-readable knowledge-source metadata shape and unresolved-topic fallback contract inside the `ProductKnowledge` namespace so onboarding and support-diagnostic hosts can adopt the shared resolver before US3 hardens the final knowledge-source and fallback guarantees
- [x] T005 [P] Add unit coverage for all eight canonical topic keys, resolver behavior, and the foundational fallback/export contract in `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php`, `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php`, and `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
- [x] T006 Run the foundational unit suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php`
---
## Phase 3: User Story 1 - Explain Onboarding Blockers In Context (Priority: P1) 🎯 MVP
**Goal**: Show registry-backed contextual help directly in the onboarding workflow so operators can interpret the current blocker without founder explanation.
**Independent Test**: Open onboarding drafts blocked by consent, permission, connection-health, and verification-freshness issues and verify that the wizard renders the matching help payload without changing the underlying readiness truth.
### Tests for User Story 1
- [x] T007 [P] [US1] Add onboarding feature coverage for `admin-consent-required`, `required-permissions-missing`, `connection-unhealthy`, `verification-stale`, `verification-failed`, and positive/negative authorization behavior in `apps/platform/tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php`
### Implementation for User Story 1
- [x] T008 [US1] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` to derive contextual-help topic inputs from existing readiness, permission, and verification signals and resolve them through the shared `ContextualHelpResolver`
- [x] T009 [US1] Render the onboarding help block with native Filament/shared primitives inside `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, keeping the host workflow's existing action hierarchy and destructive actions unchanged
- [x] T010 [US1] Run the onboarding slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php`
---
## Phase 4: User Story 2 - Reuse The Same Product Knowledge In Support Diagnostics (Priority: P1)
**Goal**: Reuse the same contextual-help contract inside tenant and operation-context support-diagnostic previews.
**Independent Test**: Open tenant and operation support diagnostics for the same dominant issue and verify that both previews render the same topic-backed help payload and degrade safely when a topic is missing.
### Tests for User Story 2
- [x] T011 [P] [US2] Add support-diagnostic feature coverage for tenant-context and operation-context rendering of `admin-consent-required`, `required-permissions-missing`, `connection-unhealthy`, `verification-failed`, `diagnostic-evidence-incomplete`, `retryable-provider-failure`, and `manual-handoff-required` in `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php`
- [x] T012 [P] [US2] Add authorization and missing-topic fallback coverage for support-diagnostic help in `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
### Implementation for User Story 2
- [x] T013 [US2] Update `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` to attach contextual-help payloads derived from dominant issue, provider state, and existing diagnostic summary inputs through the shared resolver
- [x] T014 [US2] Update the support-diagnostic preview hosts in `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` so they render the bundle's contextual-help data without introducing a second host-specific help dialect
- [x] T015 [US2] Run the support-diagnostic slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
---
## Phase 5: User Story 3 - Provide A Safe Machine-Readable Knowledge Source (Priority: P2)
**Goal**: Harden the scaffolded machine-readable knowledge source and fallback contract so the first-slice catalog stays safe for later internal AI/support reuse without turning the feature into AI execution or a public docs platform.
**Independent Test**: Export the catalog into its machine-readable knowledge source and verify that it contains only topic metadata and approved links, while missing topics continue to degrade safely.
### Tests for User Story 3
- [x] T016 [P] [US3] Extend `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php` and `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php` with finalized machine-readable knowledge-source assertions covering all eight canonical topic keys and approved-link metadata
- [x] T017 [P] [US3] Extend `apps/platform/tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php` and `apps/platform/tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php` with finalized unresolved-topic, link-safety, and no-raw-key regressions
### Implementation for User Story 3
- [x] T018 [US3] Finalize the machine-readable knowledge-source shape in `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php` so all eight canonical topic keys expose only stable topic metadata, troubleshooting steps, glossary-backed copy, and approved links
- [x] T019 [US3] Harden `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` so unresolved topics never raise exceptions or leak raw keys into onboarding or support-diagnostic surfaces, building on the foundational contract from T004
- [x] T020 [US3] Run the knowledge-source and fallback slice with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge/ContextualHelpCatalogTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpResolverTest.php tests/Unit/Support/ProductKnowledge/ContextualHelpFallbackTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Lock down vocabulary alignment, formatting, and the narrow validation suite before implementation close-out.
- [x] T021 [P] Confirm that first-slice topic keys, glossary nouns, and approved docs links stay aligned across `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php`, and the adopted onboarding/support-diagnostic surfaces
- [x] T022 Run formatting on touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [x] T023 Run the full narrow validation suite with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ProductKnowledge tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeSupportDiagnosticHelpTest.php tests/Feature/SupportDiagnostics/ProductKnowledgeAuthorizationTest.php`
---
## Dependencies & Execution Order
### User Story Dependency Graph
```text
Phase 1 (Setup)
Phase 2 (Catalog + resolver + fallback/export)
US1 (onboarding help adoption) ───────────────┐
├─→ US3 (safe knowledge-source and fallback hardening)
US2 (support-diagnostic help adoption) ───────┘
```
### Parallel Opportunities
- The unit tests in Phase 2 can be authored in parallel once the catalog shape is agreed.
- Onboarding and support-diagnostic feature tests can be authored in parallel because they touch different host surfaces.
- US3 export and fallback hardening can proceed in parallel with late US1/US2 integration cleanup once the shared resolver contract is stable.
---
## Test Governance Checklist
- [ ] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [ ] New or changed tests stay in the smallest honest family, and no heavy-governance or browser family is introduced accidentally.
- [ ] Shared helpers and fixture setup remain cheap by default.
- [ ] Planned validation commands cover the change without pulling in unrelated lane cost.
- [ ] The adopted surfaces explicitly use `standard-native-filament` plus the named monitoring-state-page regression where required.
- [ ] No material budget or baseline escalation is introduced.