diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index f2751432..db8856d8 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -129,6 +129,8 @@ ## Active Technologies - PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention) - PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention) +- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials (179-provider-truth-cleanup) +- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup) - PHP 8.4.15 (feat/005-bulk-operations) @@ -148,8 +150,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 179-provider-truth-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials - 175-workspace-governance-attention: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes - 174-evidence-freshness-publication-trust: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages -- 173-tenant-dashboard-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index e4fff28e..bfd0eafc 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -469,29 +469,12 @@ private static function migrationReviewDescription(?ProviderConnection $record): private static function consentStatusLabelFromState(mixed $state): string { - $value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown'); - - return match ($value) { - 'required' => 'Required', - 'granted' => 'Granted', - 'failed' => 'Failed', - 'revoked' => 'Revoked', - default => 'Unknown', - }; + return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label; } private static function verificationStatusLabelFromState(mixed $state): string { - $value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown'); - - return match ($value) { - 'pending' => 'Pending', - 'healthy' => 'Healthy', - 'degraded' => 'Degraded', - 'blocked' => 'Blocked', - 'error' => 'Error', - default => 'Unknown', - }; + return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label; } public static function form(Schema $schema): Schema @@ -527,7 +510,7 @@ public static function form(Schema $schema): Schema ]) ->columns(2) ->columnSpanFull(), - Section::make('Status') + Section::make('Current state') ->schema([ Placeholder::make('consent_status_display') ->label('Consent') @@ -535,18 +518,31 @@ public static function form(Schema $schema): Schema Placeholder::make('verification_status_display') ->label('Verification') ->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)), - TextInput::make('status') - ->label('Status') - ->disabled() - ->dehydrated(false), - TextInput::make('health_status') - ->label('Health') - ->disabled() - ->dehydrated(false), + Placeholder::make('last_health_check_at_display') + ->label('Last check') + ->content(fn (?ProviderConnection $record): string => $record?->last_health_check_at?->diffForHumans() ?? 'Never'), + ]) + ->columns(2) + ->columnSpanFull(), + Section::make('Diagnostics') + ->schema([ + Placeholder::make('status_display') + ->label('Legacy status') + ->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label), + Placeholder::make('health_status_display') + ->label('Legacy health') + ->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label), Placeholder::make('migration_review_status_display') ->label('Migration review') ->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record)) ->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)), + Placeholder::make('last_error_reason_code_display') + ->label('Last error reason') + ->content(fn (?ProviderConnection $record): string => filled($record?->last_error_reason_code) ? (string) $record->last_error_reason_code : 'n/a'), + Placeholder::make('last_error_message_display') + ->label('Last error message') + ->content(fn (?ProviderConnection $record): string => static::sanitizeErrorMessage($record?->last_error_message) ?? 'n/a') + ->columnSpanFull(), ]) ->columns(2) ->columnSpanFull(), @@ -580,22 +576,55 @@ public static function infolist(Schema $schema): Schema ->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)), ]) ->columns(2), - Section::make('Status') + Section::make('Current state') ->schema([ Infolists\Components\TextEntry::make('consent_status') ->label('Consent') - ->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)), + ->badge() + ->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)), Infolists\Components\TextEntry::make('verification_status') ->label('Verification') - ->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)), + ->badge() + ->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)), + Infolists\Components\TextEntry::make('last_health_check_at') + ->label('Last check') + ->since(), + ]) + ->columns(2), + Section::make('Diagnostics') + ->schema([ Infolists\Components\TextEntry::make('status') - ->label('Status'), + ->label('Legacy status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)), Infolists\Components\TextEntry::make('health_status') - ->label('Health'), + ->label('Legacy health') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), Infolists\Components\TextEntry::make('migration_review_required') ->label('Migration review') ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)), + Infolists\Components\TextEntry::make('last_error_reason_code') + ->label('Last error reason') + ->placeholder('n/a'), + Infolists\Components\TextEntry::make('last_error_message') + ->label('Last error message') + ->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state)) + ->placeholder('n/a') + ->columnSpanFull(), ]) ->columns(2), ]); @@ -655,20 +684,36 @@ public static function table(Table $table): Table ? 'Dedicated' : 'Platform') ->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'), + Tables\Columns\TextColumn::make('consent_status') + ->label('Consent') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConsentStatus)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)), + Tables\Columns\TextColumn::make('verification_status') + ->label('Verification') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderVerificationStatus)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)), Tables\Columns\TextColumn::make('status') - ->label('Status') + ->label('Legacy status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus)) ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)), + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)) + ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('health_status') - ->label('Health') + ->label('Legacy health') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth)) ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)) + ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('migration_review_required') ->label('Migration review') ->badge() @@ -714,8 +759,45 @@ public static function table(Table $table): Table return $query->where('provider_connections.provider', $value); }), + SelectFilter::make('consent_status') + ->label('Consent') + ->options([ + 'unknown' => 'Unknown', + 'required' => 'Required', + 'granted' => 'Granted', + 'failed' => 'Failed', + 'revoked' => 'Revoked', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '') { + return $query; + } + + return $query->where('provider_connections.consent_status', $value); + }), + SelectFilter::make('verification_status') + ->label('Verification') + ->options([ + 'unknown' => 'Unknown', + 'pending' => 'Pending', + 'healthy' => 'Healthy', + 'degraded' => 'Degraded', + 'blocked' => 'Blocked', + 'error' => 'Error', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if (! is_string($value) || $value === '') { + return $query; + } + + return $query->where('provider_connections.verification_status', $value); + }), SelectFilter::make('status') - ->label('Status') + ->label('Diagnostic status') ->options([ 'connected' => 'Connected', 'needs_consent' => 'Needs consent', @@ -732,7 +814,7 @@ public static function table(Table $table): Table return $query->where('provider_connections.status', $value); }), SelectFilter::make('health_status') - ->label('Health') + ->label('Diagnostic health') ->options([ 'ok' => 'OK', 'degraded' => 'Degraded', diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 8a9df1e2..9be6f255 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -284,13 +284,6 @@ public static function table(Table $table): Table ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)) ->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription) ->sortable(), - Tables\Columns\TextColumn::make('app_status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus)) - ->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)) - ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->since() @@ -310,13 +303,6 @@ public static function table(Table $table): Table 'staging' => 'STAGING', 'other' => 'Other', ]), - Tables\Filters\SelectFilter::make('app_status') - ->options([ - 'ok' => 'OK', - 'consent_required' => 'Consent required', - 'error' => 'Error', - 'unknown' => 'Unknown', - ]), ]) ->actions([ Actions\Action::make('related_onboarding') @@ -842,12 +828,6 @@ public static function infolist(Schema $schema): Schema ->label('Lifecycle summary') ->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription) ->columnSpanFull(), - Infolists\Components\TextEntry::make('app_status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus)) - ->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)), ]) ->columns(2) ->columnSpanFull(), @@ -1492,19 +1472,30 @@ private static function providerConnectionState(Tenant $tenant): array { $ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin'); - $connection = ProviderConnection::query() + $defaultConnection = ProviderConnection::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') - ->orderByDesc('is_default') + ->where('is_default', true) ->orderBy('id') ->first(); + $connection = $defaultConnection instanceof ProviderConnection + ? $defaultConnection + : ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->orderBy('id') + ->first(); + if (! $connection instanceof ProviderConnection) { return [ - 'state' => 'needs_action', + 'state' => 'missing', 'cta_url' => $ctaUrl, + 'needs_default_connection' => false, 'display_name' => null, 'provider' => null, + 'consent_status' => null, + 'verification_status' => null, 'status' => null, 'health_status' => null, 'last_health_check_at' => null, @@ -1515,8 +1506,15 @@ private static function providerConnectionState(Tenant $tenant): array return [ 'state' => $connection->is_default ? 'default_configured' : 'configured', 'cta_url' => $ctaUrl, + 'needs_default_connection' => ! $connection->is_default, 'display_name' => (string) $connection->display_name, 'provider' => (string) $connection->provider, + 'consent_status' => $connection->consent_status instanceof BackedEnum + ? (string) $connection->consent_status->value + : (is_string($connection->consent_status) ? $connection->consent_status : null), + 'verification_status' => $connection->verification_status instanceof BackedEnum + ? (string) $connection->verification_status->value + : (is_string($connection->verification_status) ? $connection->verification_status : null), 'status' => is_string($connection->status) ? $connection->status : null, 'health_status' => is_string($connection->health_status) ? $connection->health_status : null, 'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(), diff --git a/app/Filament/Widgets/Tenant/TenantVerificationReport.php b/app/Filament/Widgets/Tenant/TenantVerificationReport.php index f46d0783..b69f6c8c 100644 --- a/app/Filament/Widgets/Tenant/TenantVerificationReport.php +++ b/app/Filament/Widgets/Tenant/TenantVerificationReport.php @@ -201,7 +201,7 @@ protected function getViewData(): array && $user->can(Capabilities::PROVIDER_RUN, $tenant); $lifecycleNotice = $isTenantMember && ! $canOperate - ? 'Verification can be started from tenant management only while the tenant is active.' + ? 'Verification can be started from tenant management only while the tenant is active. Consent and connection configuration remain separate from this stored verification report.' : null; $runData = null; diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php index 90387cda..b5032135 100644 --- a/app/Support/Badges/BadgeCatalog.php +++ b/app/Support/Badges/BadgeCatalog.php @@ -46,6 +46,8 @@ final class BadgeCatalog BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class, BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, + BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class, + BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class, BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class, BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class, BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class, @@ -166,6 +168,32 @@ public static function normalizeProviderConnectionStatus(mixed $value): ?string }; } + public static function normalizeProviderConsentStatus(mixed $value): ?string + { + $state = self::normalizeState($value); + + return match ($state) { + 'needs_admin_consent', 'needs_consent', 'consent_required' => 'required', + 'connected' => 'granted', + 'error' => 'failed', + default => $state, + }; + } + + public static function normalizeProviderVerificationStatus(mixed $value): ?string + { + $state = self::normalizeState($value); + + return match ($state) { + 'not_started', 'never_checked' => 'unknown', + 'in_progress' => 'pending', + 'ok' => 'healthy', + 'warning', 'needs_attention' => 'degraded', + 'failed' => 'error', + default => $state, + }; + } + public static function normalizeProviderConnectionHealth(mixed $value): ?string { $state = self::normalizeState($value); diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index 2b0314b8..dfaa9d07 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -37,6 +37,8 @@ enum BadgeDomain: string case IgnoredAt = 'ignored_at'; case RestorePreviewDecision = 'restore_preview_decision'; case RestoreResultStatus = 'restore_result_status'; + case ProviderConsentStatus = 'provider_connection.consent_status'; + case ProviderVerificationStatus = 'provider_connection.verification_status'; case ProviderConnectionStatus = 'provider_connection.status'; case ProviderConnectionHealth = 'provider_connection.health'; case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status'; diff --git a/app/Support/Badges/Domains/ProviderConsentStatusBadge.php b/app/Support/Badges/Domains/ProviderConsentStatusBadge.php new file mode 100644 index 00000000..245a335e --- /dev/null +++ b/app/Support/Badges/Domains/ProviderConsentStatusBadge.php @@ -0,0 +1,24 @@ + new BadgeSpec('Required', 'warning', 'heroicon-m-exclamation-triangle'), + 'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'), + 'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), + 'revoked' => new BadgeSpec('Revoked', 'danger', 'heroicon-m-no-symbol'), + 'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/ProviderVerificationStatusBadge.php b/app/Support/Badges/Domains/ProviderVerificationStatusBadge.php new file mode 100644 index 00000000..33f43c1d --- /dev/null +++ b/app/Support/Badges/Domains/ProviderVerificationStatusBadge.php @@ -0,0 +1,25 @@ + new BadgeSpec('Pending', 'info', 'heroicon-m-clock'), + 'healthy' => new BadgeSpec('Healthy', 'success', 'heroicon-m-check-circle'), + 'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'), + 'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-no-symbol'), + 'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'), + 'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/OperateHub/OperateHubShell.php b/app/Support/OperateHub/OperateHubShell.php index 990aaaf9..9366116c 100644 --- a/app/Support/OperateHub/OperateHubShell.php +++ b/app/Support/OperateHub/OperateHubShell.php @@ -188,13 +188,15 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant return null; } + $tenantKeyColumn = (new Tenant)->getQualifiedKeyName(); + return Tenant::query() ->withTrashed() - ->where(static function ($query) use ($routeTenant): void { + ->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void { $query->where('external_id', $routeTenant); if (ctype_digit($routeTenant)) { - $query->orWhereKey((int) $routeTenant); + $query->orWhere($tenantKeyColumn, (int) $routeTenant); } }) ->first(); diff --git a/resources/views/filament/infolists/entries/provider-connection-state.blade.php b/resources/views/filament/infolists/entries/provider-connection-state.blade.php index 616a261f..da99a4c0 100644 --- a/resources/views/filament/infolists/entries/provider-connection-state.blade.php +++ b/resources/views/filament/infolists/entries/provider-connection-state.blade.php @@ -2,17 +2,24 @@ $state = $getState(); $state = is_array($state) ? $state : []; - $connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'needs_action'; + $connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'missing'; $ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#'; + $needsDefaultConnection = (bool) ($state['needs_default_connection'] ?? false); $displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null; $provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null; + $consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null; + $verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null; $status = is_string($state['status'] ?? null) ? (string) $state['status'] : null; $healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null; $lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null; $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null; - $isMissing = $connectionState === 'needs_action'; + $isMissing = $connectionState === 'missing'; + $consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus); + $verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus); + $legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status); + $legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus); @endphp
@@ -20,7 +27,9 @@
Provider connection
@if ($isMissing) -
Needs action: no default Microsoft provider connection is configured.
+
Needs action: no Microsoft provider connection is configured.
+ @elseif ($needsDefaultConnection) +
Needs action: set a default Microsoft provider connection.
@else
{{ $displayName ?? 'Unnamed connection' }}
@endif @@ -32,18 +41,32 @@
@unless ($isMissing) + @if ($needsDefaultConnection && $displayName) +
+ Current connection: {{ $displayName }} +
+ @endif +
Provider
{{ $provider ?? 'n/a' }}
-
Status
-
{{ $status ?? 'n/a' }}
+
Consent
+
+ + {{ $consentSpec->label }} + +
-
Health
-
{{ $healthStatus ?? 'n/a' }}
+
Verification
+
+ + {{ $verificationSpec->label }} + +
Last check
@@ -51,10 +74,32 @@
- @if ($lastErrorReason) -
- Last error reason: {{ $lastErrorReason }} -
- @endif +
+
Diagnostics
+
+
+
Legacy status
+
+ + {{ $legacyStatusSpec->label }} + +
+
+
+
Legacy health
+
+ + {{ $legacyHealthSpec->label }} + +
+
+
+ + @if ($lastErrorReason) +
+ Last error reason: {{ $lastErrorReason }} +
+ @endif +
@endunless
diff --git a/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php b/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php index f5776822..dab46266 100644 --- a/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php +++ b/resources/views/filament/widgets/tenant/tenant-verification-report.blade.php @@ -27,12 +27,12 @@
@if ($run === null)
- No verification operation has been started yet. + No provider verification check has been recorded yet.
diff --git a/specs/179-provider-truth-cleanup/checklists/requirements.md b/specs/179-provider-truth-cleanup/checklists/requirements.md new file mode 100644 index 00000000..41c84c4b --- /dev/null +++ b/specs/179-provider-truth-cleanup/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Spec 179 - Provider Readiness Source-of-Truth Cleanup + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-04 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass completed on 2026-04-04. +- Constitution-required route and action-surface references are intentionally confined to the surface contract and action matrix. +- No clarification questions were required because the requested truth rules, scope boundaries, and follow-up exclusions were explicit. \ No newline at end of file diff --git a/specs/179-provider-truth-cleanup/contracts/provider-truth-cleanup.openapi.yaml b/specs/179-provider-truth-cleanup/contracts/provider-truth-cleanup.openapi.yaml new file mode 100644 index 00000000..d35d8238 --- /dev/null +++ b/specs/179-provider-truth-cleanup/contracts/provider-truth-cleanup.openapi.yaml @@ -0,0 +1,390 @@ +openapi: 3.1.0 +info: + title: Provider Truth Cleanup Internal Surface Contract + version: 0.1.0 + summary: Internal planning contract for tenant and provider status-truth cleanup + description: | + This contract is an internal planning artifact for Spec 179. The affected + routes continue to render HTML. The schemas below describe the structured + truth that must be derivable before rendering so tenant and provider + surfaces stop elevating legacy status projections over lifecycle, consent, + and verification. +servers: + - url: /internal +x-surface-consumers: + - surface: tenant.list + summarySource: + - tenant.lifecycle + - optional_bounded_provider_signal + guardScope: + - app/Filament/Resources/TenantResource.php + expectedContract: + - lifecycle_is_primary_tenant_truth + - legacy_app_status_is_not_default_visible + - provider_signal_may_be_omitted_if_no_truthful_bounded_summary_exists + - surface: tenant.detail.provider_summary + summarySource: + - tenant.lifecycle + - tenant.provider_connection_state_helper + - provider_connection.consent_status + - provider_connection.verification_status + - latest_provider_connection_check_run + guardScope: + - app/Filament/Resources/TenantResource.php + - resources/views/filament/infolists/entries/provider-connection-state.blade.php + - app/Filament/Widgets/Tenant/TenantVerificationReport.php + expectedContract: + - lifecycle_is_separate_from_provider_truth + - provider_summary_leads_with_consent_and_verification + - legacy_status_and_health_are_diagnostic_only + - missing_default_connection_never_reads_as_ready + - surface: provider_connections.list + summarySource: + - provider_connection.consent_status + - provider_connection.verification_status + - provider_connection.connection_type + - provider_connection.is_default + - provider_connection.legacy_diagnostics + guardScope: + - app/Filament/Resources/ProviderConnectionResource.php + - app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php + expectedContract: + - consent_and_verification_are_default_visible_axes + - legacy_status_and_health_are_secondary + - core_filters_follow_leading_truth + - surface: provider_connections.detail + summarySource: + - provider_connection.consent_status + - provider_connection.verification_status + - provider_connection.legacy_diagnostics + guardScope: + - app/Filament/Resources/ProviderConnectionResource.php + - app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php + - app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php + expectedContract: + - current_state_and_diagnostics_are_visually_separate + - configured_or_consented_is_not_equated_with_verified + - legacy_diagnostics_do_not_dominate +paths: + /admin/tenants: + get: + summary: Render the tenant list with lifecycle-led truth and no legacy app-status prominence + operationId: viewTenantListTruthCleanup + responses: + '200': + description: Tenant list rendered with lifecycle as the primary tenant truth and no default-visible app-status truth + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.tenant-list-truth+json: + schema: + $ref: '#/components/schemas/TenantListTruthBundle' + '404': + description: Workspace or tenant scope is outside entitlement + /admin/tenants/{tenant}: + get: + summary: Render tenant detail with lifecycle and provider truth separated + operationId: viewTenantTruthDetail + parameters: + - name: tenant + in: path + required: true + schema: + type: string + responses: + '200': + description: Tenant detail rendered with lifecycle separate from provider consent and provider verification + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.tenant-detail-truth+json: + schema: + $ref: '#/components/schemas/TenantDetailTruthModel' + '404': + description: Tenant is outside entitlement scope + /admin/provider-connections: + get: + summary: Render the canonical provider-connections list with consent and verification as primary axes + operationId: viewProviderConnectionsTruthList + responses: + '200': + description: Provider-connection list rendered with current-state truth leading and legacy diagnostics secondary + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-connections-list-truth+json: + schema: + $ref: '#/components/schemas/ProviderConnectionListTruthBundle' + '302': + description: Workspace members without an active tenant filter or equivalent context may be redirected according to existing canonical-admin rules + '404': + description: Workspace or tenant scope is outside entitlement + /admin/provider-connections/{record}: + get: + summary: Render provider connection detail with current state and diagnostics separated + operationId: viewProviderConnectionTruthDetail + parameters: + - name: record + in: path + required: true + schema: + type: + - integer + - string + responses: + '200': + description: Provider-connection detail rendered with consent and verification as the leading state contract + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-connection-detail-truth+json: + schema: + $ref: '#/components/schemas/ProviderConnectionDetailTruthModel' + '403': + description: Actor is in scope but lacks the capability required for a protected action on the page + '404': + description: Provider connection is outside entitlement scope + /admin/provider-connections/{record}/edit: + get: + summary: Render provider connection edit with current state context before mutations + operationId: editProviderConnectionTruthDetail + parameters: + - name: record + in: path + required: true + schema: + type: + - integer + - string + responses: + '200': + description: Provider-connection edit page rendered with current consent and verification context and separate diagnostics + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-connection-edit-truth+json: + schema: + $ref: '#/components/schemas/ProviderConnectionDetailTruthModel' + '403': + description: Actor is in scope but lacks manage capability + '404': + description: Provider connection is outside entitlement scope +components: + schemas: + VisibilityMode: + type: string + enum: + - hidden + - diagnostic + - primary + ProviderSignal: + type: object + required: + - mode + - explanation + properties: + mode: + type: string + enum: + - omitted + - missing_default_connection + - consent_verification_summary + consentStatus: + type: + - string + - 'null' + verificationStatus: + type: + - string + - 'null' + explanation: + type: string + TenantListRow: + type: object + required: + - tenantId + - tenantLabel + - lifecycle + - legacyAppStatusVisibility + - primaryInspectUrl + properties: + tenantId: + type: integer + tenantLabel: + type: string + lifecycle: + type: string + providerSignal: + oneOf: + - $ref: '#/components/schemas/ProviderSignal' + - type: 'null' + legacyAppStatusVisibility: + $ref: '#/components/schemas/VisibilityMode' + primaryInspectUrl: + type: string + TenantListTruthBundle: + type: object + required: + - rows + properties: + rows: + type: array + items: + $ref: '#/components/schemas/TenantListRow' + TenantProviderSummary: + type: object + required: + - connectionPresence + - ctaUrl + - legacyStatusVisibility + - legacyHealthVisibility + properties: + connectionPresence: + type: string + enum: + - missing + - configured + - default_configured + ctaUrl: + type: string + displayName: + type: + - string + - 'null' + provider: + type: + - string + - 'null' + consentStatus: + type: + - string + - 'null' + verificationStatus: + type: + - string + - 'null' + lastCheckedAt: + type: + - string + - 'null' + lastErrorReasonCode: + type: + - string + - 'null' + legacyStatusVisibility: + $ref: '#/components/schemas/VisibilityMode' + legacyHealthVisibility: + $ref: '#/components/schemas/VisibilityMode' + TenantDetailTruthModel: + type: object + required: + - tenantId + - lifecycle + - providerSummary + properties: + tenantId: + type: integer + lifecycle: + type: string + providerSummary: + $ref: '#/components/schemas/TenantProviderSummary' + verificationReportSurface: + type: string + description: Existing tenant verification widget remains the deep-dive verification surface + ProviderConnectionListItem: + type: object + required: + - connectionId + - displayName + - provider + - connectionType + - isDefault + - consentStatus + - verificationStatus + - legacyStatusVisibility + - legacyHealthVisibility + - primaryInspectUrl + properties: + connectionId: + type: integer + tenantLabel: + type: + - string + - 'null' + displayName: + type: string + provider: + type: string + connectionType: + type: string + isDefault: + type: boolean + consentStatus: + type: string + verificationStatus: + type: string + legacyStatusVisibility: + $ref: '#/components/schemas/VisibilityMode' + legacyHealthVisibility: + $ref: '#/components/schemas/VisibilityMode' + primaryInspectUrl: + type: string + ProviderConnectionListTruthBundle: + type: object + required: + - rows + properties: + rows: + type: array + items: + $ref: '#/components/schemas/ProviderConnectionListItem' + ProviderConnectionDetailTruthModel: + type: object + required: + - connectionId + - displayName + - provider + - connectionType + - isDefault + - consentStatus + - verificationStatus + - legacyStatusVisibility + - legacyHealthVisibility + properties: + connectionId: + type: integer + displayName: + type: string + provider: + type: string + connectionType: + type: string + isDefault: + type: boolean + consentStatus: + type: string + verificationStatus: + type: string + lastCheckedAt: + type: + - string + - 'null' + lastErrorReasonCode: + type: + - string + - 'null' + lastErrorMessage: + type: + - string + - 'null' + migrationReviewRequired: + type: boolean + legacyStatusVisibility: + $ref: '#/components/schemas/VisibilityMode' + legacyHealthVisibility: + $ref: '#/components/schemas/VisibilityMode' \ No newline at end of file diff --git a/specs/179-provider-truth-cleanup/data-model.md b/specs/179-provider-truth-cleanup/data-model.md new file mode 100644 index 00000000..f7ef0ac3 --- /dev/null +++ b/specs/179-provider-truth-cleanup/data-model.md @@ -0,0 +1,222 @@ +# Phase 1 Data Model: Provider Readiness Source-of-Truth Cleanup + +## Overview + +This feature adds no table, no new enum, and no persisted readiness artifact. It reclassifies which already-stored fields are allowed to act as leading operator truth on tenant and provider connection surfaces. + +## Persistent Source Truths + +### Tenant + +**Purpose**: Tenant identity and lifecycle boundary for all tenant-facing operator surfaces. + +**Key fields**: +- `id` +- `workspace_id` +- `external_id` +- `name` +- `status` +- `app_status` (legacy tenant-level projection) +- `rbac_status` + +**Relationships**: +- `Tenant` has many `ProviderConnection` + +**Validation rules**: +- `status` remains the authoritative tenant lifecycle input and maps through `TenantLifecycle`. +- `app_status` remains persisted but is no longer allowed to act as leading operator truth on targeted tenant surfaces. +- `rbac_status` remains a separate domain and must not substitute for provider readiness. + +### ProviderConnection + +**Purpose**: Canonical stored provider-connection record for tenant-scoped provider integration state. + +**Key fields**: +- `id` +- `tenant_id` +- `workspace_id` +- `provider` +- `display_name` +- `is_default` +- `connection_type` +- `consent_status` +- `verification_status` +- `status` (legacy connection-state projection) +- `health_status` (legacy health projection) +- `last_health_check_at` +- `last_error_reason_code` +- `last_error_message` +- `migration_review_required` + +**Relationships**: +- `ProviderConnection` belongs to `Tenant` +- `ProviderConnection` belongs to `Workspace` +- `ProviderConnection` has one `ProviderCredential` + +**Validation rules**: +- `consent_status` is the primary stored truth for consent progression. +- `verification_status` is the primary stored truth for current provider verification outcome. +- `status` and `health_status` may remain persisted and updated by existing projections, but targeted operator surfaces must treat them as secondary diagnostics only. +- `is_default` remains the routing anchor for tenant-facing provider summaries where a single connection must be chosen. + +### ProviderCredential + +**Purpose**: Encrypted credential storage associated with a provider connection. + +**Key fields**: +- `provider_connection_id` +- encrypted credential payload fields + +**Relationships**: +- `ProviderCredential` belongs to `ProviderConnection` + +**Validation rules**: +- This spec does not change credential persistence, mutation rules, or encryption handling. +- Credential presence may continue to influence diagnostic projections, but it is not itself rendered as a leading readiness label on the targeted surfaces. + +### OperationRun / Verification Report + +**Purpose**: Stored verification evidence used by tenant verification and provider check surfaces. + +**Key fields**: +- `tenant_id` +- `type` with `provider.connection.check` +- `status` +- `outcome` +- `context` +- stored verification report payload derived from the run + +**Relationships**: +- `OperationRun` belongs to `Tenant` when tenant-bound + +**Validation rules**: +- The latest stored verification report remains the deep-dive verification surface for tenant detail. +- This spec does not change run lifecycle, queueing, or verification report persistence. + +## Existing Domain State Families + +### TenantLifecycle + +**Values**: +- `draft` +- `onboarding` +- `active` +- `archived` + +**Rule**: +- Lifecycle answers whether the tenant is in the normal lifecycle. It does not answer provider consent, provider verification, or provider readiness. + +### ProviderConsentStatus + +**Values**: +- `unknown` +- `required` +- `granted` +- `failed` +- `revoked` + +**Rule**: +- Consent answers whether the permission or consent step has been completed. It does not prove provider verification health. + +### ProviderVerificationStatus + +**Values**: +- `unknown` +- `pending` +- `healthy` +- `degraded` +- `blocked` +- `error` + +**Rule**: +- Verification is the primary current provider-state axis for whether a connection has been checked and what that check currently proves. + +### Legacy Diagnostic Projections + +**Known provider projection values in current surfaces**: +- `status`: commonly `connected`, `needs_consent`, `error`, `disabled` +- `health_status`: commonly `ok`, `degraded`, `down`, `unknown` + +**Rule**: +- These values remain diagnostics or projections. They must not compete with consent and verification as primary operator truth on targeted surfaces. + +## Derived Surface Contracts + +### Tenant List Row Truth (derived, non-persisted) + +**Purpose**: Minimal row-level truth for `/admin/tenants`. + +| Field | Type | Required | Description | +|------|------|----------|-------------| +| `tenantId` | integer | yes | Tenant identifier | +| `tenantLabel` | string | yes | Tenant name | +| `lifecycle` | string | yes | Lifecycle label derived from `TenantLifecycle` | +| `providerSignal` | object nullable | no | Optional bounded provider summary derived from current truth; may be absent in this slice | +| `legacyAppStatusVisible` | boolean | yes | Must be `false` on default-visible tenant list surfaces | +| `primaryInspectUrl` | string | yes | Canonical tenant detail destination | + +**Validation rules**: +- `providerSignal` may be omitted when a truthful single-row summary cannot be stated without inventing a readiness model. +- `legacyAppStatusVisible` must be `false` for the default tenant list configuration. + +### Tenant Detail Provider Summary (derived, non-persisted) + +**Purpose**: Compact provider-state summary rendered inside the tenant detail `Provider` section. + +| Field | Type | Required | Description | +|------|------|----------|-------------| +| `connectionPresence` | enum | yes | `missing`, `configured`, or `default_configured` | +| `ctaUrl` | string | yes | Canonical provider-connections destination | +| `displayName` | string nullable | no | Default or chosen connection display name | +| `provider` | string nullable | no | Provider label | +| `consentStatus` | string nullable | no | Current consent state from the chosen connection | +| `verificationStatus` | string nullable | no | Current verification state from the chosen connection | +| `lastCheckedAt` | string nullable | no | Stored last-check timestamp | +| `lastErrorReasonCode` | string nullable | no | Diagnostic reason code when present | +| `legacyStatus` | string nullable | no | Legacy diagnostic projection | +| `legacyHealthStatus` | string nullable | no | Legacy health projection | + +**Validation rules**: +- Consent and verification are the leading fields in this summary. +- Legacy status and health may remain available only as diagnostics. +- When no default Microsoft provider connection exists, the summary must explicitly say action is needed and must not invent a healthy or ready label. + +### Provider Connection Surface Truth (derived, non-persisted) + +**Purpose**: Shared truth hierarchy for provider connection list, view, and edit surfaces. + +| Field | Type | Required | Description | +|------|------|----------|-------------| +| `connectionId` | integer | yes | Provider connection identifier | +| `tenantLabel` | string nullable | no | Tenant label when rendered on tenantless canonical surfaces | +| `displayName` | string | yes | Connection display name | +| `provider` | string | yes | Provider key or label | +| `connectionType` | string | yes | Platform or dedicated | +| `isDefault` | boolean | yes | Default designation | +| `consentStatus` | string | yes | Leading consent truth | +| `verificationStatus` | string | yes | Leading verification truth | +| `legacyStatus` | string nullable | no | Secondary diagnostic projection | +| `legacyHealthStatus` | string nullable | no | Secondary diagnostic projection | +| `migrationReviewRequired` | boolean | yes | Secondary diagnostic flag | +| `lastCheckedAt` | string nullable | no | Last stored check timestamp | +| `lastErrorReasonCode` | string nullable | no | Stored diagnostic reason code | + +**Validation rules**: +- List, view, and edit surfaces must display `consentStatus` and `verificationStatus` before any legacy projection fields. +- Legacy fields may remain filterable or visible only when clearly marked and positioned as diagnostics. +- `connectionType` and `isDefault` remain supporting facts, not substitutes for readiness. + +## Surface Truth Hierarchy Rules + +1. Tenant lifecycle is the primary tenant-state axis. +2. Provider consent and provider verification are the primary provider-state axes. +3. RBAC remains a separate tenant-management domain. +4. Legacy tenant app status, provider status, and provider health are diagnostic-only on targeted surfaces. +5. No derived surface contract in this feature may collapse `active`, `connected`, or `consented` into a synthetic `ready` value. + +## No New Persistence + +- No new table is introduced. +- No new enum or reason family is introduced. +- No new persisted readiness summary is introduced. +- Existing persisted legacy fields remain for compatibility, but their surface role is reduced. \ No newline at end of file diff --git a/specs/179-provider-truth-cleanup/plan.md b/specs/179-provider-truth-cleanup/plan.md new file mode 100644 index 00000000..b29f2d9b --- /dev/null +++ b/specs/179-provider-truth-cleanup/plan.md @@ -0,0 +1,322 @@ +# Implementation Plan: Provider Readiness Source-of-Truth Cleanup + +**Branch**: `179-provider-truth-cleanup` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/spec.md` + +**Note**: This plan keeps the current tenant and provider connection model intact. It cleans up which already-stored fields are allowed to act as leading operator truth and explicitly avoids introducing a new readiness model, new persistence, or a new semantic framework. + +## Summary + +Harden the existing tenant and provider operator surfaces so lifecycle, consent, and verification become the only leading truth axes on the targeted pages. The implementation will remove `Tenant.app_status` from primary tenant surfaces, repoint the tenant-detail Provider section from legacy connection `status` and `health_status` to current consent and verification, and reorganize provider connection list, view, and edit surfaces so legacy projections become secondary diagnostics. The narrowest safe tenant-list choice in this slice is to omit a new row-level provider readiness signal rather than invent one. The provider list will instead promote consent and verification to the default-visible connection-state axes, while existing legacy fields remain persisted and available only as secondary diagnostics. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials +**Storage**: PostgreSQL unchanged; no new table, column, or persisted artifact is introduced +**Testing**: Pest 4 feature tests and Livewire-style Filament surface tests through Laravel Sail, reusing existing tenant and provider surface tests plus focused Spec 179 truth-cleanup coverage +**Target Platform**: Laravel monolith web application running in Sail locally and containerized Linux environments in staging and production +**Project Type**: web application +**Performance Goals**: Keep existing DB-only render guarantees, avoid adding uncontrolled per-row provider queries to the tenant list, and keep provider and tenant detail rendering bounded to already-loaded or single-record derived state +**Constraints**: No new readiness enum or score, no new persisted truth, no new presenter or taxonomy framework, no authorization widening, no cross-tenant leakage, no new asset pipeline work, and no false equivalence between `active`, `connected`, `consented`, and `ready` +**Scale/Scope**: Two existing Filament resources, five operator-facing surfaces, one existing tenant provider-summary partial, one tenant verification widget, three legacy badge domains under review, and a focused regression pack around truth hierarchy and filter semantics + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Pre-Research | Post-Design | Notes | +|-----------|--------------|-------------|-------| +| Inventory-first / snapshots-second | PASS | PASS | The feature changes only read-time presentation of already-stored tenant and provider truth. No snapshot or inventory contract changes are introduced. | +| Read/write separation | PASS | PASS | No new mutation path, preview flow, or destructive action is added. Existing provider and tenant mutations remain unchanged. | +| Graph contract path | N/A | N/A | No Graph contract or `config/graph_contracts.php` change is required. | +| Deterministic capabilities | PASS | PASS | Existing provider view/manage and tenant membership rules remain authoritative. | +| RBAC-UX authorization semantics | PASS | PASS | All touched surfaces remain in `/admin`, preserve tenant and workspace scoping, keep non-members at `404`, and keep member-without-capability behavior unchanged. | +| Workspace and tenant isolation | PASS | PASS | No new route or query broadens tenant visibility. Canonical tenantless provider routes remain tenant-aware and scoped. | +| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type or queueing behavior is introduced. Existing verification and provider-run actions keep their current semantics. | +| Data minimization | PASS | PASS | No new persisted data, logs, or surface payloads are introduced. | +| Proportionality / no premature abstraction | PASS | PASS | The plan reuses existing resources, helpers, and partials instead of adding a new readiness service, presenter, or DTO layer. | +| Persisted truth / behavioral state | PASS | PASS | No new state family or table is planned. Existing legacy fields remain persisted for compatibility but lose leading surface prominence. | +| UI semantics / few layers | PASS | PASS | The plan uses direct lifecycle, consent, and verification truth instead of introducing a new readiness interpreter. | +| Badge semantics (BADGE-001) | PASS | PASS | Existing badge semantics remain centralized. Where provider consent or verification need status-like badges, the plan extends `BadgeCatalog` and `BadgeRenderer` through narrow centralized mappings instead of page-local labels or a synthetic readiness domain. | +| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament resources, sections, tables, infolists, view entries, and action groups are reused. No redundant inspect action or empty group is introduced. | +| Filament UX-001 | PASS | PASS | No new screen type is introduced. Existing list, view, and edit surfaces remain within current Filament layout patterns while truth hierarchy changes inside those sections. | +| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays within the current Filament v5 and Livewire v4 stack. | +| Provider registration location | PASS | PASS | No provider or panel registration change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. | +| Global search hard rule | PASS | PASS | `TenantResource` remains globally searchable and already has view and edit pages. `ProviderConnectionResource` remains non-globally-searchable. | +| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing archive, restore, force-delete, credential deletion, and disable flows remain confirmed and capability-gated. | +| Asset strategy | PASS | PASS | No new asset bundle or deploy-time `filament:assets` change is required. | +| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds focused regression coverage for default-visible truth hierarchy, filter semantics, and non-readiness interpretation. | + +## Phase 0 Research + +Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/research.md`. + +Key decisions: + +- Remove tenant-list `app_status` prominence entirely instead of replacing it with a premature readiness badge. +- Reuse the existing tenant-detail Provider section and `providerConnectionState()` helper, but make consent and verification the leading provider summary fields. +- Keep `TenantVerificationReport` as the verification deep-dive and do not repurpose tenant operability gating as provider readiness truth. +- Promote provider connection `consent_status` and `verification_status` to the list’s default-visible current-state axes. +- Split provider connection detail and edit pages into current-state truth versus diagnostics rather than one mixed `Status` section. +- Keep legacy persisted fields and existing projection writers intact; this slice is presentation cleanup only. +- Update provider filters so the main operator filter language follows consent and verification rather than legacy status projections. +- Extend existing surface-truth tests and add one focused provider-surface truth test instead of introducing a broader test framework. + +## Phase 1 Design + +Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/`: + +- `research.md`: planning decisions and rejected alternatives +- `data-model.md`: persistent source truths and derived surface truth contracts +- `contracts/provider-truth-cleanup.openapi.yaml`: internal route and surface contract for tenant and provider truth cleanup +- `quickstart.md`: focused implementation and validation workflow + +Design highlights: + +- The tenant list will remain lifecycle-led and will not gain a new speculative readiness badge in this slice. +- `TenantResource::providerConnectionState()` and the shared Provider infolist entry will be repointed to current consent and verification truth, with legacy `status` and `health_status` relegated to diagnostics. +- `Tenant.app_status` will be removed from the leading tenant-list and tenant-detail operator contract rather than simply hidden behind a toggle. +- `ProviderConnectionResource` will make consent and verification the default-visible list columns and primary detail/edit state section, while legacy status and health become secondary diagnostics. +- No new readiness framework, readiness enum, or provider-state presenter will be introduced. Existing badge infrastructure will be extended only as needed with narrow centralized mappings for provider consent and provider verification, while legacy lifecycle and diagnostic mappings remain centralized. +- Existing provider mutation, verification, and authorization behavior stays intact; only the truth hierarchy on touched surfaces changes. + +## Phase 1 — Agent Context Update + +Run after artifact generation: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Project Structure + +### Documentation (this feature) + +```text +specs/179-provider-truth-cleanup/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── provider-truth-cleanup.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Resources/ +│ │ ├── TenantResource.php +│ │ ├── TenantResource/ +│ │ │ └── Pages/ +│ │ │ ├── ListTenants.php +│ │ │ └── ViewTenant.php +│ │ ├── ProviderConnectionResource.php +│ │ └── ProviderConnectionResource/ +│ │ └── Pages/ +│ │ ├── ListProviderConnections.php +│ │ ├── ViewProviderConnection.php +│ │ └── EditProviderConnection.php +│ └── Widgets/ +│ └── Tenant/ +│ └── TenantVerificationReport.php +├── Models/ +│ ├── Tenant.php +│ ├── ProviderConnection.php +│ ├── ProviderCredential.php +│ └── OperationRun.php +├── Services/ +│ └── Tenants/ +│ └── TenantOperabilityService.php +└── Support/ + ├── Badges/ + │ ├── BadgeCatalog.php + │ ├── BadgeDomain.php + │ └── Domains/ + │ ├── TenantAppStatusBadge.php + │ ├── ProviderConnectionStatusBadge.php + │ └── ProviderConnectionHealthBadge.php + ├── Providers/ + │ ├── ProviderConsentStatus.php + │ └── ProviderVerificationStatus.php + └── Tenants/ + └── TenantLifecycle.php + +resources/ +└── views/ + └── filament/ + ├── infolists/ + │ └── entries/ + │ └── provider-connection-state.blade.php + └── widgets/ + └── tenant/ + └── tenant-verification-report.blade.php + +tests/ +├── Feature/ +│ ├── Filament/ +│ │ ├── TenantLifecycleStatusDomainSeparationTest.php +│ │ ├── TenantTruthCleanupSpec179Test.php +│ │ └── ProviderConnectionsDbOnlyTest.php +│ ├── ProviderConnections/ +│ │ ├── ProviderConnectionTruthCleanupSpec179Test.php +│ │ ├── ProviderConnectionListAuthorizationTest.php +│ │ ├── ProviderConnectionAuthorizationTest.php +│ │ └── RequiredFiltersTest.php +│ ├── Tenants/ +│ │ └── TenantProviderConnectionsCtaTest.php +│ └── Rbac/ +│ └── TenantResourceAuthorizationTest.php +└── Unit/ + └── Badges/ + ├── TenantBadgesTest.php + └── ProviderConnectionBadgesTest.php +``` + +**Structure Decision**: Keep the feature entirely inside the existing Laravel/Filament monolith. Update the current resources, shared provider-summary partial, tenant verification widget contract, and focused test files instead of creating a new provider-readiness subsystem. + +## Complexity Tracking + +> No Constitution Check violations are planned. No exception or bloat trigger is currently justified. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Proportionality Review + +> No new enum, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice. + +- **Current operator problem**: Current tenant and provider surfaces let legacy status fields sound more authoritative than current lifecycle, consent, and verification truth. +- **Existing structure is insufficient because**: The model already stores better truth, but the resources and shared provider-summary partial still foreground the wrong fields. +- **Narrowest correct implementation**: Rewire the existing tenant and provider surfaces to consume the current truth hierarchy without inventing a new readiness model or dropping persisted compatibility fields. +- **Ownership cost created**: Focused resource updates, one shared partial rewrite, and a small regression suite for surface truth hierarchy. +- **Alternative intentionally rejected**: A new readiness enum, new presenter layer, or schema cleanup. +- **Release truth**: Current-release truth cleanup. + +## Implementation Strategy + +### Phase A — Remove Legacy Tenant App Status From Primary Tenant Surfaces + +**Goal**: Make tenant lifecycle the only default-visible tenant status axis on the tenant list and tenant detail identity block. + +| Step | File | Change | +|------|------|--------| +| A.1 | `app/Filament/Resources/TenantResource.php` | Remove `app_status` from the default-visible tenant list column set and stop treating it as a primary list filter. Keep lifecycle and other tenant identity fields intact. | +| A.2 | `app/Filament/Resources/TenantResource.php` | Remove `app_status` from the tenant-detail `Identity` section’s leading status contract rather than simply hiding it behind a toggle. | +| A.3 | `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` and `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Prove tenant list and tenant detail no longer use `app_status` as primary operator truth. | + +### Phase B — Repoint Tenant Detail Provider Summary To Current Provider Truth + +**Goal**: Make tenant detail show provider consent and verification rather than legacy provider connection status and health. + +| Step | File | Change | +|------|------|--------| +| B.1 | `app/Filament/Resources/TenantResource.php` | Refactor `providerConnectionState()` to derive provider summary fields from the chosen Microsoft connection’s `consent_status`, `verification_status`, `is_default`, `last_health_check_at`, and `last_error_reason_code`, while keeping missing-connection handling explicit. | +| B.2 | `resources/views/filament/infolists/entries/provider-connection-state.blade.php` | Rewrite the Provider summary entry so consent and verification are the leading displayed values and legacy `status` and `health_status` move to optional diagnostics. | +| B.3 | `app/Filament/Widgets/Tenant/TenantVerificationReport.php` and its Blade view | Keep the existing verification widget as the deep-dive stored-report surface and ensure the surrounding tenant-detail semantics do not duplicate or contradict it. | +| B.4 | `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php` and `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Prove the tenant-detail provider CTA remains correct and the Provider section no longer implies readiness from legacy fields. | + +### Phase C — Promote Consent And Verification On Provider Connection List Surfaces + +**Goal**: Make the provider connections list answer the current provider-state questions from consent and verification first. + +| Step | File | Change | +|------|------|--------| +| C.1 | `app/Filament/Resources/ProviderConnectionResource.php` | Add default-visible consent and verification columns using existing label helpers and keep connection type and default designation as supporting facts. | +| C.2 | `app/Filament/Resources/ProviderConnectionResource.php` | Move legacy `status` and `health_status` columns to toggleable or explicitly diagnostic visibility and adjust visible-column defaults accordingly. | +| C.3 | `app/Filament/Resources/ProviderConnectionResource.php` | Replace or demote core `status` and `health_status` filters so the leading filter language becomes consent and verification, with legacy filters retained only if clearly diagnostic. | +| C.4 | `tests/Feature/ProviderConnections/RequiredFiltersTest.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` | Prove default-visible columns and filters follow the new hierarchy without breaking DB-only rendering. | + +### Phase D — Split Provider Connection View And Edit Into Current State Versus Diagnostics + +**Goal**: Prevent provider connection detail and edit pages from showing current and legacy status axes as peers. + +| Step | File | Change | +|------|------|--------| +| D.1 | `app/Filament/Resources/ProviderConnectionResource.php` | Replace the mixed `Status` infolist section with a primary current-state section for consent and verification and a secondary diagnostics section for legacy status, legacy health, migration review, and last-error fields. | +| D.2 | `app/Filament/Resources/ProviderConnectionResource.php` | Apply the same separation to the edit form’s read-only state context so mutations happen in the presence of current consent and verification truth, with legacy fields shown only as diagnostics if retained. | +| D.3 | `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` | Prove current state and diagnostics are visually and semantically separate on provider connection view and edit surfaces. | + +### Phase E — Preserve Central Truth Mapping Without Adding A New Badge Framework + +**Goal**: Keep badge and presentation semantics centralized while avoiding a new readiness taxonomy. + +| Step | File | Change | +|------|------|--------| +| E.1 | `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, and existing legacy badge-domain classes | Add narrow centralized badge mappings for `consent_status` and `verification_status`, keep legacy app-status or connection-status mappings diagnostic-only, and do not introduce a synthetic readiness badge domain in this slice. | +| E.2 | `tests/Unit/Badges/TenantBadgesTest.php` and `tests/Unit/Badges/ProviderConnectionBadgesTest.php` | Update unit badge coverage for the centralized provider consent and provider verification mappings and keep legacy diagnostic mapping expectations explicit. | + +### Phase F — Protect Authorization, DB-only Rendering, And Formatting + +**Goal**: Keep the cleanup semantically tight and operationally safe. + +| Step | File | Change | +|------|------|--------| +| F.1 | Existing tenant and provider authorization tests | Re-run and extend current authorization coverage only where surface visibility or filter behavior changes could affect scope boundaries. | +| F.2 | Focused Pest verification pack | Add or update tests proving legacy-status suppression, provider current-state promotion, filter truth alignment, DB-only rendering, and denial semantics. | +| F.3 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the minimum Sail-first verification pack before implementation is considered complete. | + +## Key Design Decisions + +### D-001 — Do not create a new readiness model in a truth-cleanup spec + +The current feature exists to stop misleading primary surfaces, not to coin a new readiness taxonomy. Lifecycle, consent, and verification already provide the necessary authoritative inputs. + +### D-002 — The tenant list should consciously omit provider readiness rather than compress it unsafely + +Any row-level provider readiness badge would require non-trivial aggregation and semantic compression across provider connections. Omitting that signal is safer and matches the spec’s fallback allowance. + +### D-003 — Reuse the existing tenant-detail Provider section instead of adding another summary layer + +`providerConnectionState()` and the shared infolist entry already own the tenant-detail provider summary. Repointing that contract is narrower than building a new widget or presenter. + +### D-004 — Provider detail pages need one current-state section and one diagnostics section + +Equal-weight status groupings are the core current UI problem. Separating current state from diagnostics is the smallest structural change that makes the leading truth obvious. + +### D-005 — Keep legacy persisted fields, but remove their authority on targeted surfaces + +The model and some jobs still project `status` and `health_status`, and tenant `app_status` still exists in storage. This slice preserves compatibility while eliminating misleading prominence. + +### D-006 — Do not use tenant operability gating as provider readiness truth + +`TenantOperabilityService` answers lifecycle and capability eligibility questions. It is not a substitute for consent or verification health and must stay separate in both implementation and messaging. + +## Risk Assessment + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Removing `app_status` from the tenant list may reduce scanability before a later readiness spec lands | Medium | Medium | Keep the tenant list lifecycle-led, keep the provider CTA obvious on tenant detail, and treat any future list signal as follow-up work rather than a rushed addition now. | +| Tenant detail may still show the wrong provider connection when multiple connections exist | High | Medium | Continue to prefer the default Microsoft connection when present and render an explicit missing or configured state rather than an optimistic readiness label. | +| Provider list could retain legacy semantics if filters change more slowly than columns | High | Medium | Update both default-visible columns and core filter set together and protect them with `RequiredFiltersTest` plus a focused truth test. | +| Existing tests currently asserting `app_status` visibility will fail noisily | Medium | High | Update the intentional tenant truth-separation test first and add a spec-specific replacement that encodes the new contract. | +| New tenant-list provider queries could create unnecessary DB cost | Medium | Low | Avoid adding a new per-row provider signal on the tenant list in this slice. | + +## Test Strategy + +- Update `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` so it protects the new tenant truth hierarchy instead of asserting legacy `app_status` prominence. +- Add `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` to cover tenant list default-visible truth and tenant detail Provider section semantics. +- Add `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` to cover provider connection list, view, and edit truth hierarchy. +- Update `tests/Feature/ProviderConnections/RequiredFiltersTest.php` so the core filter contract follows consent and verification instead of legacy status or health. +- Update `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php` so visible-column expectations and DB-only rendering stay valid after the list contract changes. +- Keep `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php` and existing authorization tests in the focused pack so route and scope semantics do not regress. +- Keep tenant global-search safety and provider-connection global-search exclusion in the focused pack so discovery behavior does not regress. +- Finish the feature with an explicit no-migration and no-new-persistence diff validation gate in addition to runtime regression coverage. +- Run the focused Sail-first pack defined in `quickstart.md`, then run `vendor/bin/sail bin pint --dirty --format agent` before closing implementation. + +## Constitution Check (Post-Design) + +Re-check result: PASS. + +- Livewire v4.0+ compliance: preserved because the plan stays within the current Filament v5 + Livewire v4 resources, widgets, and pages. +- Provider registration location: unchanged; no panel or provider registration work is needed beyond the existing `bootstrap/providers.php` setup. +- Global-searchable resources: `TenantResource` remains globally searchable and already has both view and edit pages; `ProviderConnectionResource` remains non-globally-searchable, so no global-search conflict is introduced. +- Destructive actions: no new destructive action is added. Existing tenant archive, restore, and force-delete flows and existing provider credential or connection-state mutations remain confirmation-protected and capability-gated. +- Asset strategy: unchanged; no new assets are introduced and the deploy-time `php artisan filament:assets` process remains unaffected. +- Testing plan: focused Pest coverage will protect tenant legacy-status suppression, tenant-detail provider summary truth, provider current-state promotion, filter hierarchy, DB-only rendering, and unchanged authorization boundaries. \ No newline at end of file diff --git a/specs/179-provider-truth-cleanup/quickstart.md b/specs/179-provider-truth-cleanup/quickstart.md new file mode 100644 index 00000000..d0f71501 --- /dev/null +++ b/specs/179-provider-truth-cleanup/quickstart.md @@ -0,0 +1,147 @@ +# Quickstart: Provider Readiness Source-of-Truth Cleanup + +## Goal + +Validate that tenant and provider operator surfaces no longer elevate `Tenant.app_status`, `ProviderConnection.status`, or `ProviderConnection.health_status` as leading truth, and that lifecycle, consent, and verification now answer the primary operator questions. + +## Prerequisites + +1. Start Sail. +2. Prepare one workspace member with at least one visible tenant and provider connection management access. +3. Seed or create tenant scenarios for: + - active tenant with `app_status` populated but provider verification `unknown` + - onboarding tenant with granted consent and blocked verification + - tenant with no default Microsoft provider connection +4. Seed or create provider connection scenarios for: + - consent `granted`, verification `degraded`, legacy `status=connected`, legacy `health_status=ok` + - consent `required` or `revoked`, verification `blocked`, legacy `status` still optimistic + - configured connection that has never been verified + - disabled connection with retained legacy status or health values +5. Prepare one non-member or cross-workspace actor for deny-as-not-found checks. + +## Implementation Validation Order + +### 1. Run the current baseline tenant and provider surface tests + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php +vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php +``` + +Expected outcome: +- Existing tenant detail, provider connection pages, canonical tenantless provider route, and provider CTA behavior still render correctly before the cleanup changes are applied. + +### 2. Run focused tenant truth-cleanup coverage + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php +``` + +Expected outcome: +- Tenant list no longer shows `app_status` as default-visible truth. +- Tenant detail keeps lifecycle separate from provider consent and verification. +- Tenant detail Provider section stops leading with legacy `status` and `health_status`. + +### 3. Run focused provider truth-cleanup coverage + +```bash +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +``` + +Expected outcome: +- Provider connection list promotes consent and verification to the default-visible columns. +- Provider connection view and edit pages show current state separately from diagnostics. +- Legacy `status` and `health_status` remain secondary or hidden by default. + +### 4. Re-run authorization and discovery-safety coverage on touched resources + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php +``` + +Expected outcome: +- Workspace and tenant scoping remain unchanged. +- Non-members still receive deny-as-not-found behavior. +- Members without capability do not gain new visibility or mutation access. +- Tenant global search remains workspace-safe. +- Provider connections remain excluded from global search. + +### 5. Run badge-mapping coverage + +```bash +vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php +vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php +``` + +Expected outcome: +- Lifecycle, provider consent, provider verification, and retained legacy diagnostic badges resolve through centralized badge mappings only. + +### 6. Format touched files + +```bash +vendor/bin/sail bin pint --dirty --format agent +``` + +Expected outcome: +- All touched implementation files conform to project formatting rules. + +### 7. Run the final focused verification pack + +```bash +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php +vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php +vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php +vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php +vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php +vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php +``` + +Expected outcome: +- The targeted tenant and provider surfaces keep truthful status hierarchy, keep DB-only rendering where already promised, and preserve existing authorization boundaries. + +### 8. Validate no migration and no new persisted truth were introduced + +```bash +git diff --name-only -- database/migrations app/Models app/Support/Providers app/Support/Badges/BadgeDomain.php app/Support/Badges/BadgeCatalog.php app/Support/Badges/Domains/ProviderConsentStatusBadge.php app/Support/Badges/Domains/ProviderVerificationStatusBadge.php app/Support/Badges/Domains/TenantAppStatusBadge.php app/Support/Badges/Domains/ProviderConnectionStatusBadge.php app/Support/Badges/Domains/ProviderConnectionHealthBadge.php specs/179-provider-truth-cleanup +``` + +Expected outcome: +- No new migration file is introduced for this feature. +- No new persisted readiness artifact or new provider-status family appears outside the approved centralized badge mapping work. +- Central badge changes are limited to the approved consent, verification, and retained diagnostic badge mappers. + +## Manual Smoke Check + +1. Open `/admin/tenants` and confirm lifecycle remains visible while `app_status` is no longer a default-visible status badge. +2. Open one tenant detail page and confirm the `Provider` section now leads with consent and verification, not connection `status` and `health`. +3. Confirm the `Verification report` widget still provides the deeper stored verification surface and does not depend on outbound HTTP at render time. +4. Open `/admin/provider-connections` and confirm consent and verification are the primary default-visible state columns. +5. Confirm any retained legacy `status` or `health` values are secondary diagnostics rather than peer badges. +6. Open a provider connection view page and confirm `configured`, `connected`, or `consented` no longer read as equivalent to verified or ready. +7. Open a provider connection edit page and confirm current consent and verification context are visible before any mutation, while diagnostics remain secondary. +8. Repeat one tenant and one provider URL as a non-member or out-of-scope actor and confirm deny-as-not-found behavior still holds. + +## Non-Goals For This Slice + +- No database migration. +- No new readiness enum, score, or persisted summary. +- No change to verification queueing, `OperationRun` semantics, or provider mutation workflows. +- No removal of legacy database fields or projection writers. \ No newline at end of file diff --git a/specs/179-provider-truth-cleanup/research.md b/specs/179-provider-truth-cleanup/research.md new file mode 100644 index 00000000..0b870f2f --- /dev/null +++ b/specs/179-provider-truth-cleanup/research.md @@ -0,0 +1,74 @@ +# Phase 0 Research: Provider Readiness Source-of-Truth Cleanup + +## Decision: Remove legacy `app_status` from tenant list primary truth and do not replace it with a new row-level readiness badge in this slice + +**Rationale**: The current tenant list exposes `app_status` as a default-visible badge and filter even though the feature goal is to stop projecting frozen or semantically stale provider truth from tenant-level legacy fields. A new per-row provider readiness badge would require choosing one provider connection, compressing multi-connection state, and implicitly inventing a readiness model. The narrowest safe move is to let the tenant list remain lifecycle-led and to consciously omit provider readiness from the row when it cannot be stated without semantic overreach. + +**Alternatives considered**: +- Introduce a new tenant readiness badge on the list: rejected because it would create a new semantic layer before the later readiness spec. +- Keep `app_status` but toggle it off by default: rejected because the spec explicitly warns that hidden-by-default legacy truth still leaks back into operator interpretation. + +## Decision: Reuse the existing tenant Provider section, but repoint it from legacy `status` and `health_status` to consent and verification + +**Rationale**: Tenant detail already has a dedicated `Provider` section backed by `TenantResource::providerConnectionState()` and the shared Blade entry `provider-connection-state.blade.php`. That section is the narrowest place to present current provider truth without inventing a new widget or presenter. The helper should continue to resolve the default Microsoft provider connection or a missing-connection state, but its leading fields should become `consent_status` and `verification_status`, with legacy status and health demoted to optional diagnostics. + +**Alternatives considered**: +- Build a new tenant provider summary widget: rejected because the current section already exists and the spec does not justify another surface layer. +- Remove the Provider section entirely and rely only on the verification widget: rejected because the spec requires clear consent and verification semantics, and the verification widget is a deep-dive report, not the only summary contract. + +## Decision: Keep `TenantVerificationReport` as the verification deep-dive and do not turn `TenantOperability` into provider truth + +**Rationale**: `TenantVerificationReport` already gives tenant detail a DB-only verification deep-dive using the latest `provider.connection.check` run. `TenantOperabilityService` and `verificationReadinessOutcome()` answer whether a verification action may be started in a given lifecycle and capability context, not whether the provider is operationally healthy. The cleanup should continue to use the verification report for stored verification detail and must not promote operability gating into readiness truth. + +**Alternatives considered**: +- Use `TenantOperabilityDecision` as a tenant provider readiness signal: rejected because it mixes lifecycle and permission gating with provider-state truth. +- Collapse verification detail into the Provider section and remove the verification widget: rejected because the widget already provides the deeper stored-report surface and no new readiness model is needed. + +## Decision: Promote `consent_status` and `verification_status` to the default-visible provider connection list axes + +**Rationale**: The provider connections list currently defaults to `status` and `health_status`, even though the model already stores `consent_status` and `verification_status` and the resource already has label helpers for them. Surfacing consent and verification in the table is the clearest way to answer whether a connection is consented and whether it has been checked. Legacy `status` and `health_status` may remain available as hidden diagnostics for compatibility, but they should stop being the primary scan path. + +**Alternatives considered**: +- Keep `status` and `health_status` visible because they are shorter to scan: rejected because that preserves the competing-truth problem the spec exists to remove. +- Remove legacy columns completely from the list: rejected because the fields may still help diagnostics and internal compatibility during the transition. + +## Decision: Split provider connection detail and edit surfaces into current state versus diagnostics instead of one equal-weight Status section + +**Rationale**: The current provider connection infolist and form both render consent, verification, legacy status, and health at the same hierarchy level in one `Status` section. The cleanup should create a primary current-state section for consent and verification, and a separate diagnostics section for legacy status, health, migration review, and last-error metadata. This keeps the existing resource and existing fields while making the leading truth explicit. + +**Alternatives considered**: +- Keep one Status section and merely reorder the fields: rejected because equal visual grouping would still overstate the legacy fields. +- Delete legacy fields from detail and edit immediately: rejected because the spec allows the fields to remain as long as they stop acting like leading truth. + +## Decision: Extend the existing badge catalog with narrow provider consent and provider verification mappings while keeping legacy badge domains diagnostic-only + +**Rationale**: BADGE-001 requires status-like values to render through `BadgeCatalog` and `BadgeRenderer`. `BadgeCatalog` already centralizes legacy `TenantAppStatus`, `ProviderConnectionStatus`, and `ProviderConnectionHealth` mappings, so the narrowest compliant move is to add centralized provider consent and provider verification mappings inside the existing badge system. This keeps lifecycle, consent, verification, and legacy diagnostics on one central semantic path without creating a synthetic `Ready` or `ProviderReadiness` domain. + +**Alternatives considered**: +- Continue using plain labels or local helper output for consent and verification: rejected because BADGE-001 requires centralized status-like badge semantics. +- Add a new `ProviderReadiness` badge domain: rejected because it would front-run the later readiness spec and violate proportionality. +- Remove legacy badge mappers entirely: rejected because diagnostic surfaces and internal compatibility still reference them. + +## Decision: Keep legacy persisted fields and current projection writers intact in this slice + +**Rationale**: `ProviderConnection` still projects `status` and `health_status` during classification and enable or disable flows, and tenant records still persist `app_status`. The spec explicitly forbids a schema change or aggressive technical removal. The correct scope is presentation cleanup first, not domain-field deletion. + +**Alternatives considered**: +- Drop legacy columns now: rejected because existing jobs, projections, audit metadata, and compatibility paths still write or reference them. +- Stop writing the fields immediately as part of this spec: rejected because it would turn a surface-truth cleanup into a broader behavioral change. + +## Decision: Update provider list filters to follow the leading truth hierarchy + +**Rationale**: The current provider list filter set centers `status` and `health_status`. If the table’s leading truth becomes consent and verification, the list’s core filters should follow the same hierarchy. Legacy filters may remain only as explicitly diagnostic filters, not as the primary filter language for current provider state. + +**Alternatives considered**: +- Leave legacy filters untouched while changing only columns: rejected because that keeps the old semantics in the main operator interaction path. +- Remove all legacy filters outright: rejected because diagnostic filtering may still be useful while the fields remain persisted. + +## Decision: Extend existing truth-separation tests and add one focused provider-surface truth test instead of inventing a large new test harness + +**Rationale**: The repo already has `TenantLifecycleStatusDomainSeparationTest`, `ProviderConnectionsDbOnlyTest`, `RequiredFiltersTest`, and badge tests. The spec’s main risk is business-truth regression on a few surfaces. Focused feature tests that assert default-visible fields, section hierarchy, and filter names are enough to protect this contract without creating a broad new testing abstraction. + +**Alternatives considered**: +- Rely on manual browser inspection only: rejected because truth regressions on default-visible surfaces are easy to reintroduce. +- Create a large generic presenter test framework first: rejected because the cleanup intentionally avoids adding a new presenter layer. \ No newline at end of file diff --git a/specs/179-provider-truth-cleanup/spec.md b/specs/179-provider-truth-cleanup/spec.md new file mode 100644 index 00000000..aab16139 --- /dev/null +++ b/specs/179-provider-truth-cleanup/spec.md @@ -0,0 +1,226 @@ +# Feature Specification: Spec 179 - Provider Readiness Source-of-Truth Cleanup + +**Feature Branch**: `179-provider-truth-cleanup` +**Created**: 2026-04-04 +**Status**: Draft +**Input**: User description: "Spec 179 — Provider Readiness Source-of-Truth Cleanup" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + tenant +- **Primary Routes**: + - `/admin/tenants` + - `/admin/tenants/{tenant}` + - `/admin/provider-connections` + - `/admin/provider-connections/{record}` + - `/admin/provider-connections/{record}/edit` +- **Data Ownership**: + - Tenant-owned truth already exists in tenant lifecycle state, provider connection consent state, provider connection verification state, provider connection metadata, and stored verification outputs tied to a tenant. + - Workspace-owned context remains the selected workspace and its membership filter, which determines which tenants and provider connections are visible in `/admin`. + - This feature introduces no new persisted truth. It only removes misleading prominence from existing legacy status fields and reorders presentation of already-stored truth. +- **RBAC**: + - Workspace membership remains required for the tenant and provider resources in `/admin`. + - Tenant membership remains the isolation boundary for tenant and provider records. + - Existing provider view and provider manage capability checks remain authoritative for view and mutation surfaces. + - No authorization broadening is allowed; non-members remain deny-as-not-found and members without capability remain forbidden for protected actions. + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Surface Type | 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 | +|---|---|---|---|---|---|---|---|---|---|---|---| +| Tenant list | CRUD / List-first Resource | Full-row click into tenant view | required | Header `Add tenant`; non-inspect row actions stay in `More` | Existing archive, restore, and force-delete actions remain secondary in `More`; unchanged by this spec | `/admin/tenants` | `/admin/tenants/{tenant}` | Active workspace, tenant name, environment, and existing tenant scope | Tenants / Tenant | Tenant lifecycle, plus an optional bounded provider signal only when it is derived from current provider truth | none | +| Tenant view | Detail / view-first resource | Tenant view page itself | forbidden | Header `ActionGroup` remains the secondary action container | Existing destructive lifecycle actions stay grouped and confirmed; unchanged by this spec | `/admin/tenants` | `/admin/tenants/{tenant}` | Tenant identity, workspace context, and existing tenant-scoped widgets | Tenant | Tenant lifecycle first; provider consent and provider verification separate from lifecycle and RBAC | none | +| Provider connections list | CRUD / List-first Resource | Full-row click into provider connection view | required | Header `New connection`; operational and management actions stay in row `More` | Existing set-default, enable or disable, and credential mutations stay secondary and confirmed where already required | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Active workspace plus tenant filter or tenant query context, tenant name, provider name, and default marker | Provider Connections / Provider Connection | Consent state and verification state, not legacy connection status or health | Tenantless canonical route with tenant-aware filtering | +| Provider connection view | Detail / view-first resource | Provider connection view page itself | forbidden | Header actions and grouped header mutations remain the secondary action area | Existing credential and connection-state mutations remain grouped, confirmed, and capability-gated | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Tenant identity, provider identity, connection type, and default marker | Provider Connection | Consent state and verification state as the leading diagnosis | none | +| Provider connection edit | Edit form | Provider connection edit page itself | forbidden | Save and cancel remain the form actions; grouped header actions remain secondary | Existing credential and connection-state mutations remain grouped, confirmed, and capability-gated | `/admin/provider-connections` | `/admin/provider-connections/{record}/edit` | Tenant identity, provider identity, connection type, and default marker | Provider Connection | Current consent and verification context before any configuration mutation; legacy fields are not allowed to dominate the form | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---| +| Tenant list | Workspace or tenant operator | List | Which tenants are in a normal lifecycle state, and is there any trustworthy provider concern I should notice without assuming readiness? | Tenant name, lifecycle, environment, and either a bounded provider signal from current provider truth or no provider signal at all | Legacy app-status projections and frozen provider surrogates are not default-visible | lifecycle, optional provider consent or verification summary | none on the list itself; existing row actions remain unchanged | Open tenant, Add tenant | Existing archive, restore, and force-delete actions remain secondary and are not reinterpreted as provider readiness signals | +| Tenant view | Tenant operator | Detail | What is this tenant's lifecycle, and what does the current provider base actually prove? | Lifecycle summary, provider consent, provider verification, verification summary, and RBAC shown as a separate domain when present | Legacy app status, historical provider projections, and raw verification internals stay diagnostic-only if retained at all | lifecycle, provider consent, provider verification, RBAC status as a separate domain | Existing verification and tenant management actions remain unchanged | Provider connections, Verify configuration, Edit, Grant admin consent, Open in Entra | Existing archive, restore, and force-delete actions remain grouped, confirmed, and clearly separate from provider truth | +| Provider connections list | Tenant operator or tenant admin | List | Which connections are consented, which are verified, and which still need attention? | Tenant, provider, default marker, consent state, verification state, and connection type when useful | Legacy connection status, legacy health, migration-review metadata, and raw error text stay secondary | consent, verification, default designation | Existing provider operations and management actions remain unchanged | Open provider connection, New connection | Set as default, enable or disable connection, dedicated-credential actions, and provider-run actions remain secondary and capability-gated | +| Provider connection view | Tenant operator or tenant admin | Detail | Is this connection only configured, or has it actually been checked and verified? | Consent state, verification state, provider identity, default designation, and next-step links | Legacy status and health, raw identifiers, and low-level technical fields stay secondary | consent, verification, connection type | Existing connection-check and provider-management actions remain unchanged | Check connection, Grant admin consent, Edit, View last check run | Enable dedicated override, rotate or delete dedicated credential, revert to platform, enable or disable connection, and set default remain grouped, confirmed, and authorized | +| Provider connection edit | Tenant admin | Edit | What can I safely change, and what do the current consent and verification states tell me before I mutate this connection? | Editable configuration, consent state, verification state, save and cancel, and the same tenant scope cues as the view page | Legacy status and health, raw technical metadata, and historical projections stay diagnostic-only if retained | consent, verification, connection type | Existing provider configuration mutation scope remains unchanged | Save changes, Cancel, Check connection, View last check run | Existing dedicated-credential and enable or disable actions remain grouped, confirmed, and authorized | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: No. +- **New persisted entity/table/artifact?**: No. +- **New abstraction?**: No. +- **New enum/state/reason family?**: No. +- **New cross-domain UI framework/taxonomy?**: No. +- **Current operator problem**: A tenant or provider connection can currently look active, connected, or healthy on a leading surface even when the provider basis is unclear, stale, blocked, or semantically contradicted by newer status fields. +- **Existing structure is insufficient because**: The current model already has clearer truth axes for lifecycle, consent, and verification, but primary tenant and provider surfaces still elevate legacy fields such as tenant app status, provider connection status, and provider health as if they were current truth. +- **Narrowest correct implementation**: Rework only the existing tenant and provider presentation layers, default-visible fields, filters, and badge mappings so that lifecycle, consent, and verification become the primary operator-facing truth without inventing a new readiness model. +- **Ownership cost**: The repo takes on focused UI cleanup, badge or filter adjustments, and regression coverage for truth ordering, but no new persistence, taxonomy, or cross-cutting semantic framework. +- **Alternative intentionally rejected**: A new tenant readiness enum, a full readiness assessment, or a new persisted summary artifact is rejected for this slice because the immediate risk is conflicting surface truth, not the absence of another status model. +- **Release truth**: Current-release truth cleanup that also prepares the codebase for later readiness and gating work. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Read truthful tenant surfaces (Priority: P1) + +As an operator, I can open the tenant list and tenant view and understand tenant lifecycle separately from provider consent and provider verification, so an `active` tenant does not silently read as provider-ready. + +**Why this priority**: The biggest current risk is false confidence on the main tenant-facing surfaces. + +**Independent Test**: Can be fully tested by seeding tenants whose lifecycle, legacy app status, consent, and verification states disagree, then rendering the tenant list and tenant view to verify that lifecycle remains separate and legacy app status no longer drives the leading operator truth. + +**Acceptance Scenarios**: + +1. **Given** a tenant is `active` and its provider verification is `unknown`, **When** the operator opens the tenant list or tenant view, **Then** the surface shows lifecycle without implying the tenant is provider-ready. +2. **Given** a tenant is onboarding, consent is granted, and verification is blocked, **When** the operator opens the tenant view, **Then** onboarding remains the tenant lifecycle truth and the provider issue remains separately visible. +3. **Given** a tenant still has a populated legacy app-status value, **When** the operator opens a primary tenant surface, **Then** that legacy field is not presented as the current leading status. + +--- + +### User Story 2 - Diagnose provider connections from current axes (Priority: P1) + +As an operator, I can use provider connection list, view, and edit surfaces to see consent and verification as the leading provider state axes, even when legacy connection status fields still contain data. + +**Why this priority**: Provider connection pages are the canonical diagnostic surfaces for this domain and must stop showing parallel state systems as peers. + +**Independent Test**: Can be fully tested by seeding provider connections whose legacy `status` and `health_status` disagree with `consent_status` and `verification_status`, then verifying that list, view, and edit surfaces elevate consent and verification while demoting or hiding the legacy fields. + +**Acceptance Scenarios**: + +1. **Given** a provider connection has consent granted, verification degraded, legacy status `connected`, and legacy health `ok`, **When** the operator opens the provider connections list or detail pages, **Then** consent and degraded verification are the leading truths and the legacy fields do not appear at equal prominence. +2. **Given** a provider connection is configured but never verified, **When** the operator opens its view or edit page, **Then** the page makes clear that the connection is not yet proven healthy. +3. **Given** a provider connection is disabled, **When** the operator opens the list or detail page, **Then** connection-state actions remain secondary and do not override consent or verification semantics. + +--- + +### User Story 3 - Avoid false readiness language across surfaces (Priority: P2) + +As an operator, I can move between tenant and provider surfaces without seeing `active`, `connected`, `consented`, or similar labels treated as if they automatically mean `ready`. + +**Why this priority**: This slice is a truth cleanup, not a final readiness model, so it must reduce semantic overreach before any follow-up readiness work starts. + +**Independent Test**: Can be fully tested by rendering primary tenant and provider surfaces for records that look favorable in one status family but unfavorable in another and verifying that no default-visible wording or badge composition collapses them into one readiness conclusion. + +**Acceptance Scenarios**: + +1. **Given** a tenant is active and the default provider connection is consented but verification is error or blocked, **When** the operator inspects the tenant and provider surfaces, **Then** neither surface implies that the tenant is operationally ready. +2. **Given** the tenant list does not have enough trustworthy provider truth to summarize one tenant safely, **When** the list renders, **Then** it omits that provider readiness signal instead of inventing a legacy or optimistic substitute. +3. **Given** RBAC status is also shown on the tenant view, **When** the page renders, **Then** RBAC remains a separate domain and does not masquerade as provider readiness. + +### Edge Cases + +- Legacy app status or legacy provider status fields still contain optimistic values that contradict current verification truth. +- A tenant has no provider connection or has multiple provider connections, so the tenant list cannot safely compress provider truth into one scan-time signal. +- Consent is granted but verification has never been run, so the system must distinguish configured or consented from verified. +- Verification is stale, blocked, degraded, or error while the tenant lifecycle remains active. +- RBAC health is present on the tenant view at the same time as provider truth and must remain visibly separate. +- A non-member or cross-workspace actor attempts to reach tenant or provider surfaces and must continue to see deny-as-not-found behavior without new hints. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new long-running workflow, and no new write path. Existing verification and provider-run actions remain as they are today. The work is limited to truth cleanup on existing operator-facing surfaces. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It adds no new persistence, abstraction, state family, presenter framework, or readiness taxonomy. It cleans up leading surface truth using already-existing lifecycle, consent, verification, and verification-report data. + +**Constitution alignment (OPS-UX):** No new `OperationRun` type, execution surface, or feedback path is introduced. Existing verification and provider-operation flows keep their current run semantics. + +**Constitution alignment (RBAC-UX):** The work stays in the admin plane at `/admin` on existing tenant and provider resources. Non-members and out-of-scope users remain deny-as-not-found. In-scope members without capability remain forbidden for protected actions. Server-side authorization remains the source of truth. Tenant global search stays safe because the tenant resource already has view and edit pages; provider connections remain non-globally-searchable for this slice. Existing destructive-like actions keep their current confirmation requirements. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No auth handshake behavior is changed. + +**Constitution alignment (BADGE-001):** Status badge semantics remain centralized. This feature updates leading badge usage so tenant lifecycle, provider consent, and provider verification are the primary operator-facing status hierarchy on targeted surfaces. If provider consent or provider verification require new mappings, they MUST be added through `BadgeCatalog` and `BadgeRenderer` inside the existing badge system rather than through page-local labels or a synthetic readiness framework. + +**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament tables, infolists, widgets, form sections, action groups, and centralized badge helpers. No local replacement markup or page-local status language is needed. No exception is expected. + +**Constitution alignment (UI-NAMING-001):** Operator-facing copy must keep `Lifecycle`, `Consent`, and `Verification` as distinct questions. This slice must not introduce `Ready` as a new leading label. If any legacy field remains visible, it must be named as diagnostic or legacy rather than as primary status. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** Each affected surface keeps one primary inspect or open model, and the surface tables above define collection routes, detail routes, action placement, scope signals, and the default-visible truth. The targeted surfaces must stop treating `app_status`, provider `status`, or provider `health_status` as peer signals to lifecycle, consent, or verification. + +**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Tenant surfaces show lifecycle and, when warranted, provider truth. Provider surfaces show consent and verification first. Diagnostics such as legacy fields, raw identifiers, or historical projections must remain secondary. Existing mutation scope and safe-execution patterns remain unchanged. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from existing lifecycle, consent, and verification truth is sufficient once legacy prominence is removed. This feature must not add a new readiness interpreter or semantic wrapper. Tests must protect business truth by proving the absence of false readiness and the demotion of conflicting legacy signals. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Affected surfaces keep one primary inspect model, redundant `View` actions remain absent, empty action groups are not introduced, and destructive actions keep their current placement and confirmation rules. UI-FIL-001 is satisfied with no approved exception. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** No new screen type is introduced. Tenant view continues to use a view page with widgets and infolist content. Provider connection view continues to use a view page. Provider connection edit remains a section-based edit form. List pages keep search, sort, and filters, but the default-visible status dimensions and core filters must align to leading truth rather than legacy projections. + +### Functional Requirements + +- **FR-179-001 (Lifecycle remains its own truth)**: Targeted tenant surfaces MUST present tenant lifecycle as a distinct domain and MUST NOT use lifecycle labels to imply provider readiness. +- **FR-179-002 (Tenant app status removed from leading truth)**: Targeted tenant surfaces MUST NOT present `app_status` as a default-visible primary badge, summary field, or equivalent current-status signal. +- **FR-179-003 (Tenant list uses only trustworthy provider signal)**: The tenant list MUST either show a bounded provider signal derived from current provider consent and verification truth or omit provider status for that row. It MUST NOT rely on `app_status` or another legacy projection as the scan-time provider indicator. +- **FR-179-004 (Tenant detail separates provider axes)**: When tenant detail surfaces provider state, they MUST show provider consent and provider verification as separate axes. +- **FR-179-005 (No false readiness on tenant detail)**: Tenant detail MUST NOT allow `active`, `connected`, `configured`, or `consented` signals to read as equivalent to verified, healthy, or ready when verification is unknown, blocked, degraded, error, or absent. +- **FR-179-006 (RBAC remains separate)**: If RBAC status appears on tenant detail, it MUST remain visibly separate from provider consent and provider verification and MUST NOT substitute for provider readiness. +- **FR-179-007 (Provider list leading axes)**: Provider connection list MUST use consent state and verification state as the primary default-visible connection-state axes. +- **FR-179-008 (Legacy provider status demoted)**: Provider connection list, view, and edit surfaces MUST NOT present legacy `status` or `health_status` as equal-priority peer truth next to consent and verification. +- **FR-179-009 (Legacy diagnostics handling)**: If legacy provider `status` or `health_status` remains visible anywhere on the targeted provider surfaces, it MUST be clearly labeled and positioned as secondary diagnostics, historical projection, or technical metadata. +- **FR-179-010 (Provider detail distinguishes configured from proven)**: Provider connection view and edit surfaces MUST make it clear whether a connection is merely configured or consented versus operationally checked and verified. +- **FR-179-011 (Badge truth alignment)**: Centralized badge and presentation mappings used by the targeted tenant and provider surfaces MUST elevate lifecycle, consent, and verification over tenant app status, provider connection status, and provider connection health. +- **FR-179-012 (No conflicting equal-prominence status mosaic)**: Targeted tenant and provider surfaces MUST NOT render multiple semantically overlapping status badges or fields at the same visual level when only one is authoritative for that operator question. +- **FR-179-013 (List filters follow leading truth)**: Default-visible list filters and core list columns on affected surfaces MUST align with the leading truth hierarchy. Legacy status filters may remain only if they are explicitly secondary and do not present themselves as the primary way to judge current provider readiness. +- **FR-179-014 (No new persisted truth)**: The feature MUST ship without a new table, without a new persisted readiness artifact, and without a new status family. +- **FR-179-015 (Authorization boundaries unchanged)**: Workspace scoping, tenant isolation, and capability checks on all touched surfaces MUST remain unchanged. This cleanup MUST NOT widen visibility or bypass existing server-side authorization. +- **FR-179-016 (Global search and discovery stay safe)**: Tenant search behavior may remain in place because the tenant resource already has view and edit pages. Provider connections remain non-globally-searchable in this slice, and no touched surface may introduce new search-based status leakage. +- **FR-179-017 (Regression coverage is mandatory)**: Regression coverage MUST verify tenant legacy-status suppression, provider primary-status promotion, contradictory multi-status demotion, badge truth alignment, and global-search safety. Final implementation validation MUST verify the absence of schema requirements and new persisted truth. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant list | `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/TenantResource/Pages/ListTenants.php` | `Add tenant` | `recordUrl()` opens the tenant view; one row-primary action slot is preserved | Primary overflow includes `Resume onboarding` or `View related onboarding`; `More` contains `Open`, `Edit`, `Grant admin consent`, `Open in Entra`, `Sync`, `Verify configuration`, `Restore`, `Force delete`, `Archive`, and RBAC helper actions | `Sync selected` stays grouped under `More` | `Add tenant` | n/a | n/a | existing only | Action Surface Contract remains satisfied. This spec changes status truth, not the action inventory. | +| Tenant view | `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` | `Provider connections`, `Edit`, `View related onboarding`, `Grant admin consent`, `Open in Entra`, `Verify configuration` inside the existing header `ActionGroup` | direct page | n/a | none | n/a | Existing grouped lifecycle and verification actions remain | n/a | existing only | No new header mutations are introduced. The change is in default-visible status semantics only. | +| Provider connections list | `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php` | `New connection` | `recordUrl()` opens the provider connection view | `More` contains `Edit`, `Check connection`, `Inventory sync`, `Compliance snapshot`, `Set as default`, `Enable dedicated override`, `Rotate dedicated credential`, `Delete dedicated credential`, `Revert to platform`, `Enable connection`, and `Disable connection` | none | `New connection` | n/a | n/a | existing only | Canonical tenantless route stays `/admin/provider-connections`; this spec changes which status fields are treated as primary truth. | +| Provider connection view | `app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` | `Grant admin consent`, `Edit`, plus grouped connection-management actions | direct page | n/a | none | n/a | `Check connection`, `View last check run`, and existing grouped dedicated-credential and connection-state actions remain | n/a | existing only | Existing confirmed and audited mutations remain unchanged. This spec only changes which state is read as primary. | +| Provider connection edit | `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` | Existing grouped connection-management actions remain | direct page | n/a | none | n/a | Existing grouped connection-management actions remain | Default save and cancel actions remain | existing only | UX-001 remains satisfied. The edit surface must show consent and verification as current context without letting legacy fields dominate. | + +### Key Entities *(include if feature involves data)* + +- **Tenant lifecycle**: The tenant's own lifecycle truth, such as onboarding, active, archived, or equivalent states that describe whether the tenant is in the normal lifecycle. +- **Provider consent state**: The stored state that answers whether the provider permission or consent step has been completed. +- **Provider verification state**: The stored state that answers whether the provider connection has been checked and what that check currently proves. +- **Legacy tenant app status**: An older projected tenant-level field that may still exist in storage but is no longer allowed to act as leading operator truth on targeted surfaces. +- **Legacy provider connection status and health**: Older connection-level projections that may remain in storage for internal compatibility but are no longer allowed to compete with consent and verification on targeted surfaces. +- **Bounded provider signal**: A tightly-scoped tenant-level summary derived from current provider truth that may appear on the tenant list only when it can be expressed without inventing a broader readiness model. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-179-001**: In regression coverage, 100% of targeted tenant surfaces stop showing legacy tenant app status as a default-visible primary operator truth. +- **SC-179-002**: In regression coverage, 100% of targeted provider surfaces show consent state and verification state before any legacy provider status or health field. +- **SC-179-003**: In seeded scenarios where a tenant is active but provider verification is unknown, blocked, degraded, or error, 100% of targeted surfaces avoid implying that the tenant is provider-ready by default. +- **SC-179-004**: In seeded scenarios where legacy fields contradict current consent or verification truth, 100% of targeted surfaces render the current truth hierarchy consistently. +- **SC-179-005**: The feature ships without a required schema migration, a new persisted readiness artifact, or a new cross-surface status family. + +## Assumptions + +- Existing consent and verification fields are sufficiently trustworthy to become the leading provider truth for this cleanup slice. +- Existing verification reports and provider next-step guidance remain the correct supporting diagnostics for provider truth. +- Existing RBAC status surfaces remain valid as their own domain and do not need redesign in this spec beyond clearer separation from provider truth. +- The tenant list may omit a provider signal for some tenants if no bounded, trustworthy summary can be expressed without inventing a readiness model. + +## Non-Goals + +- Introducing a full tenant readiness assessment +- Adding a new readiness enum or score +- Building cross-dashboard or needs-attention propagation for every provider problem +- Adding operation pre-flight gating or blocking rules beyond the current behavior +- Introducing verification freshness or scheduled re-verification lifecycle rules +- Removing legacy database columns or internal compatibility paths in this slice + +## Dependencies + +- Existing tenant lifecycle model and tenant operator surfaces +- Existing provider connection model, consent state, verification state, and verification-report semantics +- Existing centralized badge and presentation mappings used on tenant and provider surfaces +- Existing workspace and tenant scoping, capability enforcement, and deny-as-not-found boundaries +- Existing tenant and provider Filament resources, pages, and regression tests covering truth and authorization behavior + +## Definition of Done + +- Tenant app status no longer appears as leading operator truth on the targeted tenant surfaces. +- Provider legacy status and health no longer compete with consent and verification on the targeted provider surfaces. +- Tenant lifecycle, provider consent, provider verification, and RBAC remain visibly separate where they coexist. +- No targeted tenant or provider surface allows `active`, `connected`, or `consented` to read as equivalent to ready. +- Badge and presentation usage reflects the cleaned truth hierarchy without inventing a new readiness model. +- The change requires no new persistence structure and is covered by targeted regression tests. diff --git a/specs/179-provider-truth-cleanup/tasks.md b/specs/179-provider-truth-cleanup/tasks.md new file mode 100644 index 00000000..979ef28e --- /dev/null +++ b/specs/179-provider-truth-cleanup/tasks.md @@ -0,0 +1,203 @@ +# Tasks: Provider Readiness Source-of-Truth Cleanup + +**Input**: Design documents from `/specs/179-provider-truth-cleanup/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/provider-truth-cleanup.openapi.yaml`, `quickstart.md` + +**Tests**: Required. Write or update Pest coverage before each behavior change and keep Sail-first verification focused. + +**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently. + +## Phase 1: Setup (Shared Regression Scaffolding) + +**Purpose**: Create the focused regression entry points for Spec 179 before changing operator-facing surfaces. + +- [X] T001 [P] Create the tenant truth-cleanup Pest scaffold in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` +- [X] T002 [P] Create the provider truth-cleanup Pest scaffold in `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Lock route, scope, and discovery invariants before changing any tenant or provider truth surfaces. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T003 Preserve canonical provider CTA and deny-as-not-found invariants in `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php` +- [X] T004 [P] Preserve tenant global-search scope and provider-connection global-search exclusion in `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, and `tests/Feature/Filament/TenantScopingTest.php` + +**Checkpoint**: Scope guards and discovery invariants are ready; tenant and provider truth cleanup can now proceed. + +--- + +## Phase 3: User Story 1 - Read truthful tenant surfaces (Priority: P1) 🎯 MVP + +**Goal**: Make tenant list and tenant detail lifecycle-led, remove `app_status` as leading truth, and show provider consent and verification separately from lifecycle. + +**Independent Test**: Seed tenants whose lifecycle, legacy `app_status`, consent, and verification disagree, then verify tenant list and tenant detail show lifecycle separately and no longer treat `app_status` as current truth. + +### Tests for User Story 1 + +- [X] T005 [P] [US1] Add tenant list truth regression cases in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` for active-plus-unknown, onboarding-plus-blocked, missing-default-connection, and multi-connection unsafe-summary omission scenarios +- [X] T006 [P] [US1] Rewrite lifecycle-separation expectations in `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` so lifecycle and RBAC remain visible while `app_status` stops acting as primary truth + +### Implementation for User Story 1 + +- [X] T007 [US1] Remove default-visible `app_status` columns and primary `app_status` filter usage from `app/Filament/Resources/TenantResource.php`, and keep any tenant-list provider signal omitted when current provider truth cannot be compressed safely +- [X] T008 [US1] Remove leading `app_status` detail output and repoint `providerConnectionState()` to `consent_status` and `verification_status` in `app/Filament/Resources/TenantResource.php` +- [X] T009 [US1] Rewrite the tenant Provider summary in `resources/views/filament/infolists/entries/provider-connection-state.blade.php` to lead with consent and verification and demote legacy status and health to diagnostics +- [X] T010 [US1] Align verification deep-dive wording with the new tenant summary contract in `app/Filament/Widgets/Tenant/TenantVerificationReport.php` and `resources/views/filament/widgets/tenant/tenant-verification-report.blade.php` +- [X] T011 [US1] Update canonical provider-connections CTA assertions after the tenant detail summary change in `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php` + +**Checkpoint**: Tenant list and tenant detail now answer lifecycle and provider questions without implying readiness from legacy fields. + +--- + +## Phase 4: User Story 2 - Diagnose provider connections from current axes (Priority: P1) + +**Goal**: Make provider connection list, view, and edit surfaces lead with consent and verification while demoting legacy connection status and health to diagnostics. + +**Independent Test**: Seed provider connections whose legacy `status` and `health_status` conflict with `consent_status` and `verification_status`, then verify list, view, and edit surfaces elevate the current axes and keep DB-only rendering intact. + +### Tests for User Story 2 + +- [X] T012 [P] [US2] Add provider list, view, and edit truth regression cases in `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` for consent and verification versus legacy status and health conflicts +- [X] T013 [P] [US2] Update provider filter expectations in `tests/Feature/ProviderConnections/RequiredFiltersTest.php` to require consent-led and verification-led filters plus `default_only` +- [X] T014 [P] [US2] Update DB-only rendering expectations in `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php` for the new default-visible provider state columns + +### Implementation for User Story 2 + +- [X] T015 [US2] Promote `consent_status` and `verification_status` to default-visible list columns and demote legacy `status` and `health_status` columns in `app/Filament/Resources/ProviderConnectionResource.php` +- [X] T016 [US2] Replace primary `status` and `health_status` filters with consent-led and verification-led filters and mark any retained legacy filters as diagnostic in `app/Filament/Resources/ProviderConnectionResource.php` +- [X] T017 [US2] Split the provider connection view infolist into Current state and Diagnostics sections in `app/Filament/Resources/ProviderConnectionResource.php` +- [X] T018 [US2] Split the provider connection edit form context into Current state and Diagnostics sections in `app/Filament/Resources/ProviderConnectionResource.php` + +**Checkpoint**: Provider connection pages now answer whether a connection is consented and verified before showing any legacy projections. + +--- + +## Phase 5: User Story 3 - Avoid false readiness language across surfaces (Priority: P2) + +**Goal**: Keep tenant and provider surfaces from collapsing `active`, `connected`, or `consented` into `ready`, and keep RBAC separate from provider truth. + +**Independent Test**: Render tenant and provider surfaces for records that look favorable in one status family but unfavorable in another and verify no default-visible wording, badge, or section title implies readiness. + +### Tests for User Story 3 + +- [X] T019 [P] [US3] Add cross-surface false-readiness assertions in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` and `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` for active-plus-blocked or error, consented-plus-unknown, and RBAC-separated scenarios +- [X] T020 [P] [US3] Re-run scope-leak and capability regression coverage in `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php` against the cleaned surfaces + +### Implementation for User Story 3 + +- [X] T021 [US3] Normalize operator-facing labels and section headings to Lifecycle, Consent, Verification, and Diagnostics wording in `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/ProviderConnectionResource.php` +- [X] T022 [US3] Add centralized badge mappings for provider consent and provider verification, keep legacy app-status or connection-status badges diagnostic-only, and avoid any synthetic readiness domain in `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, `app/Support/Badges/Domains/TenantAppStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, and `app/Support/Badges/Domains/ProviderConnectionHealthBadge.php` +- [X] T023 [US3] Update unit badge regression coverage for centralized lifecycle, provider consent, provider verification, and legacy diagnostic mappings in `tests/Unit/Badges/TenantBadgesTest.php` and `tests/Unit/Badges/ProviderConnectionBadgesTest.php` + +**Checkpoint**: No targeted tenant or provider surface uses favorable legacy language to imply provider readiness. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Format, verify, and manually confirm the cleaned truth hierarchy across all affected surfaces. + +- [X] T024 Run `vendor/bin/sail bin pint --dirty --format agent` for touched files under `app/`, `resources/views/`, and `tests/` as governed by `composer.json` +- [X] T025 Run the focused Sail verification pack from `specs/179-provider-truth-cleanup/quickstart.md` against `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`, `tests/Feature/ProviderConnections/RequiredFiltersTest.php`, `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`, `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, `tests/Feature/Filament/TenantScopingTest.php`, `tests/Unit/Badges/TenantBadgesTest.php`, and `tests/Unit/Badges/ProviderConnectionBadgesTest.php` +- [X] T026 Execute the manual smoke checklist in `specs/179-provider-truth-cleanup/quickstart.md` against `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/ProviderConnectionResource.php` on `/admin/tenants` and `/admin/provider-connections` +- [X] T027 Validate that the final implementation introduces no schema migration, no new persisted truth, and no unplanned status-family expansion by reviewing `database/migrations/`, `app/Models/Tenant.php`, `app/Models/ProviderConnection.php`, `app/Support/Providers/ProviderConsentStatus.php`, `app/Support/Providers/ProviderVerificationStatus.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, `app/Support/Badges/Domains/TenantAppStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionHealthBadge.php`, and `specs/179-provider-truth-cleanup/plan.md` against the final diff + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story work. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. +- **User Story 2 (Phase 4)**: Depends on Foundational completion. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 completion because it harmonizes cross-surface wording and diagnostic semantics. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1**: Independent after Phase 2 and is the recommended MVP slice. +- **US2**: Independent after Phase 2 and can run in parallel with US1. +- **US3**: Depends on the finished tenant and provider surface hierarchy from US1 and US2. + +### Within Each User Story + +- Write or update the story tests first and confirm they fail for the intended reason. +- Update the primary resource or shared surface contract before adjusting dependent Blade or widget output. +- Finish story-specific assertions after the implementation lands. +- Keep authorization regressions green before advancing to the next story. + +### Parallel Opportunities + +- `T001` and `T002` can run in parallel. +- `T003` and `T004` can run in parallel. +- `T005` and `T006` can run in parallel. +- `T012`, `T013`, and `T014` can run in parallel. +- `T019` and `T020` can run in parallel. +- Phase 3 and Phase 4 can run in parallel after Phase 2 completes. + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch the tenant truth regressions together before changing tenant surfaces: +Task: T005 Add tenant list truth regression cases in tests/Feature/Filament/TenantTruthCleanupSpec179Test.php +Task: T006 Rewrite lifecycle-separation expectations in tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +``` + +## Parallel Example: User Story 2 + +```bash +# Launch the provider list and rendering guards together before changing ProviderConnectionResource: +Task: T012 Add provider list, view, and edit truth regression cases in tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php +Task: T013 Update provider filter expectations in tests/Feature/ProviderConnections/RequiredFiltersTest.php +Task: T014 Update DB-only rendering expectations in tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +``` + +## Parallel Example: User Story 3 + +```bash +# Lock the cross-surface wording and scope guards together once US1 and US2 are complete: +Task: T019 Add cross-surface false-readiness assertions in tests/Feature/Filament/TenantTruthCleanupSpec179Test.php and tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php +Task: T020 Re-run scope-leak and capability regression coverage in tests/Feature/Rbac/TenantResourceAuthorizationTest.php, tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php, and tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate tenant surfaces with the US1-focused subset of `specs/179-provider-truth-cleanup/quickstart.md`. +5. Demo or review the tenant truth cleanup before expanding to provider surfaces. + +### Incremental Delivery + +1. Finish Setup and Foundational work. +2. Deliver US1 and validate tenant truth cleanup. +3. Deliver US2 and validate provider truth cleanup. +4. Deliver US3 and validate cross-surface wording and diagnostic consistency. +5. Finish Phase 6 verification and manual smoke checks. + +### Parallel Team Strategy + +1. One developer completes Phase 1 and Phase 2. +2. After Phase 2, one developer takes US1 while another takes US2. +3. Rejoin on US3 once both surface hierarchies are stable. +4. Finish with shared formatting, focused Sail tests, and manual smoke validation. + +--- + +## Notes + +- Every task follows the required checklist format: checkbox, task ID, optional parallel marker, required story label for story phases, and exact file paths. +- The task list preserves the plan decision not to invent a new tenant-list readiness badge in this slice. +- No task introduces new persistence, a new readiness enum, or a new presenter layer. diff --git a/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php b/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php index cd77669f..ccc99758 100644 --- a/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +++ b/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php @@ -52,11 +52,20 @@ ->set('tableFilters.default_only.isActive', true); $table = $component->instance()->getTable(); + $visibleColumnNames = collect($table->getVisibleColumns()) + ->map(fn ($column): string => $column->getName()) + ->values() + ->all(); expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource()); expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found'); expect($table->getColumn('display_name')?->isSearchable())->toBeTrue(); expect($table->getColumn('display_name')?->isSortable())->toBeTrue(); + expect($visibleColumnNames)->toContain('consent_status', 'verification_status'); + expect($visibleColumnNames)->not->toContain('status'); + expect($visibleColumnNames)->not->toContain('health_status'); + expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue(); + expect($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('migration_review_required'))->not->toBeNull(); diff --git a/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php b/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php index 31e0f718..167cd2e7 100644 --- a/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php +++ b/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php @@ -58,6 +58,8 @@ function tenantSearchTitles($results): array expect($results->first()?->url) ->not->toBeNull(); + expect(collect($results)->filter(fn ($result): bool => filled($result->url))->count()) + ->toBe($results->count()); }); it('keeps first-slice taxonomy resources out of global search', function (): void { diff --git a/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php b/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php index 7fa62d33..257ab4b8 100644 --- a/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php +++ b/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php @@ -14,7 +14,7 @@ uses(RefreshDatabase::class); -it('keeps lifecycle, app status, and rbac status separated on the tenant view page', function (): void { +it('keeps lifecycle and rbac status separated while removing app status from the tenant view page', function (): void { [$user, $tenant] = createUserWithTenant( tenant: Tenant::factory()->create([ 'status' => Tenant::STATUS_ONBOARDING, @@ -32,8 +32,7 @@ Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->assertSee('Lifecycle summary') ->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') - ->assertSee('App status') - ->assertSee('Consent required') + ->assertDontSee('App status') ->assertSee('RBAC status') ->assertSee('Failed'); }); diff --git a/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php b/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php new file mode 100644 index 00000000..ca5e68e5 --- /dev/null +++ b/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php @@ -0,0 +1,181 @@ +active()->create([ + 'name' => 'Primary Truth Tenant', + 'app_status' => 'ok', + ]); + + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Primary Truth Connection', + 'is_default' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Unknown->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant(null, true); + + $component = Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertCanSeeTableRecords([$tenant]); + + $table = $component->instance()->getTable(); + $filterNames = array_keys($table->getFilters()); + $visibleColumnNames = collect($table->getVisibleColumns()) + ->map(fn ($column): string => $column->getName()) + ->values() + ->all(); + + expect($filterNames)->not->toContain('app_status') + ->and($visibleColumnNames)->not->toContain('app_status') + ->and($visibleColumnNames)->not->toContain('consent_status') + ->and($visibleColumnNames)->not->toContain('verification_status') + ->and($visibleColumnNames)->not->toContain('provider_connection_state'); +}); + +it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void { + $tenant = Tenant::factory()->create([ + 'status' => Tenant::STATUS_ONBOARDING, + 'app_status' => 'consent_required', + 'rbac_status' => 'failed', + 'name' => 'Truth Cleanup Tenant', + ]); + + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Truth Cleanup Connection', + 'is_default' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant(null, true); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertSee('Lifecycle summary') + ->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') + ->assertSee('RBAC status') + ->assertSee('Failed') + ->assertDontSee('App status') + ->assertSee('Truth Cleanup Connection') + ->assertSee('Granted') + ->assertSee('Blocked') + ->assertSee('Legacy status') + ->assertSee('Connected') + ->assertSee('Legacy health') + ->assertSee('OK'); +}); + +it('flags tenants that have microsoft connections but no default connection configured', function (): void { + $tenant = Tenant::factory()->active()->create([ + 'name' => 'Missing Default Tenant', + ]); + + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Fallback Microsoft Connection', + 'is_default' => false, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Healthy->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant(null, true); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertSee('Needs action: set a default Microsoft provider connection.') + ->assertSee('Fallback Microsoft Connection') + ->assertSee('Open Provider Connections'); +}); + +it('does not collapse active lifecycle and blocked provider verification into readiness language', function (): void { + $tenant = Tenant::factory()->active()->create([ + 'name' => 'No False Readiness Tenant', + 'app_status' => 'ok', + 'rbac_status' => 'configured', + ]); + + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Blocked Connection', + 'is_default' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + Filament::setTenant(null, true); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->assertSee('Active') + ->assertSee('Granted') + ->assertSee('Blocked') + ->assertSee('RBAC status') + ->assertDontSee('Ready'); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php index 80bfad68..800b8212 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php @@ -111,3 +111,30 @@ ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connectionB], tenant: $tenantA)) ->assertNotFound(); }); + +test('provider connection view is not accessible cross-tenant', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $connectionB = ProviderConnection::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'display_name' => 'Tenant B Connection', + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) + ->get(ProviderConnectionResource::getUrl('view', ['record' => $connectionB], tenant: $tenantA)) + ->assertNotFound(); +}); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php b/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php new file mode 100644 index 00000000..16628f00 --- /dev/null +++ b/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php @@ -0,0 +1,147 @@ +create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Contoso', + 'provider' => 'microsoft', + 'is_default' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Degraded->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::actingAs($user) + ->test(ListProviderConnections::class) + ->assertCanSeeTableRecords([$connection]); + + $table = $component->instance()->getTable(); + $visibleColumnNames = collect($table->getVisibleColumns()) + ->map(fn ($column): string => $column->getName()) + ->values() + ->all(); + + expect($visibleColumnNames)->toContain('consent_status', 'verification_status') + ->and($visibleColumnNames)->not->toContain('status') + ->and($visibleColumnNames)->not->toContain('health_status') + ->and($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue() + ->and($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue(); +}); + +it('separates current state from diagnostics on the provider connection view page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Truthful Connection', + 'provider' => 'microsoft', + 'is_default' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Degraded->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertSeeInOrder([ + 'Current state', + 'Consent', + 'Granted', + 'Verification', + 'Degraded', + 'Diagnostics', + 'Legacy status', + 'Connected', + 'Legacy health', + 'OK', + ]); +}); + +it('shows current consent and verification context before diagnostics on the provider connection edit page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Editable Truthful Connection', + 'provider' => 'microsoft', + 'is_default' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Blocked->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertSeeInOrder([ + 'Current state', + 'Consent', + 'Granted', + 'Verification', + 'Blocked', + 'Diagnostics', + 'Legacy status', + 'Connected', + 'Legacy health', + 'OK', + ]); +}); + +it('does not treat consented but unverified connections as ready across provider surfaces', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Unknown Verification Connection', + 'provider' => 'microsoft', + 'is_default' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Unknown->value, + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListProviderConnections::class) + ->assertCanSeeTableRecords([$connection]) + ->assertSee('Granted') + ->assertSee('Unknown') + ->assertDontSee('Ready'); + + $this->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertSee('Granted') + ->assertSee('Unknown') + ->assertDontSee('Ready'); +}); diff --git a/tests/Feature/ProviderConnections/RequiredFiltersTest.php b/tests/Feature/ProviderConnections/RequiredFiltersTest.php index 20615b7c..8676e6f9 100644 --- a/tests/Feature/ProviderConnections/RequiredFiltersTest.php +++ b/tests/Feature/ProviderConnections/RequiredFiltersTest.php @@ -32,9 +32,12 @@ $component = Livewire::test(ListProviderConnections::class); - $filterNames = array_keys($component->instance()->getTable()->getFilters()); + $filters = $component->instance()->getTable()->getFilters(); + $filterNames = array_keys($filters); - expect($filterNames)->toContain('tenant', 'provider', 'status', 'health_status', 'default_only'); + expect($filterNames)->toContain('tenant', 'provider', 'consent_status', 'verification_status', 'status', 'health_status', 'default_only'); + expect($filters['status']->getLabel())->toBe('Diagnostic status'); + expect($filters['health_status']->getLabel())->toBe('Diagnostic health'); $component ->set('tableFilters.default_only.isActive', true) diff --git a/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php b/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php index 7d723539..9ac04ee1 100644 --- a/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php +++ b/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php @@ -6,6 +6,7 @@ use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyVersionResource; +use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\TenantResource; use App\Models\EntraGroup; use App\Models\Policy; @@ -53,7 +54,8 @@ function adminGlobalSearchTitles($results): array }); it('keeps operation runs out of admin global search regardless of remembered context state', function (): void { - expect(OperationRunResource::canGloballySearch())->toBeFalse(); + expect(OperationRunResource::canGloballySearch())->toBeFalse() + ->and(ProviderConnectionResource::canGloballySearch())->toBeFalse(); }); it('keeps representative first-slice admin global-search behavior aligned to the family registry postures', function (): void { diff --git a/tests/Feature/Rbac/TenantResourceAuthorizationTest.php b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php index 79f96ef6..46a472da 100644 --- a/tests/Feature/Rbac/TenantResourceAuthorizationTest.php +++ b/tests/Feature/Rbac/TenantResourceAuthorizationTest.php @@ -61,6 +61,26 @@ expect(TenantResource::canEdit($otherTenant))->toBeFalse(); }); + it('returns not found for tenant detail pages outside the actor tenant scope', function (): void { + [$user] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + $otherTenant = Tenant::factory()->active()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $otherTenant->workspace_id]) + ->get(TenantResource::getUrl('view', ['record' => $otherTenant], panel: 'admin')) + ->assertNotFound(); + }); + + it('returns not found for numeric tenant detail paths outside the actor tenant scope', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + $otherTenant = Tenant::factory()->active()->create(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(TenantResource::getUrl('view', ['record' => (string) $otherTenant->getKey()], panel: 'admin')) + ->assertNotFound(); + }); + it('does not grant lifecycle mutation abilities for inaccessible tenants regardless of lifecycle state', function (\Closure $tenantFactory): void { [$user] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); $otherTenant = $tenantFactory(); diff --git a/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php b/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php index 762896cf..2513320a 100644 --- a/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php +++ b/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php @@ -12,3 +12,25 @@ ->assertOk() ->assertSee('/admin/provider-connections?tenant_id='.(string) $tenant->external_id, false); }); + +it('keeps the canonical provider connections CTA when the tenant needs a default microsoft connection', function (): void { + $tenant = \App\Models\Tenant::factory()->active()->create(); + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + \App\Models\ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Fallback Connection', + 'is_default' => false, + ]); + + $this->actingAs($user) + ->get(TenantResource::getUrl('view', ['record' => $tenant], tenant: $tenant)) + ->assertOk() + ->assertSee('/admin/provider-connections?tenant_id='.(string) $tenant->external_id, false); +}); diff --git a/tests/Unit/Badges/ProviderConnectionBadgesTest.php b/tests/Unit/Badges/ProviderConnectionBadgesTest.php index 9fbefcbe..be059339 100644 --- a/tests/Unit/Badges/ProviderConnectionBadgesTest.php +++ b/tests/Unit/Badges/ProviderConnectionBadgesTest.php @@ -5,7 +5,43 @@ use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; -it('maps provider connection status safely', function (): void { +it('maps provider consent status safely', function (): void { + $unknown = BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'unknown'); + expect($unknown->color)->toBe('gray'); + expect($unknown->label)->toBe('Unknown'); + + $required = BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required'); + expect($required->color)->toBe('warning'); + expect($required->label)->toBe('Required'); + + $granted = BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted'); + expect($granted->color)->toBe('success'); + expect($granted->label)->toBe('Granted'); + + $failed = BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'failed'); + expect($failed->color)->toBe('danger'); + expect($failed->label)->toBe('Failed'); +}); + +it('maps provider verification status safely', function (): void { + $unknown = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'unknown'); + expect($unknown->color)->toBe('gray'); + expect($unknown->label)->toBe('Unknown'); + + $pending = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending'); + expect($pending->color)->toBe('info'); + expect($pending->label)->toBe('Pending'); + + $healthy = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy'); + expect($healthy->color)->toBe('success'); + expect($healthy->label)->toBe('Healthy'); + + $blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked'); + expect($blocked->color)->toBe('danger'); + expect($blocked->label)->toBe('Blocked'); +}); + +it('maps provider connection legacy status safely', function (): void { $connected = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'connected'); expect($connected->color)->toBe('success'); expect($connected->label)->toBe('Connected'); @@ -23,7 +59,7 @@ expect($disabled->label)->toBe('Disabled'); }); -it('maps provider connection health safely', function (): void { +it('maps provider connection legacy health safely', function (): void { $ok = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'ok'); expect($ok->color)->toBe('success'); expect($ok->label)->toBe('OK'); diff --git a/tests/Unit/Badges/TenantBadgesTest.php b/tests/Unit/Badges/TenantBadgesTest.php index a404a9ca..4235c4a7 100644 --- a/tests/Unit/Badges/TenantBadgesTest.php +++ b/tests/Unit/Badges/TenantBadgesTest.php @@ -23,7 +23,7 @@ expect($error->color)->toBe('danger'); }); -it('maps tenant app status values to canonical badge semantics', function (): void { +it('maps tenant app status values to legacy diagnostic badge semantics', function (): void { $ok = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'ok'); expect($ok->label)->toBe('OK'); expect($ok->color)->toBe('success');