Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
1b90927c9e feat: centralize tenant lifecycle presentation 2026-03-16 19:17:35 +01:00
38 changed files with 2558 additions and 45 deletions

View File

@ -81,6 +81,7 @@ ## Active Technologies
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
- PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -100,8 +101,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 146-central-tenant-status-presentation: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
- 145-tenant-action-taxonomy-lifecycle-safe-visibility: Added PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService`
- 144-canonical-operation-viewer-context-decoupling: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks`
- 143-tenant-lifecycle-operability-context-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Pages\Page;
@ -90,6 +91,11 @@ public function selectTenant(int $tenantId): void
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
{
return TenantLifecyclePresentation::fromTenant($tenant);
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {

View File

@ -11,7 +11,6 @@
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
@ -20,6 +19,7 @@
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\RedactionIntegrity;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
@ -196,13 +196,16 @@ public function canonicalContextBanner(): ?array
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
}
$tenantOperability = app(TenantOperabilityService::class)->decisionFor($runTenant);
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
if (! $tenantOperability->canSelectAsContext) {
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
$title ??= 'Run tenant is not available in the current tenant selector';
$tone = 'amber';
$messages[] = 'This tenant is currently '.Str::lower($tenantOperability->lifecycle->label()).' and may not appear in the tenant selector.';
$messages[] = 'Some tenant follow-up actions may be unavailable from this canonical workspace view.';
$messages[] = $selectorAvailabilityMessage;
if ($referencedTenant->contextNote !== null) {
$messages[] = $referencedTenant->contextNote;
}
} elseif (! $activeTenant instanceof Tenant) {
$title ??= 'Canonical workspace view';
$messages[] = 'No tenant context is currently selected.';

View File

@ -51,6 +51,7 @@
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Verification\VerificationAssistViewModelBuilder;
use App\Support\Verification\VerificationCheckStatus;
use App\Support\Verification\VerificationReportOverall;
@ -149,6 +150,13 @@ protected function getHeaderActions(): array
{
$actions = [];
if (isset($this->workspace)) {
$actions[] = Action::make('back_to_workspace')
->label('Back to workspace')
->color('gray')
->url(route('admin.home'));
}
if ($this->shouldShowDraftLandingAction()) {
$actions[] = Action::make('back_to_onboarding_landing')
->label($this->onboardingDraftLandingActionLabel())
@ -158,7 +166,7 @@ protected function getHeaderActions(): array
if ($this->canViewLinkedTenant()) {
$actions[] = Action::make('view_linked_tenant')
->label('View tenant')
->label($this->linkedTenantActionLabel())
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $this->managedTenant]));
}
@ -174,6 +182,18 @@ protected function getHeaderActions(): array
->action(fn () => $this->cancelOnboardingDraft());
}
if ($this->canDeleteDraft($this->onboardingSession)) {
$actions[] = Action::make('delete_onboarding_draft_header')
->label('Delete draft')
->color('danger')
->requiresConfirmation()
->modalHeading('Delete onboarding draft')
->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.')
->modalSubmitActionLabel('Delete draft')
->visible(fn (): bool => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL))
->action(fn () => $this->deleteOnboardingDraft());
}
return $actions;
}
@ -192,6 +212,18 @@ private function canViewLinkedTenant(): bool
return app(TenantOperabilityService::class)->canViewTenantSurface($this->managedTenant);
}
private function linkedTenantActionLabel(): string
{
if (! $this->managedTenant instanceof Tenant) {
return 'View tenant';
}
return sprintf(
'View tenant (%s)',
TenantLifecyclePresentation::fromTenant($this->managedTenant)->label,
);
}
public function mount(TenantOnboardingSession|int|string|null $onboardingDraft = null): void
{
$user = auth()->user();
@ -827,10 +859,23 @@ private function nonResumableSummarySchema(): array
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
]),
SchemaActions::make([
Action::make('back_to_workspace_summary')
->label('Back to workspace')
->color('gray')
->url(route('admin.home')),
Action::make('return_to_onboarding_landing')
->label('Return to onboarding')
->color('gray')
->url(route('admin.onboarding')),
Action::make('delete_onboarding_draft')
->label('Delete draft')
->color('danger')
->requiresConfirmation()
->modalHeading('Delete onboarding draft')
->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.')
->modalSubmitActionLabel('Delete draft')
->visible(fn (): bool => $this->canDeleteDraft($this->onboardingSession))
->action(fn () => $this->deleteOnboardingDraft()),
]),
];
}
@ -966,12 +1011,81 @@ private function cancelOnboardingDraft(): void
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $this->onboardingSession]));
}
private function deleteOnboardingDraft(): void
{
$user = $this->currentUser();
if (! $user instanceof User) {
abort(403);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
abort(404);
}
$this->authorize('cancel', $this->onboardingSession);
if (! $this->canDeleteDraft($this->onboardingSession)) {
Notification::make()
->title('Draft cannot be deleted')
->warning()
->send();
return;
}
$draft = $this->onboardingSession;
$draftId = (int) $draft->getKey();
$draftTitle = $this->draftTitle($draft);
$draftStatus = $draft->status()->value;
$draftLifecycle = $draft->lifecycleState()->value;
$tenantId = $draft->tenant_id !== null ? (int) $draft->tenant_id : null;
$draft->delete();
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingDeleted->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'onboarding_session_id' => $draftId,
'tenant_db_id' => $tenantId,
'status' => $draftStatus,
'lifecycle_state' => $draftLifecycle,
],
],
actor: $user,
status: 'success',
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $draftId,
targetLabel: $draftTitle,
);
$this->managedTenant = null;
$this->setOnboardingSession(null);
Notification::make()
->title('Onboarding draft deleted')
->success()
->send();
$this->redirect(route('admin.onboarding'));
}
private function showsNonResumableSummary(): bool
{
return $this->onboardingSession instanceof TenantOnboardingSession
&& ! $this->canResumeDraft($this->onboardingSession);
}
private function canDeleteDraft(?TenantOnboardingSession $draft): bool
{
return $draft instanceof TenantOnboardingSession
&& ! $this->canResumeDraft($draft)
&& $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL);
}
private function onboardingDraftLandingActionLabel(): string
{
$user = $this->currentUser();

View File

@ -22,6 +22,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDurationInsights;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -254,6 +255,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
$targetScope = static::targetScopeDisplay($record);
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
: null;
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -300,6 +304,24 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
items: array_values(array_filter([
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
$referencedTenantLifecycle->presentation->label,
badge: $factory->statusBadge(
$referencedTenantLifecycle->presentation->label,
$referencedTenantLifecycle->presentation->badgeColor,
$referencedTenantLifecycle->presentation->badgeIcon,
$referencedTenantLifecycle->presentation->badgeIconColor,
),
)
: null,
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
: null,
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
])),

View File

@ -41,6 +41,7 @@
use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionSurface;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -264,20 +265,23 @@ public static function table(Table $table): Table
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_current')
->label('Current')
->boolean(),
->boolean()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->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)),
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->since()
@ -826,6 +830,10 @@ public static function infolist(Schema $schema): Schema
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
Infolists\Components\TextEntry::make('lifecycle_summary')
->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))
@ -1001,6 +1009,11 @@ protected static function storedPermissionSnapshot(Tenant $tenant): array
return $snapshot;
}
protected static function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
{
return TenantLifecyclePresentation::fromTenant($tenant);
}
public static function tenantOperability(): TenantOperabilityService
{
return app(TenantOperabilityService::class);

View File

@ -5,6 +5,7 @@
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use App\Support\Tenants\TenantLifecyclePresentation;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
@ -23,6 +24,7 @@ protected function getViewData(): array
return [
'tenant' => $tenant instanceof Tenant ? $tenant : null,
'presentation' => $tenant instanceof Tenant ? TenantLifecyclePresentation::fromTenant($tenant) : null,
];
}
}

View File

@ -246,7 +246,7 @@ private function relatedOnboardingActionForContext(
return new TenantActionDescriptor(
key: 'related_onboarding',
family: TenantActionFamily::OnboardingWorkflow,
label: 'View cancelled onboarding',
label: 'View cancelled onboarding draft',
icon: 'heroicon-o-eye',
group: $group,
);

View File

@ -37,6 +37,7 @@ enum AuditActionId: string
case ManagedTenantOnboardingVerificationPersisted = 'managed_tenant_onboarding.verification_persisted';
case ManagedTenantOnboardingBootstrapStarted = 'managed_tenant_onboarding.bootstrap_started';
case ManagedTenantOnboardingCancelled = 'managed_tenant_onboarding.cancelled';
case ManagedTenantOnboardingDeleted = 'managed_tenant_onboarding.deleted';
case ManagedTenantOnboardingActivationOverrideUsed = 'managed_tenant_onboarding.activation_override_used';
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
case VerificationCompleted = 'verification.completed';
@ -149,6 +150,7 @@ private static function labels(): array
self::ManagedTenantOnboardingVerificationPersisted->value => 'Managed tenant onboarding verification persisted',
self::ManagedTenantOnboardingBootstrapStarted->value => 'Managed tenant onboarding bootstrap started',
self::ManagedTenantOnboardingCancelled->value => 'Managed tenant onboarding cancelled',
self::ManagedTenantOnboardingDeleted->value => 'Managed tenant onboarding deleted',
self::ManagedTenantOnboardingActivationOverrideUsed->value => 'Managed tenant onboarding activation override used',
self::ManagedTenantOnboardingActivation->value => 'Managed tenant onboarding activation',
self::VerificationCompleted->value => 'Verification completed',

View File

@ -2,6 +2,7 @@
namespace App\Support\Badges;
use App\Support\Tenants\TenantLifecyclePresentation;
use BackedEnum;
use Stringable;
use Throwable;
@ -157,13 +158,14 @@ public static function normalizeManagedTenantOnboardingVerificationStatus(mixed
public static function normalizeTenantLifecycle(mixed $value): ?string
{
$state = self::normalizeState($value);
$presentation = self::tenantLifecyclePresentation($value);
return match ($state) {
'pending' => 'onboarding',
'inactive' => 'archived',
default => $state,
};
return $presentation->isInvalidFallback ? null : $presentation->value;
}
public static function tenantLifecyclePresentation(mixed $value): TenantLifecyclePresentation
{
return TenantLifecyclePresentation::fromValue($value);
}
private static function buildMapper(BadgeDomain $domain): ?BadgeMapper

View File

@ -7,20 +7,11 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Tenants\TenantLifecycle;
final class TenantStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeTenantLifecycle($value);
return match ($state) {
'draft' => new BadgeSpec(TenantLifecycle::Draft->label(), 'gray', 'heroicon-m-document'),
'onboarding' => new BadgeSpec(TenantLifecycle::Onboarding->label(), 'warning', 'heroicon-m-arrow-path'),
'active' => new BadgeSpec(TenantLifecycle::Active->label(), 'success', 'heroicon-m-check-circle'),
'archived' => new BadgeSpec(TenantLifecycle::Archived->label(), 'gray', 'heroicon-m-archive-box'),
default => BadgeSpec::unknown(),
};
return BadgeCatalog::tenantLifecyclePresentation($value)->badge();
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Support\Tenants;
use App\Models\Tenant;
final readonly class ReferencedTenantLifecyclePresentation
{
public function __construct(
public string $viewerContext,
public ?int $tenantId,
public ?string $tenantName,
public TenantLifecyclePresentation $presentation,
public ?string $contextNote,
) {}
public static function forOperationRun(Tenant $tenant): self
{
return self::fromTenant($tenant, 'operation_run');
}
public static function fromTenant(Tenant $tenant, string $viewerContext): self
{
$presentation = TenantLifecyclePresentation::fromTenant($tenant);
return new self(
viewerContext: $viewerContext,
tenantId: (int) $tenant->getKey(),
tenantName: $tenant->name,
presentation: $presentation,
contextNote: self::contextNoteFor($presentation),
);
}
public static function forInvalid(string $viewerContext, ?Tenant $tenant = null, ?string $normalizedValue = null): self
{
return new self(
viewerContext: $viewerContext,
tenantId: $tenant instanceof Tenant ? (int) $tenant->getKey() : null,
tenantName: $tenant?->name,
presentation: TenantLifecyclePresentation::invalid($normalizedValue),
contextNote: 'Some tenant follow-up actions may be unavailable from this canonical workspace view.',
);
}
public function selectorAvailabilityMessage(): ?string
{
if ($this->presentation->isInvalidFallback) {
return 'This tenant has an invalid lifecycle value and may not appear in the tenant selector.';
}
if (! $this->presentation->isSelectableAsContext()) {
return 'This tenant is currently '.$this->presentation->lowercaseLabel().' and may not appear in the tenant selector.';
}
return null;
}
private static function contextNoteFor(TenantLifecyclePresentation $presentation): ?string
{
if ($presentation->isInvalidFallback || ! $presentation->isSelectableAsContext()) {
return 'Some tenant follow-up actions may be unavailable from this canonical workspace view.';
}
return null;
}
}

View File

@ -68,10 +68,7 @@ public static function normalize(mixed $value): ?string
$normalized = strtolower(trim($value));
$normalized = str_replace([' ', '-'], '_', $normalized);
return match ($normalized) {
'pending' => self::Onboarding->value,
default => $normalized !== '' ? $normalized : null,
};
return $normalized !== '' ? $normalized : null;
}
public function label(): string

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Support\Tenants;
use App\Models\Tenant;
use App\Support\Badges\BadgeSpec;
final readonly class TenantLifecyclePresentation
{
private function __construct(
public string $value,
public string $label,
public string $badgeColor,
public ?string $badgeIcon,
public ?string $badgeIconColor,
public string $shortDescription,
public string $longDescription,
public bool $isInvalidFallback,
public ?TenantLifecycle $lifecycle,
) {}
public static function fromTenant(Tenant $tenant): self
{
if ($tenant->trashed()) {
return self::forLifecycle(TenantLifecycle::Archived);
}
return self::fromValue($tenant->status);
}
public static function fromValue(mixed $value): self
{
$lifecycle = TenantLifecycle::tryFromValue($value);
if ($lifecycle instanceof TenantLifecycle) {
return self::forLifecycle($lifecycle);
}
return self::invalid(TenantLifecycle::normalize($value));
}
public static function forLifecycle(TenantLifecycle $lifecycle): self
{
return match ($lifecycle) {
TenantLifecycle::Draft => new self(
value: $lifecycle->value,
label: $lifecycle->label(),
badgeColor: 'gray',
badgeIcon: 'heroicon-m-document',
badgeIconColor: null,
shortDescription: 'Draft tenant awaiting onboarding completion.',
longDescription: 'This tenant is still in draft and remains available for setup and review, but it is not selectable as active context until onboarding progresses.',
isInvalidFallback: false,
lifecycle: $lifecycle,
),
TenantLifecycle::Onboarding => new self(
value: $lifecycle->value,
label: $lifecycle->label(),
badgeColor: 'warning',
badgeIcon: 'heroicon-m-arrow-path',
badgeIconColor: null,
shortDescription: 'Onboarding is in progress.',
longDescription: 'This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.',
isInvalidFallback: false,
lifecycle: $lifecycle,
),
TenantLifecycle::Active => new self(
value: $lifecycle->value,
label: $lifecycle->label(),
badgeColor: 'success',
badgeIcon: 'heroicon-m-check-circle',
badgeIconColor: null,
shortDescription: 'Active tenant available for normal operations.',
longDescription: 'This tenant is active and available across normal management, tenant selection, and operational follow-up flows.',
isInvalidFallback: false,
lifecycle: $lifecycle,
),
TenantLifecycle::Archived => new self(
value: $lifecycle->value,
label: $lifecycle->label(),
badgeColor: 'gray',
badgeIcon: 'heroicon-m-archive-box',
badgeIconColor: null,
shortDescription: 'Archived tenant retained for inspection only.',
longDescription: 'This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.',
isInvalidFallback: false,
lifecycle: $lifecycle,
),
};
}
public static function invalid(?string $normalizedValue = null): self
{
return new self(
value: $normalizedValue ?? 'invalid',
label: 'Invalid lifecycle',
badgeColor: 'danger',
badgeIcon: 'heroicon-m-exclamation-triangle',
badgeIconColor: 'danger',
shortDescription: 'Lifecycle data is invalid and requires review.',
longDescription: 'The stored tenant lifecycle value is not canonical. Review the source data before treating this tenant as draft, onboarding, active, or archived.',
isInvalidFallback: true,
lifecycle: null,
);
}
public function badge(): BadgeSpec
{
return new BadgeSpec(
label: $this->label,
color: $this->badgeColor,
icon: $this->badgeIcon,
iconColor: $this->badgeIconColor,
);
}
public function isSelectableAsContext(): bool
{
return $this->lifecycle?->canSelectAsContext() ?? false;
}
public function lowercaseLabel(): string
{
return strtolower($this->label);
}
}

View File

@ -0,0 +1,467 @@
# Repo-wide Legacy / Orphaned Truth Audit
**Date**: 2026-03-16
**Scope**: Full codebase — models, migrations, enums, services, jobs, observers, Filament resources, policies, capabilities, badges, tests, factories
**Method**: Systematic source-of-truth tracing across all layers
---
## Executive Summary
### Top 5 Most Dangerous Stale/Legacy Patterns
1. **`Tenant.app_status` displayed in TenantResource as an active badge despite having ZERO write paths** — admins see a permanently frozen "OK" badge that never reflects real provider health. This is a **dangerous operational lie**.
2. **ProviderConnection dual-status architecture** — four status columns exist (`status`, `health_status`, `consent_status`, `verification_status`). The first two are projections of the latter two, but the ProviderConnectionStateProjector computes values without always persisting them consistently. The UI shows both stacks, creating confusing overlapping semantics.
3. **`backup_sets.item_count` denormalized counter** — actively used in UI and operations but drifts silently when BackupItems are mutated outside the AddPoliciesToBackupSetJob/RemovePoliciesFromBackupSetJob paths (e.g., through RelationManager deletions).
4. **TenantFactory populates stale fields (`app_status='ok'`, `app_client_id=uuid`)** on every test tenant creation, keeping legacy architecture alive in the test suite and masking that these fields are production-dead.
5. **`SyncRestoreRunToOperation.php` orphaned listener** — never called, superseded by `SyncRestoreRunToOperationRun.php`, but its presence creates confusion about which sync path is canonical.
### Cleanup-only Items (Low Risk)
- `Tenant.app_notes` — never written, never displayed, pure dead column
- `tenant_user` legacy pivot table — data migrated to `tenant_memberships`, table still present
- No-op migration file `2026_01_07_142719_create_inventory_items_table.php`
### Items That Can Actively Mislead Operators
- `app_status` badge in TenantResource (**dangerous lie** — frozen at factory default)
- ProviderConnection showing 4 status fields with overlapping semantics (**confusing UX**)
- Badge normalization mismatches (`TenantAppStatus` filter accepts `unknown` but badge has no matching spec → falls back to gray question mark)
### Spec Candidate Themes
- **Spec: Tenant Legacy Identity Cleanup** — remove `app_status`, `app_notes`, clean up `app_client_id`/`app_client_secret` to explicit migration-only fields
- **Spec: ProviderConnection Status Simplification** — collapse 4 status columns to 2 canonical enums, derive projections live
- **Spec: Denormalized Counter Integrity** — event-driven sync for `backup_sets.item_count`
---
## Findings by Category
---
### Finding 1: Tenant.app_status — Stale Badge Displayed as Truth
**Category**: 1. Stale duplicate state / 5. Dead-end UI
**Severity**: **CRITICAL**
**Confidence**: HIGH
**Why this exists**: Early in the project (Dec 2025), `app_status` was added to track tenant app registration health (ok, consent_required, error, unknown). The ProviderConnection model was introduced Jan 2026, superseding this with `consent_status` and `verification_status`.
**Canonical source of truth**: `ProviderConnection.consent_status` + `ProviderConnection.verification_status`
**Stale structure**: `tenants.app_status` column (varchar, default `'unknown'`, indexed)
**Evidence**:
- Migration: `database/migrations/2025_12_11_121623_add_app_fields_to_tenants_table.php`
- Model: `app/Models/Tenant.php` — field is fillable
- Badge domain: `BadgeDomain::TenantAppStatus` maps values (ok, configured, pending, consent_required, error)
- UI display: `app/Filament/Resources/TenantResource.php` line 277 (table column), line 835 (infolist)
- Filter: `app/Filament/Resources/TenantResource.php` line 302 (SelectFilter with hardcoded options)
**Read paths**: TenantResource table column (badge), TenantResource infolist, TenantResource filter
**Write paths**: **NONE** — zero production code writes to this field after initial migration default
**Why this is dangerous**: An admin viewing the tenant list sees an "App Status: OK" badge on every tenant. This badge is frozen at the factory/migration default. Real provider health has moved to ProviderConnection, but this badge creates false confidence that app registration is healthy.
**Recommended action**: **MUST REMOVE**
1. Remove `app_status` column display from TenantResource (table + infolist + filter)
2. Remove `BadgeDomain::TenantAppStatus` badge domain
3. Drop `app_status` column via migration
4. If admins need this info: derive from ProviderConnection.consent_status live
**Classification**: must remove
---
### Finding 2: Tenant.app_notes — Orphaned Column
**Category**: 1. Stale duplicate state
**Severity**: LOW
**Confidence**: HIGH
**Why this exists**: Added alongside `app_status` in Dec 2025 for freeform notes about app registration.
**Stale structure**: `tenants.app_notes` column (text, nullable)
**Read paths**: None — not displayed in any resource or service
**Write paths**: None — only set to `null` by TenantFactory
**Why this is harmless**: Not displayed anywhere, not read by any code path. Pure dead column.
**Recommended action**: Remove — drop column via migration
**Classification**: can remove
---
### Finding 3: Tenant Legacy Identity Fields (app_client_id, app_client_secret)
**Category**: 6. Partially completed migrations
**Severity**: MEDIUM
**Confidence**: HIGH
**Why this exists**: Before ProviderConnection + ProviderCredential existed, tenants stored app registration credentials directly. The provider migration system (Spec 081) introduced a classification layer that reads these fields to determine if a tenant has legacy identity that needs migration review.
**Canonical source of truth**: `ProviderCredential.client_id` / `ProviderCredential.client_secret`
**Legacy structure**: `tenants.app_client_id`, `tenants.app_client_secret` (encrypted), `tenants.app_certificate_thumbprint`
**Read paths**:
- `ProviderConnectionMigrationClassificationService` — reads for legacy identity detection
- Guard tests — `NoPlatformCredentialFallbackTest`, `NoLegacyTenantProviderFallbackTest`
- `MembershipAuditLogTest` — asserts `app_client_secret` is NOT in audit metadata
**Write paths**: Only TenantFactory (sets `app_client_id` to `fake()->uuid()` on every creation)
**Why this matters**: These fields are intentionally read-only during the provider migration cutover. The classification service uses them to detect legacy identity and flag `migration_review_required` on ProviderConnection. They are NOT duplicate state — they are migration-source data.
**Recommended action**: Keep temporarily — explicitly mark as `@deprecated` in model, add PHPDoc explaining migration-only purpose. Schedule removal after all tenants pass migration review.
**Classification**: should remove (after migration completion)
---
### Finding 4: ProviderConnection Dual Status Architecture
**Category**: 1. Stale duplicate state / 3. Mapping drift
**Severity**: HIGH
**Confidence**: HIGH
**Why this exists**: ProviderConnection was created (Jan 2026) with varchar `status` and `health_status` fields. In Mar 2026, enum-backed `consent_status` and `verification_status` were added as the new source of truth. The old varchar fields were kept as "projected" summaries for UI display.
**Canonical source of truth**: `consent_status` (ProviderConsentStatus enum) + `verification_status` (ProviderVerificationStatus enum)
**Stale structure**: `status` (varchar: connected/needs_consent/error/disabled) + `health_status` (varchar: ok/degraded/down/unknown)
**Evidence**:
- Original migration: `database/migrations/2026_01_24_000001_create_provider_connections_table.php`
- New enum columns: `database/migrations/2026_03_13_000001_add_provider_identity_fields_to_provider_connections.php`
- Projector: `app/Services/Providers/ProviderConnectionStateProjector.php`
- UI reads BOTH stacks: `app/Filament/Resources/ProviderConnectionResource.php`
**Read paths**: ProviderConnectionResource table (4 columns), filters (2 filters on varchar fields), badge system (2 badge domains)
**Write paths**: ProviderConnectionHealthCheckJob calls projector → writes both stacks atomically
**Why this is dangerous**:
1. Three incompatible status vocabularies displayed together (varchar status, varchar health, enum consent, enum verification)
2. Badge normalization functions hidden in BadgeCatalog translate between vocabularies
3. If projector fails or isn't called on a code path, varchar fields drift from enum truth
4. ProviderConnectionStateProjector does NOT always persist projected values
5. UI filters use varchar vocabulary, which breaks if normalization rules change
**Recommended action**: **Finish cutover** — migrate UI to read `consent_status` and `verification_status` directly. Deprecate varchar `status` and `health_status`. Eventually drop columns.
**Classification**: should remove
---
### Finding 5: backup_sets.item_count Denormalized Counter Drift
**Category**: 4. Read paths without credible write paths
**Severity**: MEDIUM
**Confidence**: HIGH
**Why this exists**: `item_count` is a denormalized integer stored on BackupSet for quick display without counting related BackupItems.
**Canonical source of truth**: `BackupSet::items()->count()` (relationship count)
**Stale structure**: `backup_sets.item_count` column (unsignedInteger, default 0)
**Read paths**: BackupSetResource table column, BackupSetResource detail page (2x), RunBackupScheduleJob, RemovePoliciesFromBackupSetJob
**Write paths**: AddPoliciesToBackupSetJob, RemovePoliciesFromBackupSetJob, BackupService (all call `$backupSet->items()->count()` to sync)
**Why this matters**: The write path re-counts from DB, which is correct. But if BackupItems are deleted through the BackupItemsRelationManager or soft-delete cascading, and the parent BackupSet isn't re-synced through the job infrastructure, the counter drifts silently. Currently no event/observer recounts on BackupItem delete.
**Recommended action**: Either add an observer on BackupItem to recount parent, or replace with `withCount('items')` in queries and drop the denormalized column.
**Classification**: should remove (or add sync observer)
---
### Finding 6: SyncRestoreRunToOperation.php — Orphaned Listener
**Category**: 8. Jobs/events/listeners that no longer matter
**Severity**: LOW
**Confidence**: HIGH (confirmed via Spec 087)
**Why this exists**: Early listener for RestoreRun → OperationRun sync. Superseded by `SyncRestoreRunToOperationRun.php` which is called directly from the RestoreRunObserver.
**Stale structure**: `app/Listeners/SyncRestoreRunToOperation.php`
**Read paths**: None — never imported, never registered
**Write paths**: N/A — never instantiated
**Evidence**: Zero grep matches for class name. Spec 087 analysis report (line 616) flagged it as "Low risk; verify/keep during transition."
**Recommended action**: **Remove** — Spec 087 is complete, transition is done
**Classification**: can remove
---
### Finding 7: TenantFactory Populates Stale Fields
**Category**: 9. Test suite preserving dead architecture
**Severity**: MEDIUM
**Confidence**: HIGH
**Why this exists**: Factory was created with the original Tenant schema and never cleaned up as fields became stale.
**Stale structure**: `database/factories/TenantFactory.php` lines 40-44, 49
**Evidence**:
```php
'app_client_id' => fake()->uuid(), // Legacy: only read for migration classification
'app_status' => 'ok', // Dead: never updated in production
'app_notes' => null, // Dead: never written or read
'rbac_status' => 'ok', // Hardcoded: never reflects real health checks
```
**Why this matters**: Every `Tenant::factory()->create()` call populates dead fields with plausible-looking values. Tests pass even though these values never match production behavior. `app_status='ok'` in tests masks that the field is always `'unknown'` in production (migration default). `rbac_status='ok'` prevents testing RBAC degradation paths.
**Recommended action**:
1. Remove `app_status` and `app_notes` from factory definition
2. Make `app_client_id` nullable in factory (set only when testing migration classification)
3. Set `rbac_status` to `null` in default definition; use named states for health scenarios
**Classification**: should remove (update factory)
---
### Finding 8: Badge Normalization Mismatches
**Category**: 3. Mapping drift / badge drift
**Severity**: MEDIUM
**Confidence**: HIGH
**Why this exists**: Badge normalization functions translate between different state vocabularies (enum values → display values). Over time, the translation rules have diverged from the actual value sets.
**Evidence**:
- `TenantAppStatus` filter accepts `unknown` but badge domain has no explicit mapping → falls back to `BadgeSpec::unknown()` (gray question mark)
- `ProviderConnectionStatus` badge normalizes 7+ input values into 3 display states; drift if new enum cases added
- `ProviderConnectionHealth` badge normalizes `healthy``ok` and `blocked`/`error` → `down`; these translations are buried in badge catalog, not documented
**Why this matters**: When an admin sees a gray question mark badge, they cannot distinguish "this record has a genuinely unknown status" from "this record has a status value that the badge system doesn't recognize." This masks data quality issues.
**Recommended action**: Add exhaustive badge mapping tests (dataset-driven) ensuring every enum case has an explicit badge spec. Remove `BadgeSpec::unknown()` fallback for production badge domains — force all values to be explicitly mapped.
**Classification**: should remove (fallback masking)
---
### Finding 9: tenant_user Legacy Pivot Table
**Category**: 6. Partially completed migrations
**Severity**: LOW
**Confidence**: HIGH
**Why this exists**: Original many-to-many pivot for tenant ↔ user. Migrated to `tenant_memberships` table (richer model with role, source, created_by) in Jan 2026.
**Old world**: `tenant_user` pivot table
**New world**: `tenant_memberships` table (TenantMembership model)
**What still references old**: Data was backfilled via `2026_01_25_023708_backfill_tenant_memberships_from_tenant_user.php`. No model references `tenant_user`.
**What prevents deletion**: No drop migration exists. Table is present but unused.
**Recommended action**: Create drop migration for `tenant_user` table. Add architecture guard test.
**Classification**: can remove
---
### Finding 10: EntraGroupPolicy Bypasses Capability Layer
**Category**: 7. Orphaned policies / security-relevant
**Severity**: MEDIUM
**Confidence**: HIGH
**Why this exists**: EntraGroupPolicy was created for the EntraGroupResource but only checks `canAccessTenant()` — it does not check any capability constants.
**Evidence**: `app/Policies/EntraGroupPolicy.php``viewAny()` and `view()` methods call `$user->canAccessTenant()` only.
**Why this matters**: All other policies enforce capability checks (e.g., `TENANT_FINDINGS_VIEW`, `WORKSPACE_BASELINES_MANAGE`). EntraGroupPolicy is the only read-access policy that skips the capability layer entirely. While EntraGroups are read-only, this inconsistency could lead to copy-paste errors if new methods are added.
**Recommended action**: Add appropriate capability check (e.g., `TENANT_VIEW`) or document the intentional bypass.
**Classification**: should remove (the bypass, not the policy)
---
### Finding 11: ProviderConnectionStateProjector Untested
**Category**: 9. Test suite preserving dead architecture (gap variant)
**Severity**: MEDIUM
**Confidence**: HIGH
**Why this exists**: The projector is a critical bridge between the new enum status sources and the old varchar display fields. Despite being called from 5+ production paths, it has no dedicated test coverage.
**Evidence**: `app/Services/Providers/ProviderConnectionStateProjector.php` — called by TenantOnboardingController, AdminConsentCallbackController, ManagedTenantOnboardingWizard, CreateProviderConnection, ProviderConnectionHealthCheckJob.
**Why this matters**: If the projection logic breaks (e.g., a new enum case is added to `ProviderConsentStatus` without updating the projector), the derived `status` and `health_status` fields silently produce wrong values. No test would catch this.
**Recommended action**: Create dataset-driven unit test covering all enum combinations → projected varchar values.
**Classification**: must address (test gap for critical infrastructure)
---
### Finding 12: RestoreRunStatus Legacy Enum Cases
**Category**: 3. Mapping drift / enum drift
**Severity**: LOW
**Confidence**: HIGH
**Why this exists**: `RestoreRunStatus` enum includes `Aborted` and `CompletedWithErrors` cases labeled as `(legacy)` in the enum inventory. These were from an earlier restore workflow.
**Evidence**: `app/Support/RestoreRunStatus.php` — cases `Aborted` and `CompletedWithErrors` exist but are never written by current restore execution code.
**Why this matters**: These cases still have badge mappings and can appear in filters, but no production code path produces them. They are backward-compatibility noise that should be formally deprecated.
**Recommended action**: Mark with `@deprecated` annotation. Keep for now (DB may contain historical records with these values) but exclude from filters and creation logic.
**Classification**: can remove (from active UX, keep in enum for deserialization)
---
### Finding 13: TENANT_ROLE_MAPPING Capabilities — Defined but No UI
**Category**: 7. Orphaned capabilities
**Severity**: LOW
**Confidence**: MEDIUM
**Why this exists**: `TENANT_ROLE_MAPPING_VIEW` and `TENANT_ROLE_MAPPING_MANAGE` are defined in `Capabilities.php` and mapped in role capability resolvers, but no Filament resource or page enforces them. The TenantRoleMapping model exists with a migration but has no admin UI.
**Evidence**: `app/Support/Auth/Capabilities.php` — constants defined. Tests confirm capability resolver maps them correctly. No Filament resource references them.
**Why this matters**: This is intentional WIP infrastructure (Spec 062, T028 marked optional). Not dangerous, just incomplete.
**Recommended action**: Keep — this is staged infrastructure. Add a comment documenting the planned UI.
**Classification**: keep (intentional WIP)
---
### Finding 14: Direct Graph Calls Bypassing ProviderGateway
**Category**: 2. Legacy abstractions still referenced
**Severity**: MEDIUM
**Confidence**: HIGH
**Why this exists**: Two locations in TenantResource.php make direct `$graph->request('GET', ...)` calls instead of using the ProviderGateway abstraction layer.
**Evidence**:
- `app/Filament/Resources/TenantResource.php` line ~1571: `$graph->request('GET', 'deviceManagement/roleDefinitions', ...)`
- `app/Filament/Resources/TenantResource.php` line ~1692: `$graph->request('GET', 'groups', ...)`
**Why this matters**: All other Graph calls go through ProviderGateway, which handles credential resolution, contract validation, and error mapping. These direct calls bypass those protections and use an inconsistent error handling pattern.
**Recommended action**: Refactor to use ProviderGateway or a dedicated service method.
**Classification**: should remove (the direct calls)
---
### Finding 15: AlertDeliveryPolicy Incomplete Methods
**Category**: 7. Orphaned policies
**Severity**: LOW
**Confidence**: MEDIUM
**Evidence**: `app/Policies/AlertDeliveryPolicy.php` — only `viewAny()` and `view()` exist. No `create()`, `update()`, `delete()`. AlertDelivery is a read-only model (deliveries are created by jobs, not users), so this is likely intentional but should be documented.
**Recommended action**: Add `create()`, `update()`, `delete()` methods returning `false` to make read-only intent explicit. Or document in PHPDoc.
**Classification**: can remove (the ambiguity, not the policy)
---
## Cross-cutting Architectural Themes
### Theme A: Denormalized Status Shadow Fields
**Pattern**: A canonical source of truth exists (enum column, service output, relationship count) but a denormalized varchar/integer copy is persisted for query performance or historical reasons, then displayed in UI without lifecycle maintenance.
**Instances**: `Tenant.app_status`, `ProviderConnection.status`/`health_status`, `backup_sets.item_count`
**Root cause**: Columns were added before the canonical source existed, then the canonical source was introduced without deprecating the shadow field.
**Fix pattern**: For each shadow field, choose one of:
- Live derivation (compute on read via accessor or query join)
- Event-driven sync (observer/listener updates shadow on canonical change)
- Remove entirely (if no query performance justification)
### Theme B: Incomplete Provider Identity Cutover
**Pattern**: Tenant was the original identity holder (app_client_id, app_client_secret, app_status). ProviderConnection + ProviderCredential now own identity. Migration classification exists but the old Tenant columns remain populated (by factory) and displayed (by TenantResource).
**What blocks full cutover**: The classification service still reads `Tenant.app_client_id` to detect legacy identity. Until all tenants pass migration review, these fields serve as migration input.
**Residual risk**: Factory continues populating legacy fields, tests assert against them, TenantResource displays `app_status` badge.
### Theme C: Badge Normalization as Hidden Translation Layer
**Pattern**: Badge domains contain normalization functions that translate between different value vocabularies. These are undocumented, untested, and drift-prone.
**Instances**: TenantAppStatus normalizer, ProviderConnectionStatus normalizer, ProviderConnectionHealth normalizer.
**Fix pattern**: Exhaustive enum-to-badge mapping tests. Remove `BadgeSpec::unknown()` fallback for production domains. Move normalization out of badge catalog into explicit enum methods.
### Theme D: Test Suite Preserving Dead Architecture
**Pattern**: Factories populate stale fields with plausible values, making tests pass even though those fields are production-dead. Guard tests exist (excellent practice) but factory defaults create a false baseline.
**Instances**: TenantFactory sets `app_status='ok'`, `rbac_status='ok'`, generates `app_client_id`.
**Fix pattern**: Factory defaults should match production reality. Use named factory states for legacy-identity-specific test scenarios.
### Theme E: Projection Without Test Coverage
**Pattern**: Critical state-computation services (ProviderConnectionStateProjector) translate between enum sources and derived display fields but have no dedicated test coverage.
**Risk**: A new enum case added without projector update silently produces wrong UI state.
**Fix pattern**: Dataset-driven exhaustive projector tests covering all enum combinations.
---
## Spec Candidate Recommendations
### Spec Candidate 1: Tenant Legacy Identity Cleanup
**Scope**: Remove `app_status`, `app_notes` column and UI surfaces. Mark `app_client_id`/`app_client_secret` as `@deprecated` migration-only fields. Clean TenantFactory.
**Tasks**:
1. Remove `app_status` from TenantResource (table, infolist, filter)
2. Remove `BadgeDomain::TenantAppStatus` and related badge files
3. Migration: drop `app_status`, `app_notes` columns + index
4. Update TenantFactory: remove `app_status`, `app_notes` from definition
5. Add `@deprecated` PHPDoc to `app_client_id`, `app_client_secret` on Tenant model
6. Update tests that reference `app_status`
7. Drop `tenant_user` legacy pivot table
**Findings covered**: #1, #2, #3, #7, #9
---
### Spec Candidate 2: ProviderConnection Status Simplification
**Scope**: Collapse 4 status columns to 2 canonical enums. UI reads enums directly. Drop varchar projection columns.
**Tasks**:
1. Migrate TenantResource and ProviderConnectionResource to read `consent_status`/`verification_status` directly
2. Update badge domains to map enum values (not projected varchars)
3. Update filters to use enum values
4. Create projector test suite (exhaustive enum combinations)
5. Deprecate, then drop `status` and `health_status` varchar columns
6. Remove normalization functions from BadgeCatalog
**Findings covered**: #4, #8, #11
---
### Spec Candidate 3: Denormalized Counter Integrity
**Scope**: Fix `backup_sets.item_count` drift. Either add observer for sync or replace with live count.
**Tasks**:
1. Add BackupItem observer that recounts parent BackupSet `.item_count` on create/delete/restore
2. Or: replace all reads of `item_count` with `withCount('items')` and drop the column
3. Add test coverage for count drift scenario
**Findings covered**: #5
---
### Spec Candidate 4: Orphaned Code Removal (Quick Wins)
**Scope**: Remove clearly dead code identified in this audit.
**Tasks**:
1. Delete `app/Listeners/SyncRestoreRunToOperation.php`
2. Add architecture guard test confirming it's gone
3. Clean up `RestoreRunStatus::Aborted` and `CompletedWithErrors` from active filters (keep in enum)
4. Add capability check to `EntraGroupPolicy` or document bypass
5. Add explicit deny methods to `AlertDeliveryPolicy`
6. Refactor 2 direct Graph calls in TenantResource to use ProviderGateway
**Findings covered**: #6, #10, #12, #14, #15

View File

@ -5,7 +5,7 @@ # Spec Candidates
>
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
**Last reviewed**: 2026-03-15
**Last reviewed**: 2026-03-16 (action surface cluster added, tenant draft discard lifecycle added)
---
@ -55,6 +55,29 @@ ### Livewire Context Locking and Trusted-State Reduction
- **Dependencies**: Managed tenant onboarding draft identity (Spec 138), onboarding lifecycle checkpoint work (Spec 140)
- **Priority**: medium
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
- **Type**: hardening
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
- **Problem**: TenantPilot correctly separates durable tenant lifecycle (`draft`, `onboarding`, `active`, `archived`) from onboarding workflow lifecycle (`draft` → `completed` / `cancelled`), but there is no end-of-life path for abandoned draft tenants. When all onboarding sessions for a tenant are cancelled, the tenant reverts to `draft` and remains visible indefinitely without a semantically correct cleanup action. Archive/restore do not apply (draft tenants have no operational data worth preserving), and force delete requires archive first (which is semantically wrong for a provisional record). Operators cannot remove orphaned drafts.
- **Why it matters**: Without a discard path, abandoned draft tenants accumulate as orphaned rows in the tenant list. This creates operator confusion (draft vs. archived vs. active ambiguity), data hygiene issues, and forces operators to either ignore stale records or misuse lifecycle actions that don't fit the domain semantics. The gap also makes tenant list UX harder to trust for enterprise operators managing many tenants.
- **Proposed direction**:
- Introduce a canonical **draft discardability contract** (central service/policy, not scattered UI visibility logic) that determines whether a draft tenant may be safely removed, considering linked onboarding sessions, downstream artifacts, and operational traces
- Add a **discard draft** destructive action for tenant records in `draft` status with no resumable onboarding sessions, gated by the discardability contract, capability authorization (`tenant.delete` or a dedicated `tenant.discard_draft`), and confirmation modal
- Add an **orphaned draft indicator** to the tenant list/detail views — visual distinction between a resumable draft (has active session) and an abandoned draft (all sessions terminal or none exist)
- Emit a **distinct audit event** (`tenant.draft_discarded`) separate from `tenant.force_deleted`, capturing workspace context, tenant identifiers, linked session state, and acting user
- Preserve and reinforce the existing domain separation: `archive/restore/force_delete` remain reserved for durable tenant lifecycle; `cancel/delete` remain reserved for onboarding workflow lifecycle; `discard` is the new end-of-life action for provisional drafts
- **Key domain rules**:
- `archive` = preserve durable tenant for compliance while removing from active use
- `restore` = reactivate an archived durable tenant
- `force delete` = permanently destroy an already archived durable tenant
- `discard draft` = permanently remove a provisional tenant that never became a durable operational entity
- Draft tenants must NOT become archivable or restorable
- **Safety preconditions for discard**: tenant is in `draft` status, not trashed, no resumable onboarding sessions exist, no accumulated operational data (no policies, backups, operation runs beyond onboarding)
- **Out of scope**: automatic cleanup without operator confirmation, retention policy for cancelled onboarding sessions, changes to the 4-state tenant lifecycle enum, changes to the 7-state onboarding session lifecycle enum
- **Dependencies**: Spec 140 (onboarding lifecycle checkpoints — already shipped), Spec 143 (tenant lifecycle operability context semantics)
- **Related specs**: Spec 138 (draft identity), Spec 140 (lifecycle checkpoints), Spec 143 (lifecycle operability semantics)
- **Priority**: medium
### Exception / Risk-Acceptance Workflow for Findings
- **Type**: feature
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
@ -199,6 +222,118 @@ ### Provider Connection Legacy Cleanup
- **Related specs**: Spec 081 (credential migration guardrails), Spec 088 (provider connection model), Spec 137 (data-layer provider prep)
- **Priority**: medium (deferred until normalization is complete)
### Tenant App Status False-Truth Removal
- **Type**: hardening
- **Source**: legacy / orphaned truth audit 2026-03-16
- **Classification**: quick removal
- **Problem**: `Tenant.app_status` is displayed in tenant UI as current operational truth even though production code no longer writes it. Operators can see a frozen "OK" or other stale badge that does not reflect the real provider connection state.
- **Why it matters**: This is misleading operator-facing truth, not just dead schema. It creates false confidence on a tier-1 admin surface.
- **Target model**: `Tenant`
- **Canonical source of truth**: `ProviderConnection.consent_status` and `ProviderConnection.verification_status`
- **Must stop being read**: `Tenant.app_status` in `TenantResource` table columns, infolist/details, filters, and badge-domain mapping.
- **Can be removed immediately**:
- TenantResource reads of `app_status`
- tenant app-status badge domain / badge mapping usage
- factory defaults that seed `app_status`
- **Remove only after cutover**:
- the `tenants.app_status` column itself, once all UI/report/export reads are confirmed gone
- **Migration / backfill**: No backfill. One cleanup migration to drop `app_status`. `app_notes` may be dropped in the same migration only if it does not broaden the spec beyond tenant stale app fields.
- **UI / resource / policy / test impact**:
- UI/resources: remove misleading badge and filter from tenant surfaces
- Policy: none
- Tests: update `TenantFactory`, remove assertions that treat `app_status` as live truth
- **Scope boundaries**:
- In scope: remove stale tenant app-status reads and schema field
- Out of scope: provider connection UX redesign, credential migration, broader tenant health redesign
- **Dependencies**: None required if the immediate operator-facing action is removal rather than replacement with a new tenant-level derived badge.
- **Risks**: Low rollout risk. Main risk is short-term operator confusion about where to view connection health after removal.
- **Why it should be its own spec**: This is the cleanest high-severity operator-trust fix in the repo. It is bounded, low-coupling, and should not wait for the larger provider cutover work.
- **Priority**: high
### Provider Connection Status Vocabulary Cutover
- **Type**: hardening
- **Source**: legacy / orphaned truth audit 2026-03-16
- **Classification**: bounded cutover
- **Problem**: `ProviderConnection` currently exposes overlapping status vocabularies across `status`, `health_status`, `consent_status`, and `verification_status`. Resources, badges, and filters can read both projected legacy state and canonical enum state, creating drift and operator ambiguity.
- **Why it matters**: This is duplicate status truth on an operator-facing surface. It also leaves the system vulnerable to projector drift if legacy projected fields stop matching the enum source of truth.
- **Target model**: `ProviderConnection`
- **Canonical source of truth**: `ProviderConnection.consent_status` and `ProviderConnection.verification_status`
- **Must stop being read**: `ProviderConnection.status` and `ProviderConnection.health_status` in resources, filters, badges, and any operator-facing status summaries.
- **Can be removed immediately**:
- new operator-facing reads of legacy varchar status fields
- new badge/filter logic that depends on normalized legacy values
- **Remove only after cutover**:
- `status` and `health_status` columns
- projector persistence of those fields, if still retained for compatibility
- legacy badge normalization paths
- **Migration / backfill**: No data backfill if enum columns are already complete. Requires a later schema cleanup migration to drop legacy varchar columns after all reads are migrated.
- **UI / resource / policy / test impact**:
- UI/resources: `ProviderConnectionResource` and related badges/filters move to one coherent operator vocabulary
- Policy: none directly
- Tests: add exhaustive projection and badge mapping coverage during the transition; update resource/filter assertions to enum-driven behavior
- **Scope boundaries**:
- In scope: provider connection status fields, display semantics, badge/filter vocabulary, deprecation path for projected columns
- Out of scope: tenant credential migration, provider onboarding flow redesign, unrelated badge cleanup elsewhere
- **Dependencies**: Confirm all hidden read paths outside the main resource and define the operator-facing enum presentation.
- **Risks**: Medium rollout risk. Filters, badges, and operator language change together, and hidden reads may exist outside the primary resource.
- **Why it should be its own spec**: This is a self-contained source-of-truth cutover on one model. It is too important and too operationally visible to bury inside a generic provider cleanup spec.
- **Priority**: high
### Tenant Legacy Credential Source Decommission
- **Type**: hardening
- **Source**: legacy / orphaned truth audit 2026-03-16
- **Classification**: staged migration
- **Problem**: Tenant-level credential fields remain in the data model after ProviderCredential became the canonical identity store. They are still used for migration classification and are kept artificially alive by factory defaults, which obscures the real architecture and prolongs the cutover.
- **Why it matters**: This is an incomplete architectural cutover around sensitive identity data. The system needs an explicit end-state where runtime credential resolution no longer depends on tenant legacy fields.
- **Target model**: `Tenant`, with `ProviderCredential` as the destination canonical model
- **Canonical source of truth**: `ProviderCredential.client_id` and `ProviderCredential.client_secret`
- **Must stop being read**: tenant legacy credential fields in normal runtime credential resolution. Transitional reads remain allowed only inside migration-classification paths until exit criteria are met.
- **Can be removed immediately**:
- factory defaults that populate legacy tenant credentials by default
- any non-classification runtime reads if discovered during spec work
- UI affordances that imply tenant-stored credentials are active
- **Remove only after cutover**:
- `Tenant.app_client_id`, `Tenant.app_client_secret`, `Tenant.app_certificate_thumbprint`
- migration-classification reads and related transitional guardrails
- **Migration / backfill**: Requires explicit completion criteria for the tenant-to-provider credential migration. No blind backfill; removal should follow confirmed migration review state for all affected tenants.
- **UI / resource / policy / test impact**:
- UI/resources: remove any residual legacy credential messaging once the cutover is complete
- Policy: none directly
- Tests: `TenantFactory` must stop creating legacy credentials by default; transition-only tests should use explicit legacy states
- **Scope boundaries**:
- In scope: tenant legacy credential fields, classification-only transition reads, factory/test cleanup tied to the cutover
- Out of scope: provider connection status vocabulary, unrelated tenant stale fields, onboarding UX redesign
- **Dependencies**: Hard dependency on the provider credential migration/review lifecycle being complete enough to identify all remaining transitional tenants safely.
- **Risks**: Higher rollout risk than simple cleanup because this touches credential-path architecture and transitional data needed for migration review.
- **Why it should be its own spec**: This has distinct exit criteria, migration gating, and rollback concerns. It is not the same problem as stale operator-facing badges or provider status vocabulary cleanup.
- **Priority**: high
### Entra Group Authorization Capability Alignment
- **Type**: hardening
- **Source**: legacy / orphaned truth audit 2026-03-16
- **Classification**: bounded cutover
- **Problem**: `EntraGroupPolicy` currently grants read access based on tenant access alone and bypasses the capability layer used by the rest of the repo's authorization model.
- **Why it matters**: This is a security- and RBAC-relevant inconsistency. Even if currently read-only, it weakens the capability-first architecture and increases the chance of future authorization drift.
- **Target model**: `EntraGroupPolicy` and the Entra group read-access surface
- **Canonical source of truth**: capability-based authorization decisions layered on top of tenant-access checks
- **Must stop being read**: implicit "tenant access alone is sufficient" as the effective rule for Entra group read access.
- **Can be removed immediately**:
- the direct bypass if the correct capability already exists and seeded roles already carry it
- **Remove only after cutover**:
- any compatibility allowances needed while role-capability mappings are updated and verified
- **Migration / backfill**: Usually no schema migration. May require role-capability seeding updates or RBAC backfill so intended operators retain access.
- **UI / resource / policy / test impact**:
- UI/resources: some users may lose access if role mapping is incomplete; tenant-facing Entra group screens need regression verification
- Policy: this spec is the policy change
- Tests: add authorization matrix coverage proving tenant access alone no longer grants read access
- **Scope boundaries**:
- In scope: read authorization semantics for Entra group surfaces and the required capability mapping
- Out of scope: new CRUD semantics, role mapping product UI, unrelated policy tidy-up
- **Dependencies**: Choose the correct capability and verify seeded/default roles include it where intended.
- **Risks**: Medium rollout risk because authorization mistakes become access regressions for legitimate operators.
- **Why it should be its own spec**: This is a targeted RBAC hardening change with its own stakeholders, rollout checks, and regression matrix. It should not be hidden inside data or UI cleanup work.
- **Priority**: high
### Support Intake with Context (MVP)
- **Type**: feature
- **Source**: Product design, operator feedback
@ -208,6 +343,153 @@ ### Support Intake with Context (MVP)
- **Dependencies**: OperationRun-Domain stabil, RBAC/Capability-System (066+), Workspace-/Tenant-Scoping
- **Priority**: medium
### Policy Setting Explorer — Reverse Lookup for Tenant Configuration
- **Type**: feature
- **Source**: recurring enterprise pain point, governance/troubleshooting gap
- **Problem**: In medium-to-large Intune tenants with dozens of policy types and hundreds of policies, admins routinely face the question: "Where is this setting actually defined?" Examples: "Which policy configures BitLocker?", "Where is `EnableTPM` set to `true`?", "Why does this tenant enforce a specific firewall rule, and which policy is the source?" Today, answering this requires manually opening policies one by one across device configuration, compliance, endpoint security, admin templates, settings catalog, and more. TenantPilot inventories and versions these policies but provides no reverse-lookup surface that maps a setting name, key, or value back to the policies that explicitly define it.
- **Why it matters**: This is a governance, troubleshooting, and explainability gap — not a search convenience. Enterprise admins, auditors, and reviewers need authoritative answers to "where is X defined?" for incident triage, change review, compliance evidence, and duplicate detection. Without it, TenantPilot has deep policy data but cannot surface it from the operator's natural entry point (the setting, not the policy). This capability directly increases the product's value proposition for security reviews, audit preparation, and day-to-day configuration governance.
- **V1 scope**:
- Tenant-scoped only. User queries settings within the active tenant's indexed policies. No cross-tenant or portfolio-wide search in V1.
- Dedicated working surface: a tenant-level "Policy Explorer" or "Setting Search" page with query input, filters, and structured result inspection. Not a global header search widget.
- Query modes: search by setting name/label, by raw key/path, or by value-oriented query (e.g. `EnableTPM = true`).
- Results display: policy name, policy type/family, setting label/path/key, configured value, version/snapshot context, deep link to the policy detail or version inspector.
- Supported policy families: start with a curated subset of high-value indexed families (settings catalog, device configuration, compliance, endpoint security baselines, admin templates). Not every Microsoft policy type from day one.
- Search projection model: a lightweight extracted-setting-facts table per supported policy family. Preserves policy-family-local structure, retains raw path/key, stores search-friendly displayable rows. PostgreSQL-first (GIN indexes on JSONB or dedicated columns as appropriate). Not a universal canonical key normalization engine — a pragmatic, product-oriented search projection.
- Trust boundary: results reflect settings explicitly present in supported indexed policies. UI must clearly communicate this scope. No-result does NOT imply the setting is absent from effective tenant configuration — only that it was not found in currently supported indexed policy families. This distinction must be visible in the UX (scope indicator, help text, empty-state copy).
- "Defined in" only: V1 answers "where is this setting explicitly defined?" — it does NOT answer "is this setting effectively applied to devices/users?" The difference between explicit definition and effective state must be preserved and communicated.
- **Explicit non-goals (V1)**:
- No universal cross-provider canonical setting ontology. Avoid a large fragile semantic mapping project. Setting identity stays policy-family-local in V1.
- No effective-state guarantees. V1 does not resolve assignment targeting, conflict resolution, or platform-side precedence.
- No portfolio / cross-tenant / workspace-wide scope.
- No dependency on external search infrastructure (Elasticsearch, Meilisearch, etc.) if PostgreSQL-first is sufficient.
- No naive raw JSON full-text search as the product surface. The projection model must provide structured, rankable, explainable results — not grep output.
- No requirement to support every Microsoft policy family from day one.
- **Architectural direction**:
- Search projection layer: when policies are synced/versioned, extract setting facts into a dedicated search-friendly projection (e.g. `policy_setting_facts` table or JSONB-indexed structure). Each row captures: `tenant_id`, `policy_id`, `policy_version_id` (nullable), `policy_type`/`family`, `setting_key`/`path`, `setting_label` (display name where available), `configured_value`, `raw_payload_path`. Extraction logic is per-family, not a universal parser.
- PostgreSQL-first: use GIN indexes on JSONB or trigram indexes on text columns for efficient search. Evaluate `pg_trgm` for fuzzy matching.
- Extraction is append/rebuild on sync — not real-time transformation. Can be a post-sync projection step integrated into the existing inventory sync pipeline.
- Provider boundary stays explicit: the projection is populated by each policy family's extraction logic. No abstraction that pretends all policy families share the same schema.
- RBAC: tenant-scoped, gated by a capability (e.g. `policy.settings.search`). Results respect existing policy-level visibility rules.
- Audit: queries are loggable but do not require per-query audit entries in V1. The feature is read-only.
- **UX direction**:
- Primary surface: dedicated page under the tenant context (e.g. tenant → Policy Explorer or tenant → Setting Search). Full working surface with query input, optional filters (policy type, policy family, value match mode), and a results table.
- Result rows: policy name (linked), policy type badge, setting path/key, configured value, version indicator. Expandable detail or click-through to policy inspector.
- Empty state: clearly explains scope limitations ("No matching settings found in supported indexed policies. This does not mean the setting is absent from your tenant's effective configuration.").
- Scope indicator: persistent badge or label showing the search scope (e.g. "Searching N supported policy families in [tenant name]").
- Future quick-access entry point (e.g. command palette, header search shortcut) is a natural extension but not V1 scope.
- **Future expansion space** (not V1):
- Semantic aliases / display-name normalization across families
- Duplicate / conflict detection hints ("this setting is defined in 3 policies")
- Assignment-aware enrichment ("this policy targets group X")
- Setting history / change timeline ("this value changed from false to true in version 4")
- Baseline / drift linkage ("this setting deviates from the CIS baseline")
- Workspace-wide / portfolio search across tenants
- Quick-access command palette entry point
- **Risks / notes**:
- Extraction logic per policy family is the main incremental effort. Each new family supported requires a family-specific extractor. Start with the highest-value families and expand.
- Settings catalog policies have structured setting definitions that are relatively easy to extract. OMA-URI / admin template policies are less structured. The V1 family selection should favor extractability.
- The "no-result ≠ not configured" trust boundary is critical for enterprise credibility. Overcommitting search completeness erodes trust.
- Projection freshness depends on sync frequency. Stale projections must be visually flagged if the tenant hasn't been synced recently.
- **Dependencies**: Inventory sync stable, policy versioning (snapshots), tenant context model, RBAC capability system (066+)
- **Priority**: high
<!-- Row Interaction / Action Surface follow-up cluster (2026-03-16) -->
> **Action Surface follow-up direction** — The action-surface contract foundation (Specs 082, 090) and the follow-up taxonomy/viewer specs (143146) are all fully implemented. The remaining gaps are not architectural redesign — they are incomplete adoption, missing decision criteria, and scope boundaries that haven't expanded to cover all product surfaces. The correct shape is: one foundation amendment to codify the missing rules and extend contract scope (v1.1), two compliance rollout specs to enroll currently-exempted surface families, and one targeted correction to fix the clearest remaining anti-pattern on a high-signal surface. This avoids reinventing the architecture, avoids umbrella "consistency" specs, and produces bounded, independently shippable work. TenantResource lifecycle-conditional actions and PolicyResource More-menu ordering are addressed by the updated foundation rules, not by standalone specs. Widgets, choosers, and pickers remain deferred/exempt.
### Action Surface Contract v1.1 — Decision Criteria, Ordering Rules, and System Scope Extension
- **Type**: foundation/spec amendment
- **Source**: row interaction / action surface architecture analysis 2026-03-16
- **Problem**: The action-surface contract (Spec 082) establishes profiles, slots, affordances, validator tests, and guard tests — but does not codify three things: (1) formal decision criteria for when a surface should use ClickableRow vs ViewAction vs PrimaryLinkColumn as its inspect affordance; (2) ordering rules for actions inside the More menu (destructive-last, lifecycle position, stable grouping); (3) system-panel table surfaces are explicitly excluded from contract scope, meaning ~6 operational surfaces have no declaration and no CI coverage. The architecture is correct; it just cannot prevent inconsistent choices on new surfaces or catch drift on existing ones.
- **Why this is its own spec**: This is a foundation amendment — it changes the rules that all other surfaces must follow. Rollout specs (system panel enrollment, relation manager enrollment) depend on this spec's updated rules existing first. Merging rollout work into a foundation amendment blurs the boundary between "what the rules are" and "who must comply."
- **In scope**:
- Codify inspect-affordance decision tree (ClickableRow default, ViewAction exception criteria, PrimaryLinkColumn criteria) in `docs/ui/action-surface-contract.md`
- Define the "lone ViewAction" anti-pattern formally and add it to validator detection
- Codify More-menu action ordering rules (lifecycle actions, severity ordering, destructive-last)
- Extend contract scope so system-panel table surfaces are enrollable (not exempt by default)
- Add guidance that cross-panel surface taxonomy should converge where semantically equivalent
- Update `ActionSurfaceValidator` to enforce new criteria
- Update guard/contract tests to cover new rules
- **Non-goals**:
- Retrofitting all existing system-panel pages (separate rollout spec)
- Retrofitting all relation managers (separate rollout spec)
- One-off resource-level fixes (those are tasks within rollout or correction specs)
- TenantResource or PolicyResource redesign (addressed by applying the updated rules, not by dedicated specs)
- Chooser/picker/widget contracts (remain deferred/exempt)
- **Depends on**: Spec 082, Spec 090 (both fully complete — this extends their foundation)
- **Suggested order**: First. All other candidates in this cluster depend on the updated rules.
- **Risk**: Low. This adds rules and extends scope — it does not change existing compliant declarations.
- **Why this boundary is right**: Foundation rules must be codified before rollout enforcement. Mixing rule definition with compliance rollout makes it impossible to review the rules independently and creates circular dependencies.
- **Priority**: high
### System Panel Action Surface Contract Enrollment
- **Type**: compliance rollout
- **Source**: row interaction / action surface architecture analysis 2026-03-16
- **Problem**: System-panel table surfaces (Ops/Runs, Ops/Failures, Ops/Stuck, Directory/Tenants, Directory/Workspaces, Security/AccessLogs) use `recordUrl()` consistently but have no `ActionSurfaceDeclaration`, no CI coverage, and are exempt from the contract by default. They are the largest family of undeclared table surfaces in the product.
- **Why this is its own spec**: System-panel surfaces belong to a different panel with different operator audiences and potentially different profile requirements. Enrolling them is a distinct compliance effort from tenant-panel relation managers or targeted resource corrections. The scope is bounded and independently shippable.
- **In scope**:
- Declare `ActionSurfaceDeclaration` for each system-panel table surface (~6 pages)
- Map to existing profiles where semantically correct (e.g., `ListOnlyReadOnly` for access logs, `RunLog` for ops run tables)
- Introduce new system-specific profiles only if existing profiles truly do not fit
- Remove enrolled system-panel pages from `ActionSurfaceExemptions` baseline
- Add guard test coverage for enrolled system surfaces
- **Non-goals**:
- Tenant-panel resource declarations (already covered by Spec 090)
- Relation manager enrollment (separate candidate)
- Non-table system pages (dashboards, diagnostics, choosers)
- System-panel RBAC redesign
- Cross-workspace query authorization (tracked as "System Console Scope Hardening" candidate)
- **Depends on**: Action Surface Contract v1.1 (must extend scope to system panel first)
- **Suggested order**: Second, in parallel with "Run Log Inspect Affordance Alignment" after v1.1 is complete.
- **Risk**: Low. These surfaces already behave consistently; this work adds formal declarations and CI coverage.
- **Why this boundary is right**: System-panel enrollment is self-contained — it doesn't touch tenant-panel resources or relation managers. Completing it independently gives CI coverage over a currently-invisible surface family.
- **Priority**: medium
### Relation Manager Action Surface Contract Enrollment
- **Type**: compliance rollout
- **Source**: row interaction / action surface architecture analysis 2026-03-16
- **Problem**: Three relation managers (`BackupItemsRelationManager`, `TenantMembershipsRelationManager`, `WorkspaceMembershipsRelationManager`) are in the `ActionSurfaceExemptions` baseline with no declaration. They were exempted during initial rollout (Spec 090) because relation-manager-specific profile semantics were not yet settled. Three other relation managers already have declarations. The exemption should be reduced, not permanent.
- **Why this is its own spec**: Relation managers have different interaction expectations than standalone list resources (context is always nested under a parent record, pagination/empty-state semantics differ, attach/detach may replace create/delete in some cases). Enrollment requires relation-manager-specific review of profile fit, not just copying resource-level declarations.
- **In scope**:
- Declare `ActionSurfaceDeclaration` for each currently-exempted relation manager (3 components)
- Validate profile fit (`RelationManager` profile vs a more specific variant)
- Reduce `ActionSurfaceExemptions` baseline by removing enrolled relation managers
- Add guard test coverage
- **Non-goals**:
- Redesigning backup item management UX
- Redesigning membership management UX
- Parent resource changes (TenantResource, WorkspaceResource)
- Full restore/backup domain redesign
- Introducing new relation managers
- **Depends on**: Action Surface Contract v1.1 (for any updated profile guidance or relation-manager-specific ordering rules)
- **Suggested order**: Third, after both v1.1 and System Panel Enrollment are complete. Lowest urgency because these surfaces are low-traffic and already functionally correct.
- **Risk**: Low. These relation managers already work correctly. This adds formal compliance, not behavioral change.
- **Why this boundary is right**: Relation manager enrollment is a distinct surface family with its own profile semantics. Mixing it with system-panel enrollment or targeted resource corrections would create an unfocused rollout spec.
- **Priority**: low
### Run Log Inspect Affordance Alignment
- **Type**: targeted surface correction
- **Source**: row interaction / action surface architecture analysis 2026-03-16
- **Problem**: `OperationRunResource` declares the `RunLog` profile with `ViewAction` as its inspect affordance. In practice, it renders a lone `ViewAction` in the actions column — the "lone ViewAction" anti-pattern identified in `docs/ui/action-surface-contract.md`. The row-click-first direction means this surface should use `ClickableRow` drill-down to the canonical tenantless viewer (`OperationRunLinks::tenantlessView()`), not a standalone View button. This surface is also inherited by the `Monitoring/Operations` page (which delegates to `OperationRunResource::table()`), so the fix propagates to both surfaces.
- **Why this is its own spec**: This is the single highest-signal concrete violation of the action-surface contract direction. It is bounded to one resource declaration + one inherited page. It does not require rewriting the canonical viewer, redesigning the operations domain, or touching other monitoring surfaces. Keeping it separate from foundation amendments ensures it can ship quickly after v1.1 codifies the anti-pattern rule.
- **In scope**:
- Change `OperationRunResource` inspect affordance from `ViewAction` to `ClickableRow`
- Verify `recordUrl()` points to the canonical tenantless viewer
- Remove the lone `ViewAction` from the actions column
- Confirm the change propagates correctly to `Monitoring/Operations` (which delegates to `OperationRunResource::table()`)
- Update/add guard test assertion for the corrected declaration
- **Non-goals**:
- Rewriting the canonical operation viewer (Spec 144 already complete)
- Broad operations UX redesign
- All monitoring pages (Alerts, Stuck, Failures are separate surfaces with distinct interaction models)
- RestoreRunResource alignment (currently exempted — separate concern)
- Action hierarchy / More-menu changes on this surface (belong to a general rollout, not this correction)
- **Depends on**: Action Surface Contract v1.1 (for codified anti-pattern rule and ClickableRow-default guidance)
- **Suggested order**: Second, in parallel with "System Panel Enrollment" after v1.1 is complete. Quickest win and highest signal correction.
- **Risk**: Low. Single resource, no behavioral regression, no data model change.
- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement.
- **Priority**: medium
---
## Covered / Absorbed

View File

@ -24,7 +24,7 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400"
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No active tenants available</h3>
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
There are no selectable active tenants in this workspace. View managed tenants to inspect onboarding or archived records, or switch to a different workspace.
There are no selectable Active tenants in this workspace. View managed tenants to inspect Onboarding or Archived records, or switch to a different workspace.
</p>
<div class="mt-6 flex flex-col items-center gap-3">
@ -68,6 +68,15 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
{{-- Tenant cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
@foreach ($tenants as $tenant)
@php
$presentation = $this->tenantLifecyclePresentation($tenant);
$badgeClasses = match ($presentation->badgeColor) {
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200',
'warning' => 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200',
'danger' => 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200',
default => 'border-gray-200 bg-gray-100 text-gray-700 dark:border-white/10 dark:bg-white/10 dark:text-gray-300',
};
@endphp
<button
type="button"
wire:key="tenant-{{ $tenant->id }}"
@ -101,6 +110,14 @@ class="h-5 w-5 text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:g
{{ strtoupper($tenant->environment) }}
</span>
@endif
<div class="mt-2 flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium {{ $badgeClasses }}">
{{ $presentation->label }}
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ $presentation->shortDescription }}
</p>
</div>
</div>

View File

@ -1,15 +1,16 @@
@php
/** @var ?\App\Models\Tenant $tenant */
/** @var ?\App\Support\Tenants\TenantLifecyclePresentation $presentation */
@endphp
<div>
@if ($tenant?->trashed())
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-100">
<div class="flex flex-col gap-2">
<div class="text-sm font-semibold">Tenant archived</div>
<div class="text-sm font-semibold">Tenant {{ strtolower($presentation?->label ?? 'Archived') }}</div>
<div class="text-sm">{{ \App\Support\Rbac\UiTooltips::TENANT_ARCHIVED }}</div>
<div class="text-sm text-amber-800 dark:text-amber-200">
This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.
{{ $presentation?->longDescription }}
</div>
</div>
</div>

View File

@ -0,0 +1,77 @@
# Implementation Readiness Checklist: Central Tenant Status Presentation
**Purpose**: Final pre-implementation gate for requirement readiness, scope control, lifecycle semantics, test obligations, and repo-specific constraints before coding begins.
**Created**: 2026-03-16
**Feature**: /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/146-central-tenant-status-presentation/spec.md
**Note**: This checklist is generated from the current feature artifacts and is intended to validate whether the requirements are complete, clear, bounded, and implementation-ready.
## Requirement Completeness
- [x] CHK001 Are the feature guardrails explicit that this work only standardizes lifecycle presentation and does not change lifecycle ownership, transitions, tenant-workspace relationships, authorization planes, queued work, or `OperationRun` behavior? [Completeness, Spec Scope Fields, Spec §Requirements]
- [x] CHK002 Are the exact primary routes and surface families in scope defined clearly enough to prevent lifecycle rollout from drifting into unrelated screens? [Completeness, Spec §Scope, Spec §Primary Routes]
- [x] CHK003 Are canonical-view constraints documented clearly enough that referenced tenant lifecycle is presentation-only and does not broaden record selection, tenant inclusion, or filtering behavior? [Completeness, Spec §Canonical-view specs, Spec §Assumptions]
- [x] CHK004 Are the inherited boundaries from Spec 143 and Spec 145 identified precisely enough that implementers know which lifecycle semantics and action semantics are fixed inputs rather than open design questions? [Dependency, Spec §Assumptions]
- [x] CHK005 Are all required in-scope lifecycle-bearing surface types covered in the requirements, including table, detail, selector, banner, summary, and canonical-viewer contexts? [Coverage, Spec §FR-012, Data Model §3]
## Scope Guardrails
- [x] CHK006 Are the out-of-scope areas stated strongly enough to block scope creep into provider health, consent, verification, RBAC, governance posture, run outcome, or other adjacent status systems? [Clarity, Spec §FR-006, Spec §Edge Cases]
- [x] CHK007 Is it clear which surfaces may continue to omit lifecycle by design so implementers do not treat omission as a defect or expand the rollout unintentionally? [Gap, Spec §Edge Cases]
- [x] CHK008 Is the surface list bounded well enough that newly discovered lifecycle-bearing screens are deferred unless they are tenant management surfaces already named in the spec or the tenantless operations viewer plus its `OperationRunResource` enterprise-detail summary payload? [Ambiguity, Spec §Primary Routes]
- [x] CHK009 Are the plan and tasks aligned that this feature is read-only presentation work with no schema changes, no remote calls, no new actions, and no audit/mutation flow changes? [Consistency, Plan §Technical Context, Tasks §Operations, Tasks §Filament UI Action Surfaces]
## Canonical Lifecycle Semantics
- [x] CHK010 Are the canonical lifecycle states exhaustive and stable as `draft`, `onboarding`, `active`, and `archived`, with operator-facing labels fixed as `Draft`, `Onboarding`, `Active`, and `Archived`? [Clarity, Spec §FR-001, Spec §FR-004, Data Model §1]
- [x] CHK011 Is the authoritative presentation contract defined with enough detail to support both machine-readable lifecycle state and operator-facing presentation without reintroducing local UI dictionaries? [Completeness, Spec §FR-002, Data Model §2]
- [x] CHK012 Are allowed density differences between surfaces constrained clearly enough that concise and detailed variants may change verbosity but never change lifecycle meaning? [Consistency, Spec §FR-004, Spec §FR-011, Spec §FR-012, Data Model §3]
- [x] CHK013 Are non-canonical pseudo-lifecycle labels such as `Pending`, `Suspended`, `Inactive`, or `Error` explicitly forbidden as primary lifecycle vocabulary? [Clarity, Spec §FR-007]
- [x] CHK014 Are mixed-status requirements specific enough that lifecycle cannot be used as shorthand for readiness, failure, provider connectivity, authorization, or run state? [Clarity, Spec §FR-006, User Story 3, Research §Decision 2]
- [x] CHK015 Can the success criteria objectively catch the core failure mode that valid canonical states render as `Unknown`, blank, or a generic fallback? [Acceptance Criteria, Spec §SC-001, Spec §FR-003]
## BADGE-001 Centralization
- [x] CHK016 Do the requirements define one authoritative centralization point for lifecycle presentation strongly enough that new or changed code cannot justify local `match`, `formatStateUsing()`, template strings, or helper-method mappings? [Completeness, Spec §BADGE-001, Spec §FR-009]
- [x] CHK017 Is the relationship between the new lifecycle presentation contract and the existing `BadgeCatalog` / `BadgeRenderer` / `TenantStatusBadge` architecture explained clearly enough to avoid parallel badge systems? [Consistency, Plan §Summary, Plan §Implementation Approach, Research §Decision 1]
- [x] CHK018 Are badge-only semantics versus richer helper-copy semantics separated clearly enough that implementers know the badge primitives stay centralized while detailed copy is still derived from the same contract? [Clarity, Plan §Implementation Approach, Research §Decision 3, Data Model §2]
- [x] CHK019 Are the existing code anchors identified explicitly enough to guide implementation toward reuse rather than reinvention? [Dependency, Quickstart §Prerequisites, Research §Supporting Findings]
## Surface Coverage And Viewer Scope
- [x] CHK020 Are tenant management surfaces identified clearly enough that tenant index, tenant detail, archived banner, choose-tenant, and onboarding-linked tenant presentation are all indisputably in scope? [Completeness, Plan §Planned Workstreams, Tasks T012-T017]
- [x] CHK021 Are operations-viewer surfaces identified precisely enough that `TenantlessOperationRunViewer` and the `OperationRunResource` enterprise-detail summary payload are clearly in scope, while any additional canonical viewer rollout is explicitly out of scope? [Clarity, Research §Supporting Findings, Tasks T019-T020]
- [x] CHK022 Is viewer scope defined strongly enough that referenced tenant lifecycle may appear only after existing server-side entitlement checks have already granted access to the canonical record? [Clarity, Spec §Canonical-view specs, Data Model §4]
- [x] CHK023 Do the requirements make it explicit that no lifecycle hints may leak for unauthorized tenants, inaccessible records, or tenants filtered out by existing tenant-context or viewer rules? [Coverage, Spec §Explicit entitlement checks, Plan §Constitution Check]
## Invalid Fallback Policy
- [x] CHK024 Is the invalid fallback policy specific enough that fallback remains reserved for corrupted or unexpected non-canonical data only, never for valid canonical states? [Clarity, Spec §FR-008, Data Model §2, Research §Decision 4]
- [x] CHK025 Is the distinction between canonical rendering failure and genuinely invalid data defined clearly enough that reviewers can reject implementations that silently normalize unsupported aliases? [Ambiguity, Research §Decision 4]
- [x] CHK026 Are the requirements explicit about how invalid fallback should remain visually and semantically separate from real lifecycle meaning so operators do not confuse bad data with `Draft`, `Onboarding`, `Active`, or `Archived`? [Clarity, Spec §FR-008, Spec §Edge Cases]
## Test Readiness
- [x] CHK027 Are the before-implementation testing expectations explicit that foundational and story-specific tests should be written first and fail before surface work begins? [Completeness, Tasks §Within Each User Story]
- [x] CHK028 Are the required test files and responsibilities defined precisely enough to cover the central contract, tenant surfaces, referenced-tenant viewers, mixed-status separation, and onboarding copy? [Coverage, Tasks T001-T003, T009-T025]
- [x] CHK029 Do the test requirements cover both positive and negative cases, including all four canonical states, invalid fallback, onboarding/archived referenced tenants, and separation from provider or RBAC status domains? [Coverage, Plan §Testing Strategy, Tasks T009-T024]
- [x] CHK030 Are the minimum validation commands documented clearly enough that implementers know the required Sail and Pest workflow before and after coding? [Completeness, Quickstart §Focused Validation Commands]
## Laravel And Filament Repo Constraints
- [x] CHK031 Are the Laravel and Filament implementation constraints captured clearly enough that this work remains compatible with Laravel 12, Filament v5, and Livewire v4 without introducing action-surface or panel-registration regressions? [Consistency, Plan §Technical Context, Spec §Filament Action Surfaces]
- [x] CHK032 Is it explicit that Filament action behavior, destructive confirmations, global-search behavior, and authorization enforcement remain unchanged because this feature only alters read-only presentation? [Consistency, Spec §Filament Action Surfaces, Plan §Constitution Check]
- [x] CHK033 Are repo-specific delivery rules documented sufficiently that implementation must stay Sail-first, Pest-based, BADGE-001-compliant, and formatting-complete through Pint rather than ad hoc local workflows? [Dependency, Quickstart §Focused Validation Commands, Tasks T024-T025]
## Traceability And Open Questions
- [x] CHK034 Is there enough traceability from requirements to plan and tasks that reviewers can detect if any in-scope surface or lifecycle rule lacks an implementation task or regression test? [Traceability, Spec §FR-001-012, Tasks T004-T025]
- [x] CHK035 Do the documents explicitly freeze canonical-view scope to the tenantless operations viewer and its `OperationRunResource` enterprise-detail summary payload for this feature? [Ambiguity, Spec §Primary Routes, UI Action Matrix]
- [x] CHK036 Are the readiness criteria strong enough that coding can be blocked if any of the above lifecycle semantics, viewer-scope boundaries, or fallback rules remain undocumented or conflicting? [Acceptance Criteria, Spec §Success Criteria, Plan §Post-Design Re-check]
## Notes
- Check items off as completed: `[x]`
- Use this as the final gate before implementation starts, not as an implementation test plan.
- If any ambiguity item remains unresolved, update the feature docs before coding rather than widening viewer scope during implementation.
- Reviewed against the finalized feature packet on 2026-03-16. All readiness gates pass.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Central Tenant Status Presentation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-16
**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 passed on the first review pass.
- Required constitution-alignment sections remain scope statements rather than implementation plans.
- No clarification questions are required before `/speckit.plan`.

View File

@ -0,0 +1,127 @@
openapi: 3.1.0
info:
title: Tenant Lifecycle Presentation Contract
version: 0.1.0
description: >-
Internal presentation contract for rendering tenant lifecycle consistently across
tenant management surfaces and canonical viewers. This feature introduces no new
external HTTP endpoints; the contract is expressed as shared schemas for UI-facing
presentation objects.
paths: {}
components:
schemas:
TenantLifecycleValue:
type: string
enum:
- draft
- onboarding
- active
- archived
description: Canonical tenant lifecycle values defined by the tenant domain.
TenantLifecycleBadge:
type: object
additionalProperties: false
required:
- color
- icon
properties:
color:
type: string
description: Shared badge or chip tone token.
icon:
type: string
description: Shared icon token for lifecycle rendering.
iconColor:
type:
- string
- 'null'
description: Optional icon color override.
TenantLifecyclePresentation:
type: object
additionalProperties: false
required:
- value
- label
- badge
- shortDescription
- longDescription
- isInvalidFallback
properties:
value:
$ref: '#/components/schemas/TenantLifecycleValue'
label:
type: string
enum:
- Draft
- Onboarding
- Active
- Archived
badge:
$ref: '#/components/schemas/TenantLifecycleBadge'
shortDescription:
type: string
description: Concise explanation for detail, selector, or viewer surfaces.
longDescription:
type: string
description: Detailed helper text for infolists, banners, or canonical viewers.
isInvalidFallback:
type: boolean
description: Reserved for corrupted or unexpected non-canonical values only.
TenantLifecyclePresentationVariant:
type: object
additionalProperties: false
required:
- surface
- showBadge
- showShortDescription
- showLongDescription
properties:
surface:
type: string
enum:
- table
- detail
- selector
- canonical_viewer
- banner
showBadge:
type: boolean
showShortDescription:
type: boolean
showLongDescription:
type: boolean
contextNote:
type:
- string
- 'null'
description: Optional surface-specific note that does not change lifecycle meaning.
ReferencedTenantLifecyclePresentation:
type: object
additionalProperties: false
required:
- tenantId
- tenantName
- presentation
- viewerContext
properties:
tenantId:
type:
- integer
- 'null'
tenantName:
type:
- string
- 'null'
presentation:
$ref: '#/components/schemas/TenantLifecyclePresentation'
viewerContext:
type: string
enum:
- operation_run
- report
- audit_reference
contextNote:
type:
- string
- 'null'
description: Optional explanatory copy for onboarding or archived references.

View File

@ -0,0 +1,115 @@
# Data Model: Central Tenant Status Presentation
## 1. TenantLifecycleState
- Purpose: The canonical tenant lifecycle domain value already defined by Spec 143 and implemented in `TenantLifecycle`.
- Source: Existing tenant domain model.
- Fields:
- `value`: enum string
- Allowed values: `draft`, `onboarding`, `active`, `archived`
- `label`: string
- Allowed values: `Draft`, `Onboarding`, `Active`, `Archived`
- Relationships:
- Derived from one `Tenant`
- Referenced by one `TenantLifecyclePresentation`
- Validation rules:
- Must be one of the four canonical lifecycle values for normal presentation flow
- Non-canonical values must bypass canonical rendering and enter invalid-data fallback only
- State transitions:
- Not defined here; owned by Spec 143 and the tenant domain
## 2. TenantLifecyclePresentation
- Purpose: The authoritative UI presentation contract for a lifecycle state.
- Ownership: Presentation layer only; no persistence required.
- Fields:
- `value`: canonical lifecycle value
- `label`: canonical operator-facing label
- `badge_color`: concise badge/chip color token
- `badge_icon`: icon token for badge or chip rendering
- `short_description`: concise explanatory text for detail or selector surfaces
- `long_description`: longer helper text for infolists, banners, or canonical viewers
- `is_invalid_fallback`: boolean flag reserved for corrupted or unexpected input
- Relationships:
- Belongs to one `TenantLifecycleState` when canonical
- May be constructed from a referenced tenant in a canonical viewer
- Validation rules:
- Canonical values must always produce `is_invalid_fallback = false`
- Invalid fallback must never be used for `draft`, `onboarding`, `active`, or `archived`
- `label` must match the canonical lifecycle vocabulary for canonical values
## 3. TenantLifecyclePresentationVariant
- Purpose: Surface-specific density derived from the same underlying lifecycle meaning.
- Ownership: Presentation composition only.
- Fields:
- `surface`: enum-like string
- Expected values: `table`, `detail`, `selector`, `canonical_viewer`, `banner`
- `show_badge`: boolean
- `show_short_description`: boolean
- `show_long_description`: boolean
- `show_context_note`: boolean
- Relationships:
- Belongs to one `TenantLifecyclePresentation`
- Validation rules:
- Must not alter `value` or `label`
- May change density only, never meaning
## 4. ReferencedTenantLifecycleContext
- Purpose: Captures how lifecycle is shown when a tenant appears as context on another record, such as an operation run.
- Ownership: Canonical viewer presentation only.
- Fields:
- `tenant_id`: integer or null
- `tenant_name`: string or null
- `lifecycle_value`: canonical lifecycle value or invalid raw value
- `viewer_context`: string
- Expected examples: `operation_run`, `report`, `audit_reference`
- `context_note`: optional explanatory text describing why the referenced tenant may be non-active
- Relationships:
- References one `Tenant`
- Uses one `TenantLifecyclePresentation`
- Validation rules:
- Must only be constructed after existing viewer authorization succeeds
- Must not imply the canonical record is invalid when the tenant lifecycle is `onboarding` or `archived`
## 5. Existing Domain Models in Scope
### Tenant
- Existing model: `App\Models\Tenant`
- Relevant existing fields:
- `status`
- `deleted_at` / soft-delete state
- `workspace_id`
- `name`
- `domain`
- `app_status`
- `rbac_status`
- Relevant derived methods:
- `lifecycle()`
- `isDraft()`
- `isOnboarding()`
- `isArchived()`
### OperationRun
- Existing model: `App\Models\OperationRun`
- Relevant existing fields:
- `tenant_id`
- `workspace_id`
- `status`
- `outcome`
- `context`
- Role in this feature:
- Supplies referenced tenant context only
- No lifecycle ownership changes
- No `OperationRun` state machine changes
## 6. Invariants
- Tenant lifecycle presentation must remain exhaustive for canonical states.
- Lifecycle presentation must not redefine lifecycle transitions or authorization.
- Lifecycle and health-like domains must remain separate in the rendered UI.
- Surface-specific rendering may change density, but not label or semantic meaning.
- Invalid fallback is reserved exclusively for non-canonical lifecycle data.

View File

@ -0,0 +1,152 @@
# Implementation Plan: Central Tenant Status Presentation
**Branch**: `146-central-tenant-status-presentation` | **Date**: 2026-03-16 | **Spec**: [specs/146-central-tenant-status-presentation/spec.md](./spec.md)
**Input**: Feature specification from `/specs/146-central-tenant-status-presentation/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Standardize tenant lifecycle rendering behind one authoritative presentation contract that extends the existing `TenantLifecycle` + `BadgeCatalog` architecture with explicit lifecycle semantics, helper copy, and invalid-data handling. Roll the shared contract through tenant list/detail summary surfaces, archived-tenant banner copy, choose-tenant and onboarding-linked surfaces, and the tenantless operations viewer plus its enterprise-detail summary payload so `draft`, `onboarding`, `active`, and `archived` always render intentionally, never as Unknown, and never get conflated with provider, verification, RBAC, or run status domains.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
**Storage**: PostgreSQL (existing tenant and operation records only; no schema changes planned)
**Testing**: Pest 4 feature and unit tests, including Filament and Livewire coverage
**Target Platform**: Laravel Sail web application on Filament admin and workspace-canonical UI surfaces
**Project Type**: Web application monolith
**Performance Goals**: No render-time external calls, no material query-count regression on list/detail/viewer surfaces, and no extra polling or background work introduced
**Constraints**: Preserve Spec 143 lifecycle semantics and Spec 145 action semantics; do not change authorization, lifecycle transitions, selector inclusion rules, or tenant/workspace ownership; centralize badge semantics under BADGE-001; invalid fallback must be reserved for truly non-canonical data only
**Scale/Scope**: Workspace-owned tenant lifecycle presentation across tenant management, choose-tenant, onboarding-linked tenant references, and the tenantless operations viewer plus its `OperationRunResource` enterprise-detail summary payload, plus regression coverage for the primary surfaces
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS. This is presentation-only work over existing tenant and canonical records. No snapshot/backfill/write behavior changes.
- Read/write separation: PASS. No new writes, previews, confirmations, queues, or audits are introduced.
- Graph contract path: PASS. No Microsoft Graph calls are added or changed.
- Deterministic capabilities: PASS. No capability derivation changes.
- RBAC-UX: PASS. Authorization planes, 404/403 semantics, membership checks, and capability enforcement remain unchanged. Canonical viewers continue to rely on existing record-level authorization before any tenant lifecycle is shown.
- Workspace isolation: PASS. No workspace-scoping changes.
- RBAC-UX destructive confirmation: PASS. No new destructive actions.
- RBAC-UX global search: PASS. This plan may touch global lifecycle presentation only if an already-authorized tenant result is rendered; it does not widen search behavior.
- Tenant isolation: PASS. No cross-tenant access changes.
- Run observability: PASS. No `OperationRun` creation, mutation, or monitoring behavior changes.
- Ops-UX 3-surface feedback: PASS. Not applicable because no `OperationRun` workflow changes are introduced.
- Ops-UX lifecycle/service ownership: PASS. No `OperationRun` transition changes.
- Ops-UX summary counts/guards/system runs: PASS. Not applicable.
- Automation/data minimization: PASS. No new jobs, locks, or logging paths.
- Badge semantics (BADGE-001): PASS WITH REQUIRED CENTRALIZATION. The implementation must extend the existing `BadgeCatalog` / `BadgeRenderer` path instead of adding local lifecycle maps.
- UI naming (UI-NAMING-001): PASS. Operator vocabulary remains `Draft`, `Onboarding`, `Active`, and `Archived`.
- Filament UI Action Surface Contract: PASS. Presentation-only changes on existing surfaces; no action-surface exemption needed.
- Filament UI UX-001 (Layout & IA): PASS. Existing layouts remain intact; lifecycle rendering changes must fit current table, infolist, and viewer structures.
**Post-Design Re-check**: PASS. Phase 1 design keeps lifecycle presentation read-only, tenant-safe, badge-centralized, and action-surface neutral.
## Project Structure
### Documentation (this feature)
```text
specs/146-central-tenant-status-presentation/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── tenant-lifecycle-presentation.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── Operations/
│ │ └── Workspaces/
│ ├── Resources/
│ │ └── TenantResource.php
│ └── System/Pages/Directory/
├── Models/
│ └── Tenant.php
├── Services/
│ ├── Onboarding/
│ └── Tenants/
├── Support/
│ ├── Badges/
│ └── Tenants/
resources/
└── views/
└── filament/
├── pages/
└── widgets/
tests/
├── Feature/
│ ├── Filament/
│ ├── Onboarding/
│ └── Rbac/
└── Unit/
```
**Structure Decision**: Use the existing Laravel monolith structure and extend the current support-layer centralization points first: `app/Support/Tenants` for lifecycle semantics, `app/Support/Badges` for badge-level rendering, `app/Filament/Resources/TenantResource.php` and related pages for tenant surfaces, `app/Filament/Pages/Operations` for canonical viewer alignment, and focused Pest coverage in `tests/Feature` and `tests/Unit`.
## Phase 0 Research Summary
- Confirmed the canonical lifecycle already exists in `App\Support\Tenants\TenantLifecycle` with the exact four required values and labels.
- Confirmed badge semantics are already centralized through `BadgeCatalog`, `BadgeRenderer`, and `TenantStatusBadge`, but today that path only standardizes badge label/color/icon, not helper copy or richer cross-surface presentation density.
- Confirmed tenant list/detail and system directory tenant tables already use `BadgeDomain::TenantStatus`, while choose-tenant currently communicates lifecycle indirectly in prose, archived tenant context is surfaced through a dedicated banner view, and operation viewers derive lifecycle context through `TenantOperabilityService` plus `OperationRunResource` enterprise-detail payload data rather than a dedicated lifecycle presentation contract.
- Confirmed Filament v5 supports badge presentation through shared text/badge semantics, which aligns with continuing to feed UI badges from one renderer rather than introducing per-surface mappings.
## Phase 1 Design
### Implementation Approach
1. Introduce a dedicated tenant lifecycle presentation contract that sits on top of `TenantLifecycle` and returns:
- canonical lifecycle value
- canonical label
- badge tone/icon data
- concise helper text
- detailed helper text
- explicit invalid-data marker for non-canonical values only
2. Keep `BadgeCatalog` / `BadgeRenderer` as the badge primitive for BADGE-001 compliance, but stop treating badge spec alone as the full lifecycle presentation contract.
3. Route all in-scope lifecycle rendering through the same presentation source, with per-surface density adapters for:
- table badge only
- infolist badge plus helper text
- tenant summary and archived-banner helper copy
- selector/supporting prose
- canonical viewer referenced-tenant banner or note
4. Audit existing lifecycle render points and tenant-adjacent statuses before rollout so lifecycle remains clearly separate from provider app status, RBAC, verification, and run state and no valid-state local mapping survives outside the shared contract.
5. Add regression tests that cover all four canonical lifecycle states plus invalid-data fallback behavior, including archived-banner and canonical-view summary surfaces.
### Planned Workstreams
- **Workstream A: Central contract**
Create a single presentation object or presenter in the tenant/support layer that derives from `TenantLifecycle` and is reusable outside tables.
- **Workstream B: Tenant management surfaces**
Update tenant list/detail summary areas, archived-tenant banner content, and any onboarding-linked tenant cards/sections to use the richer contract while preserving current Filament layouts and actions.
- **Workstream C: Selector and operations viewer surfaces**
Update choose-tenant and tenantless operation viewer context copy, including referenced-tenant banners and the `OperationRunResource` enterprise-detail summary payload consumed by that viewer, to use canonical lifecycle wording when lifecycle is shown or described.
- **Workstream D: Regression hardening**
Add focused unit tests for exhaustive lifecycle mapping and feature tests for tenant index/detail summary areas, archived banner content, onboarding-linked surfaces, and operation viewers.
## Testing Strategy
- Add a focused unit test for the central lifecycle presentation contract covering `draft`, `onboarding`, `active`, `archived`, and explicit invalid fallback for non-canonical values.
- Add a focused audit pass over the existing tenant lifecycle render points before rollout so every valid-state surface is routed through the shared contract.
- Update or add tenant Filament feature tests to assert lifecycle labels on:
- tenant index table
- tenant detail identity and summary sections
- archived tenant banner copy
- choose-tenant empty or contextual copy where lifecycle is described
- Update or add canonical viewer tests to assert onboarding and archived referenced tenants render intentional lifecycle context in both the tenantless viewer banner and the `OperationRunResource`-backed summary payload rendering.
- Add at least one mixed-status assertion showing lifecycle remains distinct from provider or RBAC status on tenant detail.
- Run the minimum focused Pest suite through Sail for the touched feature and unit tests.
## Complexity Tracking
No constitution violations or exceptional complexity are planned at this stage.

View File

@ -0,0 +1,46 @@
# Quickstart: Central Tenant Status Presentation
## Goal
Implement a shared tenant lifecycle presentation contract that keeps `draft`, `onboarding`, `active`, and `archived` explicit and consistent across tenant management surfaces and the tenantless operations viewer surface.
## Prerequisites
- Work on branch `146-central-tenant-status-presentation`
- Sail services available for running tests and formatting
- Review these existing code anchors before implementation:
- `app/Support/Tenants/TenantLifecycle.php`
- `app/Support/Badges/BadgeCatalog.php`
- `app/Support/Badges/Domains/TenantStatusBadge.php`
- `app/Services/Tenants/TenantOperabilityService.php`
- `app/Filament/Resources/TenantResource.php`
- `resources/views/filament/pages/choose-tenant.blade.php`
- `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- `app/Filament/Resources/OperationRunResource.php`
## Suggested Implementation Order
1. Add a central lifecycle presentation object or presenter in the tenant/support layer.
2. Refactor badge-only lifecycle rendering to source label, tone, and icon from that contract without breaking BADGE-001.
3. Add concise and detailed helper-text variants for surfaces that need more than a badge.
4. Update tenant list/detail surfaces to consume the new contract.
5. Update choose-tenant and onboarding-linked lifecycle wording to use the canonical lifecycle vocabulary.
6. Update the tenantless operations viewer referenced-tenant context, including the `OperationRunResource` enterprise-detail summary payload, to use the same contract.
7. Add focused unit and feature regression tests for all four lifecycle states and invalid fallback behavior.
8. Run formatting and focused tests through Sail.
## Focused Validation Commands
```bash
vendor/bin/sail artisan test --compact tests/Unit
vendor/bin/sail artisan test --compact tests/Feature/Filament
vendor/bin/sail artisan test --compact tests/Feature/Onboarding
vendor/bin/sail bin pint --dirty --format agent
```
## Expected Outcomes
- Canonical lifecycle states never render as Unknown.
- Tenant list/detail and tenantless operations viewer surfaces reuse one lifecycle vocabulary.
- Lifecycle remains visually and semantically separate from provider, RBAC, verification, and run status.
- Invalid-data fallback is explicit and test-covered.

View File

@ -0,0 +1,54 @@
# Research: Central Tenant Status Presentation
## Decision 1: Build on the existing TenantLifecycle enum and badge catalog instead of creating a parallel status system
- Decision: Treat `App\Support\Tenants\TenantLifecycle` as the canonical lifecycle source and extend presentation through a new lifecycle presentation contract layered on top of the existing `BadgeCatalog` and `BadgeRenderer` pipeline.
- Rationale: The codebase already has the correct domain enum (`draft`, `onboarding`, `active`, `archived`) and an existing BADGE-001-compliant badge registry. Reusing those pieces avoids semantic drift and keeps lifecycle presentation aligned with the authoritative domain model from Spec 143.
- Alternatives considered:
- Add more per-surface `match` or `formatStateUsing()` closures. Rejected because that would reintroduce scattered UI dictionaries.
- Put all presentation logic directly on the `Tenant` model. Rejected because lifecycle presentation density varies by surface and should not overload the domain model with Filament-specific UI concerns.
- Keep using `TenantStatusBadge` alone. Rejected because badge label/color/icon is not enough to cover helper text, referenced-tenant notes, and invalid-data distinction.
## Decision 2: Separate lifecycle presentation from adjacent status domains explicitly
- Decision: Keep lifecycle presentation as its own contract and do not merge it with provider connection, app consent, RBAC, verification, onboarding progress, or operation-run state.
- Rationale: The tenant detail and tenantless operations viewer surfaces already carry multiple status domains. Spec 146 requires lifecycle to remain honest to lifecycle only, otherwise operators misread onboarding, archived, or active state as health or readiness.
- Alternatives considered:
- Collapse lifecycle and readiness into one composite badge. Rejected because it obscures whether a tenant is active but unhealthy versus onboarding but valid.
- Reuse app-status or RBAC helper copy to explain lifecycle. Rejected because it creates cross-domain semantic leakage.
## Decision 3: Use one presentation source with multiple density variants
- Decision: The new presentation contract should return both concise and detailed variants from the same underlying lifecycle meaning.
- Rationale: Tables and selectors need short badge semantics, while detail views and the tenantless operations viewer need helper text that explains why onboarding or archived tenants still appear. One source with multiple densities satisfies the spec without forcing verbose copy into every surface.
- Alternatives considered:
- Make all surfaces badge-only. Rejected because the tenantless operations viewer and detail pages need more context.
- Make all surfaces badge plus paragraph text. Rejected because selector and list surfaces would become noisy.
## Decision 4: Keep invalid fallback only for non-canonical data and make it explicit in tests
- Decision: Preserve a reserved fallback path for corrupted or unexpected lifecycle values, but do not allow canonical states to reach it.
- Rationale: `TenantStatusBadge` currently falls back to `BadgeSpec::unknown()`. The feature needs a stricter separation so valid canonical states are exhaustive and only truly invalid values use fallback behavior.
- Alternatives considered:
- Remove fallback completely. Rejected because legacy or bad data still needs a safe render path.
- Continue allowing normalized aliases like `inactive` to map silently without explicit tests. Rejected because future drift would be hard to detect.
## Decision 5: Favor focused regression tests over broad UI rewrites
- Decision: Add unit coverage for the central presentation contract and focused Pest feature tests for the tenant index, tenant detail, choose-tenant/onboarding-linked presentation, and tenantless operations viewer surfaces.
- Rationale: The existing architecture is already partially centralized. The highest-value risk is regression through future local mappings or fallback behavior, so tests should lock down lifecycle semantics where operators actually see them.
- Alternatives considered:
- Add only unit tests. Rejected because cross-surface consistency is the actual acceptance criterion.
- Add a full browser test suite for every surface. Rejected because feature and unit coverage can prove the contract with lower cost and faster CI.
## Supporting Findings
- `App\Support\Tenants\TenantLifecycle` already defines the exact four lifecycle states and canonical labels required by the spec.
- `App\Support\Badges\BadgeCatalog` and `App\Support\Badges\Domains\TenantStatusBadge` already centralize badge label/color/icon mapping for tenant lifecycle, but they do not yet provide richer helper semantics.
- `app/Filament/Resources/TenantResource.php` already renders tenant `status` through `BadgeRenderer` on both the table and infolist surfaces.
- `resources/views/filament/pages/choose-tenant.blade.php` uses lifecycle-adjacent prose such as "No active tenants available" and references onboarding/archived records without a dedicated lifecycle presentation object.
- `resources/views/filament/widgets/tenant/tenant-archived-banner.blade.php` already carries archived-tenant helper copy, which makes it an in-scope surface that must be aligned with the shared lifecycle contract instead of maintaining standalone wording.
- `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` derives lifecycle-aware canonical-view copy via `TenantOperabilityService`, which is a good integration point for the shared presentation contract.
- `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` renders the canonical context banner body, making it the concrete viewer surface where referenced-tenant lifecycle wording must stay aligned with tenant management pages.
- `app/Filament/Resources/OperationRunResource.php` builds the enterprise-detail payload consumed by the tenantless operations viewer, so referenced-tenant lifecycle summary text there is part of the same viewer surface rather than a separate resource rollout.
- Filament v5 documentation confirms badge rendering is intended to be driven centrally through shared text/badge semantics rather than repeated local mappings.

View File

@ -0,0 +1,140 @@
# Feature Specification: Central Tenant Status Presentation
**Feature Branch**: `146-central-tenant-status-presentation`
**Created**: 2026-03-16
**Status**: Draft
**Input**: User description: "Spec 146 — Central Tenant Status Presentation"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace + tenant lifecycle presentation + referenced tenant lifecycle presentation on the tenantless operations viewer and its `OperationRunResource` enterprise-detail summary payload
- **Primary Routes**: `/admin/tenants`, `/admin/tenants/{tenant}`, `/admin/choose-tenant`, `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}`, `/admin/operations`, `/admin/operations/{run}`
- **Data Ownership**: Workspace-owned tenant records remain the source of lifecycle values. Workspace-owned canonical records that reference tenants may display lifecycle, but this feature does not change record ownership, lifecycle transitions, or tenant-workspace relationships.
- **RBAC**: No new membership rules, capabilities, or authorization planes are introduced. Existing workspace and canonical-view access rules remain in force; this feature only standardizes how already-authorized users see tenant lifecycle.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Existing tenant-context and viewer filtering behavior remains unchanged. When a permitted canonical record references a tenant, the viewer may display that tenant's lifecycle using the shared lifecycle contract, but the feature does not broaden record selection or tenant inclusion rules.
- **Explicit entitlement checks preventing cross-tenant leakage**: Referenced tenant lifecycle may only be shown after the viewer's existing server-side entitlement checks have already allowed access to the canonical record. The feature must not expose lifecycle hints for unauthorized tenants, inaccessible records, or filtered-out tenants.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Read Trusted Lifecycle Badges Everywhere (Priority: P1)
As a workspace operator, I want tenant lifecycle to render with the same label and meaning everywhere it appears so I can trust what a tenant's state means without reinterpreting each page.
**Why this priority**: This is the core trust problem. If the same valid state renders differently across core surfaces, operators cannot reliably interpret readiness, archived retention, or onboarding context.
**Independent Test**: Can be fully tested by rendering tenants in each canonical lifecycle state across the tenant index, tenant detail, and choose-tenant surfaces and confirming the same lifecycle label appears without falling back to Unknown.
**Acceptance Scenarios**:
1. **Given** a tenant in `draft`, `onboarding`, `active`, or `archived`, **When** an operator views the tenant in any in-scope table or detail surface, **Then** the lifecycle renders with the canonical label for that state and never falls back to Unknown.
2. **Given** the same tenant appears on multiple in-scope tenant management surfaces, **When** the lifecycle is shown, **Then** the label and lifecycle meaning remain consistent even if the surface uses a more concise or more detailed presentation.
---
### User Story 2 - Understand Referenced Tenant Context in the Operations Viewer (Priority: P2)
As an operator reviewing an operation run, I want referenced tenant lifecycle to use the same vocabulary as tenant management pages so I can understand whether the tenant was onboarding, active, draft, or archived when the record was created or viewed.
**Why this priority**: The operations viewer is a high-trust review surface. Referenced tenant lifecycle must explain context clearly without making the record itself appear broken.
**Independent Test**: Can be fully tested by viewing operation-run records that reference onboarding and archived tenants and confirming the referenced lifecycle uses the same label set as tenant pages.
**Acceptance Scenarios**:
1. **Given** an authorized operator opens the tenantless operations viewer for a run that references an onboarding tenant, **When** tenant lifecycle is shown in the viewer banner or enterprise-detail summary, **Then** the viewer uses the canonical Onboarding presentation rather than Unknown, Pending, or another alternate label.
2. **Given** an authorized operator opens the tenantless operations viewer for a run that references an archived tenant, **When** tenant lifecycle is shown in the viewer banner or enterprise-detail summary, **Then** the viewer communicates archival lifecycle clearly without implying the referenced tenant is deleted or the canonical record is invalid.
---
### User Story 3 - Distinguish Lifecycle from Other Tenant Status Domains (Priority: P3)
As an operator, I want tenant lifecycle to remain separate from provider health, consent, verification, RBAC, and run outcome so I can tell whether a tenant's lifecycle is the issue or whether another domain needs attention.
**Why this priority**: Lifecycle becomes misleading if it is used as a proxy for unrelated operational states. Clear separation reduces operator error and preserves domain trust.
**Independent Test**: Can be fully tested by rendering pages that show lifecycle alongside other tenant-adjacent statuses and verifying each status domain remains separately labeled and understandable.
**Acceptance Scenarios**:
1. **Given** a surface shows tenant lifecycle together with provider, verification, or RBAC information, **When** the page is rendered, **Then** lifecycle remains labeled with the canonical lifecycle vocabulary and does not replace or absorb adjacent status domains.
2. **Given** an active tenant has failing provider or verification signals, **When** the page is rendered, **Then** lifecycle still reads Active and the failure is communicated through its own separate status domain.
### Edge Cases
- A lifecycle value outside the canonical model is present because of corrupted or legacy data; the UI may use a reserved invalid-data fallback, but valid canonical states must never use that fallback.
- A surface chooses a concise badge-only presentation while another uses helper text; both must still communicate the same lifecycle meaning.
- A page shows lifecycle next to provider connection, consent, verification, RBAC, onboarding progress, or run outcome; none of those adjacent domains may be renamed to lifecycle-like terms.
- An archived or onboarding tenant appears as a referenced tenant in the tenantless operations viewer; the lifecycle must explain context without making the record appear missing or erroneous.
- A surface omits lifecycle entirely by design; when lifecycle is shown later on that surface, it must consume the central contract rather than invent local wording.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce Microsoft Graph calls, write/change behavior, queued work, scheduled work, or new operation execution. No contract registry changes, preview flows, confirmation flows, audit-only mutation paths, or `OperationRun` changes are in scope.
**Constitution alignment (OPS-UX):** This feature does not create, reuse, or mutate an `OperationRun`. Monitoring, toast behavior, progress surfaces, terminal notifications, and summary count semantics are unchanged.
**Constitution alignment (RBAC-UX):** This feature does not introduce new authorization behavior. Existing workspace and canonical-view authorization rules remain unchanged, including deny-as-not-found behavior for inaccessible records. No new capabilities, policy branches, destructive actions, or capability strings are introduced by this spec.
**Constitution alignment (OPS-EX-AUTH-001):** No authentication handshake or synchronous outbound auth behavior is introduced.
**Constitution alignment (BADGE-001):** This feature changes status-like presentation and therefore must centralize tenant lifecycle badge semantics in one authoritative contract. All in-scope surfaces must consume that shared contract so valid lifecycle states cannot drift, disappear, or fall back to Unknown. Regression coverage must verify canonical labels for `draft`, `onboarding`, `active`, and `archived`, plus reserved fallback behavior for truly invalid data only.
**Constitution alignment (UI-NAMING-001):** Operator-facing lifecycle vocabulary must remain stable across labels, helper text, viewer references, and summary prose. The target object is tenant lifecycle. The operator vocabulary is `Draft`, `Onboarding`, `Active`, and `Archived`. No implementation-first terms or alternate pseudo-lifecycle names may become the primary operator label for lifecycle.
**Constitution alignment (Filament Action Surfaces):** In-scope Filament surfaces are modified for presentation consistency only. The Action Surface Contract remains satisfied because this spec does not add new actions, alter destructive behavior, or change existing mutation affordances. The UI Action Matrix below records the affected surfaces and confirms no action semantics change.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature updates lifecycle presentation within existing screens rather than redesigning layouts. Existing Create/Edit/View/Table structures remain intact. Status presentation must comply with BADGE-001 centralization. No UX-001 exemption is required because the spec does not remove or weaken the established layout and information architecture rules.
### Functional Requirements
- **FR-001**: The system MUST define one authoritative tenant lifecycle presentation contract covering the canonical lifecycle values `draft`, `onboarding`, `active`, and `archived`.
- **FR-002**: The lifecycle presentation contract MUST provide, at minimum, a machine-readable lifecycle value, a human-readable label, a concise badge or chip semantic, and optional short and detailed explanatory variants derived from the same meaning.
- **FR-003**: The system MUST render every valid canonical tenant lifecycle value explicitly on in-scope surfaces and MUST NOT render any canonical lifecycle value as Unknown, blank, or a default fallback.
- **FR-004**: In-scope tenant management surfaces that render lifecycle MUST use the canonical labels `Draft`, `Onboarding`, `Active`, and `Archived`, with surface-specific density allowed only when the underlying meaning remains unchanged.
- **FR-005**: The tenantless operations viewer and its `OperationRunResource` enterprise-detail summary payload MUST use the same lifecycle presentation contract as tenant management surfaces when rendering referenced tenant lifecycle.
- **FR-006**: The system MUST preserve a clear distinction between tenant lifecycle and adjacent status domains, including provider connection status, app or consent status, RBAC status, verification outcome, onboarding progress, governance posture, and operation-run status.
- **FR-007**: The system MUST NOT introduce non-canonical lifecycle labels such as Pending, Suspended, Inactive, or Error for tenant lifecycle unless a future domain specification formally adds them.
- **FR-008**: The system MAY provide a reserved fallback for corrupted or unexpected lifecycle data, but that fallback MUST be visually and semantically reserved for invalid non-canonical data only.
- **FR-009**: New or changed in-scope code MUST consume the central lifecycle presentation contract rather than defining local lifecycle mapping logic in templates, columns, infolists, widgets, pages, viewers, or helper methods.
- **FR-010**: The feature MUST make missing coverage for canonical lifecycle states detectable during development and regression testing.
- **FR-011**: Detailed surfaces MAY show lifecycle helper text, but that helper text MUST reinforce lifecycle meaning without implying provider health, readiness, failure, or authorization outcomes that belong to other domains.
- **FR-012**: Table, selector, viewer, banner, and summary surfaces that describe tenant lifecycle in prose or badge form MUST align with the same canonical vocabulary and lifecycle meaning.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| 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 index and tenant detail surfaces | Existing tenant management Filament resources and pages | No change | Existing record links and inspect affordances remain unchanged | No change | No change | No lifecycle-specific CTA change | No change | No change | No change | Presentation-only lifecycle updates; no new actions or destructive behavior |
| Choose-tenant and onboarding-linked tenant surfaces | Existing tenant selection and onboarding Filament or Livewire surfaces | No change | Existing selection and inspection affordances remain unchanged | No change | No change | No lifecycle-specific CTA change | No change | No change | No change | Lifecycle vocabulary becomes canonical where shown |
| Operations viewer referenced-tenant surfaces | `TenantlessOperationRunViewer` and the `OperationRunResource` enterprise-detail summary payload it renders | No change | Existing viewer navigation remains unchanged | No change | No change | No lifecycle-specific CTA change | No change | No change | No change | Referenced tenant lifecycle aligns with the same contract; no additional canonical viewers are in scope for this feature |
### Key Entities *(include if feature involves data)*
- **Tenant Lifecycle State**: The canonical domain lifecycle assigned to a tenant. For this feature, the relevant values are `draft`, `onboarding`, `active`, and `archived`.
- **Tenant Lifecycle Presentation Contract**: The authoritative presentation definition that translates a canonical lifecycle state into its shared operator-facing label, tone, and explanatory variants.
- **Referenced Tenant Display Context**: Any authorized viewer, summary, card, or detail surface that displays a tenant's lifecycle as context for another record without changing the underlying tenant record.
## Assumptions
- Spec 143 remains the source of truth for lifecycle ownership and lifecycle values.
- Spec 145 remains the source of truth for lifecycle-safe action interpretation and visibility semantics.
- This feature is limited to presentation consistency and does not change which tenants are included in selectors, context switching, or canonical viewers.
- Existing authorization, global search, and tenant-context rules remain unchanged except that any already-rendered lifecycle must use the canonical presentation vocabulary.
- The only canonical-view surface in scope for this feature is the tenantless operations viewer, including the `OperationRunResource` enterprise-detail summary payload it renders. Any additional canonical viewer rollout requires a follow-up spec.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In the in-scope surfaces covered by regression tests, 100% of canonical lifecycle states `draft`, `onboarding`, `active`, and `archived` render with explicit canonical labels and 0% render as Unknown.
- **SC-002**: Operators reviewing the same tenant across at least three in-scope surfaces encounter the same lifecycle label and meaning for that tenant state, with no alternate pseudo-lifecycle labels in the tested paths.
- **SC-003**: Authorized operators can distinguish tenant lifecycle from adjacent status domains on tested mixed-status surfaces without lifecycle labels changing to reflect provider, consent, RBAC, verification, or run outcome conditions.
- **SC-004**: Referenced tenant lifecycle in the tested tenantless operations viewer renders intentionally for onboarding and archived tenants without the viewer implying the record is broken, deleted, or missing.

View File

@ -0,0 +1,197 @@
# Tasks: Central Tenant Status Presentation
**Input**: Design documents from `/specs/146-central-tenant-status-presentation/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
**Operations**: This feature does not introduce long-running, remote, queued, or scheduled work. No `OperationRun` creation or lifecycle changes are required.
**RBAC**: This feature does not change authorization behavior. Existing tenant/workspace and canonical-view access checks remain unchanged; tests here focus on presentation consistency after authorization has already succeeded.
**UI Naming**: Lifecycle copy must stay canonical as `Draft`, `Onboarding`, `Active`, and `Archived` across badges, helper text, and referenced-tenant context copy.
**Filament UI Action Surfaces**: This feature changes presentation only. No header, row, bulk, empty-state CTA, or destructive action behavior is added or changed.
**Filament UI UX-001 (Layout & IA)**: Existing layouts remain intact. Tasks below only change lifecycle presentation within current table, infolist, summary, banner, selector, and tenantless-operations-viewer structures.
**Badges**: Tenant lifecycle badge changes MUST continue to use `BadgeCatalog` / `BadgeRenderer` and MUST NOT introduce ad-hoc Filament mappings.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare the shared test targets and implementation files for lifecycle presentation work.
- [X] T001 [P] Create the central lifecycle presentation unit test file in `tests/Unit/Support/Tenants/TenantLifecyclePresentationTest.php`
- [X] T002 [P] Create the tenant surface lifecycle regression test file in `tests/Feature/Filament/TenantLifecyclePresentationAcrossTenantSurfacesTest.php`
- [X] T003 [P] Create the canonical viewer lifecycle regression test file in `tests/Feature/Filament/ReferencedTenantLifecyclePresentationTest.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Build the shared lifecycle presentation contract and exhaustive badge integration before any story-specific surface work starts.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [X] T004 Implement the central lifecycle presentation contract in `app/Support/Tenants/TenantLifecyclePresentation.php`
- [X] T005 [P] Implement the referenced-tenant lifecycle presentation adapter in `app/Support/Tenants/ReferencedTenantLifecyclePresentation.php`
- [X] T006 Update tenant lifecycle normalization to route canonical and invalid-state handling through the shared contract in `app/Support/Badges/BadgeCatalog.php`
- [X] T007 Update the tenant lifecycle badge mapper to consume the shared presentation contract in `app/Support/Badges/Domains/TenantStatusBadge.php`
- [X] T008 Audit and remove scattered valid-lifecycle mappings across `app/Filament/Resources/TenantResource.php`, `app/Filament/Pages/ChooseTenant.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `resources/views/filament/pages/choose-tenant.blade.php`, and `resources/views/filament/widgets/tenant/tenant-archived-banner.blade.php`
- [X] T009 Add exhaustive unit coverage for canonical lifecycle values and invalid fallback for non-canonical data in `tests/Unit/Support/Tenants/TenantLifecyclePresentationTest.php`
**Checkpoint**: Foundation ready - tenant lifecycle presentation can now be rolled out across surfaces in parallel.
---
## Phase 3: User Story 1 - Read Trusted Lifecycle Badges Everywhere (Priority: P1) 🎯 MVP
**Goal**: Make tenant lifecycle render with the same canonical labels and meaning across tenant index, tenant detail summary areas, archived banner surfaces, choose-tenant, and onboarding-linked tenant surfaces.
**Independent Test**: Render tenants in `draft`, `onboarding`, `active`, and `archived` across tenant list/detail summary, archived-banner, and chooser surfaces and verify the canonical label appears every time with no Unknown fallback.
### Tests for User Story 1 ⚠️
- [X] T010 [P] [US1] Add tenant index, tenant detail summary, and archived-banner lifecycle assertions in `tests/Feature/Filament/TenantLifecyclePresentationAcrossTenantSurfacesTest.php`
- [X] T011 [P] [US1] Add chooser and onboarding-linked lifecycle copy assertions in `tests/Feature/Onboarding/TenantLifecyclePresentationCopyTest.php`
### Implementation for User Story 1
- [X] T012 [US1] Refactor tenant list lifecycle badge rendering to consume the shared presentation contract in `app/Filament/Resources/TenantResource.php`
- [X] T013 [US1] Add detailed lifecycle helper presentation to tenant detail identity and summary sections in `app/Filament/Resources/TenantResource.php`
- [X] T014 [US1] Align archived tenant banner lifecycle copy with the shared contract in `app/Filament/Widgets/Tenant/TenantArchivedBanner.php` and `resources/views/filament/widgets/tenant/tenant-archived-banner.blade.php`
- [X] T015 [US1] Expose lifecycle presentation data to chooser records in `app/Filament/Pages/ChooseTenant.php`
- [X] T016 [US1] Update chooser lifecycle prose and card metadata to use canonical lifecycle semantics in `resources/views/filament/pages/choose-tenant.blade.php`
- [X] T017 [US1] Align onboarding-linked tenant lifecycle copy with the shared contract in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
**Checkpoint**: User Story 1 is complete when tenant management and chooser surfaces all render canonical lifecycle labels and helper copy consistently.
---
## Phase 4: User Story 2 - Understand Referenced Tenant Context in the Operations Viewer (Priority: P2)
**Goal**: Make referenced tenant lifecycle in the tenantless operations viewer use the same lifecycle vocabulary as tenant management surfaces.
**Independent Test**: Open authorized tenantless operations viewer cases for onboarding and archived tenants and verify the referenced tenant lifecycle uses the same canonical label set and does not imply the record is broken or deleted.
### Tests for User Story 2 ⚠️
- [X] T018 [P] [US2] Add onboarding and archived referenced-tenant banner and summary assertions in `tests/Feature/Filament/ReferencedTenantLifecyclePresentationTest.php`
### Implementation for User Story 2
- [X] T019 [US2] Render referenced tenant lifecycle through the shared contract in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
- [X] T020 [US2] Update the `OperationRunResource` enterprise-detail summary payload so referenced tenant lifecycle helper text stays aligned with the tenantless operations viewer banner in `app/Filament/Resources/OperationRunResource.php`
**Checkpoint**: User Story 2 is complete when the tenantless operations viewer presents onboarding and archived referenced tenants intentionally and with the same lifecycle vocabulary as tenant pages.
---
## Phase 5: User Story 3 - Distinguish Lifecycle from Other Tenant Status Domains (Priority: P3)
**Goal**: Keep lifecycle presentation clearly separate from provider, verification, RBAC, and run-status domains on mixed-status surfaces.
**Independent Test**: Render mixed-status tenant and tenantless-operations-viewer surfaces where lifecycle appears alongside provider or RBAC status and verify lifecycle remains canonical while adjacent domains keep their own distinct labels.
### Tests for User Story 3 ⚠️
- [X] T021 [P] [US3] Add mixed-status separation assertions for lifecycle, app status, and RBAC status in `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
### Implementation for User Story 3
- [X] T022 [US3] Keep lifecycle helper text separate from provider and RBAC summaries in `app/Filament/Resources/TenantResource.php`
- [X] T023 [US3] Separate lifecycle context wording from run status and operability messaging in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
**Checkpoint**: User Story 3 is complete when mixed-status surfaces communicate lifecycle, provider/app state, RBAC status, and run status as distinct concepts.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final regression cleanup, validation, and formatting across all stories.
- [X] T024 [P] Run focused lifecycle presentation Pest suites covering `tests/Unit/Support/Tenants/TenantLifecyclePresentationTest.php`, `tests/Feature/Filament/TenantLifecyclePresentationAcrossTenantSurfacesTest.php`, `tests/Feature/Filament/ReferencedTenantLifecyclePresentationTest.php`, `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, and `tests/Feature/Onboarding/TenantLifecyclePresentationCopyTest.php`
- [X] T025 Run formatting for touched lifecycle presentation files using the validation commands in `specs/146-central-tenant-status-presentation/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational completion
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow User Story 1 once the shared tenant-surface vocabulary is stable
- **User Story 3 (Phase 5)**: Depends on Foundational completion and is safest after User Stories 1 and 2 because it hardens mixed-status behavior on the same touched surfaces
- **Polish (Phase 6)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Starts immediately after Foundational and delivers the MVP lifecycle consistency baseline
- **User Story 2 (P2)**: Depends on the shared presentation contract and benefits from the canonical vocabulary established in US1
- **User Story 3 (P3)**: Depends on the shared presentation contract and the surface rollouts from US1/US2 so mixed-status separation is hardened on the final rendered output
### Within Each User Story
- Tests for the story should be written first and fail before implementation
- Shared presentation contract usage should land before per-surface copy refinements
- Surface rendering changes should land before cross-surface cleanup assertions
- Story-specific validation should pass before moving to the next priority story
### Parallel Opportunities
- T001, T002, and T003 can run in parallel because they create separate test targets
- T005 can run in parallel with T006 once T004 defines the core presentation contract shape
- T010 and T011 can run in parallel because they cover different feature files
- T018 and T021 can run in parallel because they target separate feature test files
---
## Parallel Example: User Story 1
```bash
# Launch the P1 test coverage tasks together:
Task: "Add tenant index and tenant detail lifecycle assertions in tests/Feature/Filament/TenantLifecyclePresentationAcrossTenantSurfacesTest.php"
Task: "Add chooser and onboarding-linked lifecycle copy assertions in tests/Feature/Onboarding/TenantLifecyclePresentationCopyTest.php"
```
## Parallel Example: User Story 2
```bash
# After the shared presentation contract is stable, canonical-view work can split by test and implementation:
Task: "Add onboarding and archived referenced-tenant viewer assertions in tests/Feature/Filament/ReferencedTenantLifecyclePresentationTest.php"
Task: "Render referenced tenant lifecycle through the shared contract in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php"
```
## Parallel Example: User Story 3
```bash
# Mixed-status hardening can separate regression coverage from resource refinement:
Task: "Add mixed-status separation assertions for lifecycle, app status, and RBAC status in tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php"
Task: "Keep lifecycle helper text separate from provider and RBAC summaries in app/Filament/Resources/TenantResource.php"
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2
- Deliver User Story 1 as the MVP because it establishes the shared lifecycle vocabulary and removes Unknown fallback from the primary tenant management surfaces
- Validate the focused P1 tests before moving on
### Incremental Delivery
- Add User Story 2 next to align the tenantless operations viewer with the same lifecycle contract
- Add User Story 3 last to harden separation from adjacent status domains on mixed-status surfaces
- Finish with the Polish phase for focused validation and formatting
### Team Strategy
- One engineer can own the foundational contract and badge integration work
- A second engineer can prepare the tenant-surface regression tests in parallel once the contract shape is clear
- Canonical viewer and mixed-status hardening can proceed as separate follow-up streams after the foundational phase is merged

View File

@ -17,10 +17,12 @@
'archived' => ['archived', 'Archived', 'gray'],
]);
it('normalizes legacy tenant lifecycle aliases before mapping', function (): void {
it('uses explicit invalid lifecycle fallback for non-canonical tenant lifecycle values', function (): void {
$pending = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'PENDING');
$inactive = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'inactive');
expect($pending->label)->toBe('Onboarding')
->and($inactive->label)->toBe('Archived');
expect($pending->label)->toBe('Invalid lifecycle')
->and($pending->color)->toBe('danger')
->and($inactive->label)->toBe('Invalid lifecycle')
->and($inactive->color)->toBe('danger');
});

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders onboarding referenced tenant lifecycle consistently in the viewer banner and summary card', function (): void {
$tenant = Tenant::factory()->onboarding()->create([
'name' => 'Onboarding Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Run tenant is not available in the current tenant selector')
->assertSee('This tenant is currently onboarding and may not appear in the tenant selector.')
->assertSee('Tenant lifecycle')
->assertSee('Onboarding')
->assertSee('Tenant selector context')
->assertSee('Some tenant follow-up actions may be unavailable from this canonical workspace view.');
});
it('renders archived referenced tenant lifecycle consistently in the viewer banner and summary card', function (): void {
$activeTenant = Tenant::factory()->create([
'name' => 'Active Tenant',
]);
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
$archivedTenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $activeTenant->workspace_id,
'name' => 'Archived Tenant',
]);
createUserWithTenant(tenant: $archivedTenant, user: $user, role: 'owner');
$archivedTenant->delete();
$run = OperationRun::factory()->create([
'workspace_id' => (int) $activeTenant->workspace_id,
'tenant_id' => (int) $archivedTenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Run tenant is not available in the current tenant selector')
->assertSee('This tenant is currently archived and may not appear in the tenant selector.')
->assertSee('Tenant lifecycle')
->assertSee('Archived')
->assertSee('Viewer context')
->assertDontSee('deactivated');
});

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders canonical lifecycle labels on the managed tenants landing', function (): void {
$workspace = Workspace::factory()->create(['slug' => 'tenant-lifecycle-ws']);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$active = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Active Tenant',
]);
$onboarding = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Onboarding Tenant',
]);
$archived = Tenant::factory()->archived()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Archived Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$active->getKey() => ['role' => 'owner'],
$onboarding->getKey() => ['role' => 'owner'],
$archived->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Active Tenant')
->assertSee('Onboarding Tenant')
->assertSee('Archived Tenant')
->assertSee('Active')
->assertSee('Onboarding')
->assertSee('Archived');
});
it('renders lifecycle summary on the tenant detail view', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Lifecycle summary')
->assertSee('This tenant is active and available across normal management, tenant selection, and operational follow-up flows.')
->assertSee('RBAC status')
->assertSee('App status');
});
it('renders the archived banner from the shared lifecycle presentation contract', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->delete();
Filament::setTenant($tenant, true);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Tenant archived')
->assertSee('This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.');
});

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('keeps lifecycle, app status, and rbac status separated on the tenant view page', function (): void {
[$user, $tenant] = createUserWithTenant(
tenant: Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING,
'app_status' => 'consent_required',
'rbac_status' => 'failed',
'name' => 'Separated Status Tenant',
]),
role: 'owner',
);
$this->actingAs($user);
Filament::setTenant($tenant, 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('App status')
->assertSee('Consent required')
->assertSee('RBAC status')
->assertSee('Failed');
});
it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void {
$tenant = Tenant::factory()->onboarding()->create([
'name' => 'Viewer Separation Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Status')
->assertSee('Completed')
->assertSee('Tenant lifecycle')
->assertSee('Onboarding')
->assertSee('Tenant selector context')
->assertSee('This tenant is currently onboarding and may not appear in the tenant selector.');
});

View File

@ -129,7 +129,7 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('related_onboarding')
->assertActionExists('related_onboarding', function (Action $action): bool {
return $action->getLabel() === 'View cancelled onboarding';
return $action->getLabel() === 'View cancelled onboarding draft';
});
});
});

View File

@ -162,6 +162,37 @@
->assertForbidden();
});
it('returns 403 for readonly members on cancelled draft summaries so delete controls never render', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_DRAFT,
]);
$readonly = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $readonly,
role: 'readonly',
workspaceRole: 'readonly',
ensureDefaultMicrosoftProviderConnection: false,
);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $readonly,
'updated_by' => $readonly,
'status' => 'cancelled',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($readonly)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertForbidden();
});
it('returns 404 for workspace members without linked archived tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$archivedTenant = Tenant::factory()->archived()->create([

View File

@ -46,7 +46,9 @@
->assertSuccessful()
->assertSee('This onboarding draft is Completed.')
->assertSee('Completed, cancelled, and lifecycle-locked drafts remain viewable, but they cannot return to editable wizard mode.')
->assertSee('Back to workspace')
->assertSee('Return to onboarding')
->assertSee('Delete draft')
->assertDontSee('Cancel draft');
});
@ -79,7 +81,9 @@
->assertSuccessful()
->assertSee('This onboarding draft is Cancelled.')
->assertSee('Completed, cancelled, and lifecycle-locked drafts remain viewable, but they cannot return to editable wizard mode.')
->assertSee('Back to workspace')
->assertSee('Return to onboarding')
->assertSee('Delete draft')
->assertDontSee('Cancel draft');
});
@ -157,6 +161,55 @@
->exists())->toBeTrue();
});
it('deletes a non-resumable onboarding draft from the header action', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$draft = createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'status' => 'cancelled',
'state' => [
'entra_tenant_id' => '66666666-6666-6666-6666-666666666666',
'tenant_name' => 'Draft To Delete',
],
]);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
])
->assertActionVisible('back_to_workspace')
->assertActionVisible('delete_onboarding_draft_header')
->mountAction('delete_onboarding_draft_header')
->callMountedAction()
->assertNotified('Onboarding draft deleted')
->assertRedirect(route('admin.onboarding'));
expect(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::ManagedTenantOnboardingDeleted->value)
->where('resource_id', (string) $draft->getKey())
->exists())->toBeTrue();
$this->assertDatabaseMissing('managed_tenant_onboarding_sessions', [
'id' => (int) $draft->getKey(),
]);
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertNotFound();
});
it('keeps cancelled drafts out of the landing redirect and picker flow', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows canonical lifecycle copy on selectable tenant cards', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Workspace A']);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => $workspace->getKey(),
'name' => 'Active Card Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('Active Card Tenant')
->assertSee('Active')
->assertSee('Active tenant available for normal operations.');
});
it('labels the onboarding linked tenant action with the canonical lifecycle name', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Onboarding Linked Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
],
]);
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
])
->assertActionHasLabel('view_linked_tenant', 'View tenant (Onboarding)');
});

View File

@ -15,12 +15,12 @@
expect($archived->color)->toBe('gray');
$unknown = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'suspended');
expect($unknown->label)->toBe('Unknown');
expect($unknown->color)->toBe('gray');
expect($unknown->label)->toBe('Invalid lifecycle');
expect($unknown->color)->toBe('danger');
$error = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'error');
expect($error->label)->toBe('Unknown');
expect($error->color)->toBe('gray');
expect($error->label)->toBe('Invalid lifecycle');
expect($error->color)->toBe('danger');
});
it('maps tenant app status values to canonical badge semantics', function (): void {

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantLifecycle;
use App\Support\Tenants\TenantLifecyclePresentation;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('maps every canonical lifecycle value to a canonical presentation contract', function (string $value, string $label, string $color): void {
$presentation = TenantLifecyclePresentation::fromValue($value);
expect($presentation->value)->toBe($value)
->and($presentation->label)->toBe($label)
->and($presentation->badge()->color)->toBe($color)
->and($presentation->isInvalidFallback)->toBeFalse()
->and($presentation->lifecycle?->value)->toBe($value);
})->with([
'draft' => ['draft', 'Draft', 'gray'],
'onboarding' => ['onboarding', 'Onboarding', 'warning'],
'active' => ['active', 'Active', 'success'],
'archived' => ['archived', 'Archived', 'gray'],
]);
it('builds lifecycle presentation from tenant soft-delete state', function (): void {
$tenant = Tenant::factory()->archived()->create();
$presentation = TenantLifecyclePresentation::fromTenant($tenant);
expect($presentation->lifecycle)->toBe(TenantLifecycle::Archived)
->and($presentation->label)->toBe('Archived')
->and($presentation->longDescription)->toContain('inspection and audit history');
});
it('uses explicit invalid fallback for non-canonical lifecycle data', function (): void {
$presentation = TenantLifecyclePresentation::fromValue('pending');
expect($presentation->isInvalidFallback)->toBeTrue()
->and($presentation->label)->toBe('Invalid lifecycle')
->and($presentation->badge()->color)->toBe('danger')
->and($presentation->value)->toBe('pending');
});
it('marks invalid referenced tenant lifecycle as unavailable for selector context', function (): void {
$presentation = ReferencedTenantLifecyclePresentation::forInvalid('operation_run', normalizedValue: 'inactive');
expect($presentation->selectorAvailabilityMessage())->toBe('This tenant has an invalid lifecycle value and may not appear in the tenant selector.')
->and($presentation->contextNote)->toBe('Some tenant follow-up actions may be unavailable from this canonical workspace view.');
});
it('describes non-selectable referenced tenant lifecycle using canonical wording', function (): void {
$tenant = Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Onboarding Tenant',
]);
$presentation = ReferencedTenantLifecyclePresentation::forOperationRun($tenant);
expect($presentation->selectorAvailabilityMessage())->toBe('This tenant is currently onboarding and may not appear in the tenant selector.')
->and($presentation->presentation->label)->toBe('Onboarding');
});

View File

@ -8,16 +8,20 @@
uses(RefreshDatabase::class);
it('normalizes canonical and aliased lifecycle values', function (string $input, TenantLifecycle $expected): void {
expect(TenantLifecycle::fromValue($input))->toBe($expected);
it('normalizes canonical lifecycle values only', function (string $input, TenantLifecycle $expected): void {
expect(TenantLifecycle::tryFromValue($input))->toBe($expected);
})->with([
'draft' => ['draft', TenantLifecycle::Draft],
'onboarding' => ['onboarding', TenantLifecycle::Onboarding],
'active' => ['active', TenantLifecycle::Active],
'archived' => ['archived', TenantLifecycle::Archived],
'pending alias' => ['pending', TenantLifecycle::Onboarding],
]);
it('returns null for non-canonical lifecycle aliases', function (): void {
expect(TenantLifecycle::tryFromValue('pending'))->toBeNull()
->and(TenantLifecycle::tryFromValue('inactive'))->toBeNull();
});
it('derives archived lifecycle from soft-deleted tenants', function (): void {
$tenant = Tenant::factory()->archived()->create();