247: plans entitlements billing readiness (#287)
Some checks failed
Main Confidence / confidence (push) Failing after 53s

Automated commit and PR created by Copilot per user request.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #287
This commit is contained in:
ahmido 2026-04-27 17:35:04 +00:00
parent 6e3736a53f
commit e222845a36
38 changed files with 3982 additions and 138 deletions

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Entitlements;
final class WorkspaceEntitlementBlockedException extends \RuntimeException
{
/**
* @param array<string, mixed> $decision
*/
public function __construct(private readonly array $decision)
{
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
}
/**
* @return array<string, mixed>
*/
public function decision(): array
{
return $this->decision;
}
}

View File

@ -9,6 +9,7 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
@ -176,6 +177,10 @@ public function table(Table $table): Table
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true))
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
: null)
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
])
->bulkActions([])

View File

@ -8,6 +8,8 @@
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
@ -21,6 +23,7 @@
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -58,10 +61,23 @@ class WorkspaceSettings extends Page
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
];
/**
* @var array<string, string>
*/
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
];
/**
* Fields rendered as Filament KeyValue components (array state, not JSON string).
*
@ -111,6 +127,14 @@ class WorkspaceSettings extends Page
*/
public array $resolvedSettings = [];
/**
* @var array{
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions?: array<string, array<string, mixed>>
* }
*/
public array $entitlementSummary = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
@ -180,6 +204,50 @@ public function content(Schema $schema): Schema
return $schema
->statePath('data')
->schema([
Section::make('Workspace entitlements')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2)
->schema([
Select::make('entitlements_plan_profile')
->label('Plan profile')
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
->native(false)
->columnSpanFull()
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->planProfileFieldHelperText()),
TextInput::make('entitlements_managed_tenant_limit_override_value')
->label('Managed tenant activation limit override')
->placeholder('Unset (uses plan profile default)')
->suffix('tenants')
->hint('0 or greater')
->numeric()
->integer()
->minValue(0)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitHelperText())
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
Textarea::make('entitlements_managed_tenant_limit_override_reason')
->label('Managed tenant activation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
Select::make('entitlements_review_pack_generation_override_value')
->label('Review pack generation override')
->options(self::booleanOptions())
->placeholder('Unset (uses plan profile default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
Textarea::make('entitlements_review_pack_generation_override_reason')
->label('Review pack generation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
]),
Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
->schema([
@ -455,6 +523,56 @@ public function resetSetting(string $field): void
->send();
}
private function resetEntitlementOverridePair(string $field): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
if (! $this->hasEntitlementOverridePair($field)) {
Notification::make()
->title('Entitlement already uses plan profile default')
->success()
->send();
return;
}
$writer = app(SettingsWriter::class);
$valueSetting = $this->settingForField($field);
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
$reasonSetting = $this->settingForField($reasonField);
if ($this->workspaceOverrideForField($field) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $valueSetting['domain'],
key: $valueSetting['key'],
);
}
if ($this->workspaceOverrideForField($reasonField) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $reasonSetting['domain'],
key: $reasonSetting['key'],
);
}
$this->loadFormState();
Notification::make()
->title('Workspace entitlement override reset')
->success()
->send();
}
private function loadFormState(): void
{
$resolver = app(SettingsResolver::class);
@ -490,6 +608,7 @@ private function loadFormState(): void
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
$this->loadDomainLastModified();
}
@ -563,15 +682,25 @@ private function makeResetAction(string $field): Action
->color('danger')
->requiresConfirmation()
->action(function () use ($field): void {
if ($this->isEntitlementOverrideValueField($field)) {
$this->resetEntitlementOverridePair($field);
return;
}
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.';
}
if (! $this->hasWorkspaceOverride($field)) {
if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) {
return 'No workspace override to reset.';
}
return 'No workspace override to reset.';
}
@ -579,6 +708,149 @@ private function makeResetAction(string $field): Action
});
}
private function canResetField(string $field): bool
{
if ($this->isEntitlementOverrideValueField($field)) {
return $this->hasEntitlementOverridePair($field);
}
return $this->hasWorkspaceOverride($field);
}
private function isEntitlementOverrideValueField(string $field): bool
{
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
}
private function hasEntitlementOverridePair(string $field): bool
{
if (! $this->isEntitlementOverrideValueField($field)) {
return false;
}
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
return $this->workspaceOverrideForField($field) !== null
|| $this->workspaceOverrideForField($reasonField) !== null;
}
private function planProfileFieldHelperText(): string
{
$profile = $this->resolvedPlanProfile();
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
if (! is_string($selectedProfile) || $selectedProfile === '') {
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
}
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
}
private function managedTenantLimitHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$capacityText = $remainingCapacity < 0
? sprintf('Over limit by %d.', abs($remainingCapacity))
: sprintf('%d remaining.', $remainingCapacity);
return sprintf(
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
$effectiveValue,
$currentUsage,
$capacityText,
$this->entitlementSourceLabel($decision),
);
}
private function managedTenantLimitReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_managed_tenant_limit_override_value',
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function reviewPackGenerationHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
return sprintf(
'Effective state: %s. Source: %s.',
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
$this->entitlementSourceLabel($decision),
);
}
private function reviewPackGenerationReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_review_pack_generation_override_value',
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function entitlementReasonHelperText(string $valueField, string $key): string
{
$decision = $this->entitlementDecision($key);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
if ($this->workspaceOverrideForField($valueField) === null) {
return 'Required when an explicit override value is set.';
}
if ($rationale === null || $rationale === '') {
return 'Required when an explicit override value is set.';
}
return sprintf('Current rationale: %s', $rationale);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
private function resolvedPlanProfile(): array
{
$profile = $this->entitlementSummary['plan_profile'] ?? null;
if (is_array($profile)) {
return $profile;
}
return app(WorkspacePlanProfileCatalog::class)->default();
}
/**
* @return array<string, mixed>
*/
private function entitlementDecision(string $key): array
{
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
return is_array($decision) ? $decision : [];
}
/**
* @param array<string, mixed> $decision
*/
private function entitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$planProfileLabel = $decision['plan_profile_label'] ?? null;
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
return sprintf('%s plan profile', $planProfileLabel);
}
return 'plan profile default';
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;
@ -721,6 +993,27 @@ private function normalizedInputValues(): array
}
}
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
if (($normalizedValues[$valueField] ?? null) === null) {
$normalizedValues[$reasonField] = null;
continue;
}
if (($normalizedValues[$reasonField] ?? null) !== null) {
continue;
}
$message = match ($valueField) {
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
default => 'Override reason is required when an explicit override is set.',
};
$validationErrors['data.'.$reasonField] ??= [];
$validationErrors['data.'.$reasonField][] = $message;
}
return [$normalizedValues, $validationErrors];
}

View File

@ -30,6 +30,7 @@
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderOperationRegistry;
@ -662,7 +663,16 @@ public function content(Schema $schema): Schema
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()),
Text::make('Activation entitlement')
->color('gray'),
Text::make(fn (): string => $this->completionSummaryEntitlementSummary())
->badge()
->color(fn (): string => $this->completionSummaryEntitlementColor()),
]),
Callout::make('Activation entitlement')
->description(fn (): string => $this->completionSummaryEntitlementDetail())
->warning()
->visible(fn (): bool => $this->completionSummaryEntitlementBlocked()),
Callout::make('Bootstrap needs attention')
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
->warning()
@ -700,9 +710,7 @@ public function content(Schema $schema): Schema
->modalSubmitActionLabel('Yes, complete onboarding')
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
? null
: 'Owner required to complete onboarding.')
->tooltip(fn (): ?string => $this->completionActionTooltip())
->action(fn () => $this->completeOnboarding()),
]),
]),
@ -4498,6 +4506,10 @@ private function canCompleteOnboarding(): bool
return false;
}
if ($this->completionSummaryEntitlementBlocked()) {
return false;
}
$user = $this->currentUser();
if (! app(TenantOperabilityService::class)->outcomeFor(
@ -4530,6 +4542,111 @@ private function canCompleteOnboarding(): bool
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
}
/**
* @return array<string, mixed>
*/
private function completionSummaryEntitlementDecision(): array
{
if (! isset($this->workspace) || ! $this->workspace instanceof Workspace) {
return [];
}
return app(WorkspaceEntitlementResolver::class)->resolve(
$this->workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function completionSummaryEntitlementBlocked(): bool
{
return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false);
}
private function completionSummaryEntitlementSummary(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
return sprintf(
'%s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$currentUsage,
$effectiveValue,
$sourceLabel,
);
}
private function completionSummaryEntitlementDetail(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf(
'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
$currentUsage,
$currentUsage === 1 ? '' : 's',
$effectiveValue,
$sourceLabel,
);
if ($remainingCapacity >= 0) {
$message .= sprintf(' Remaining capacity: %d.', $remainingCapacity);
}
if ($this->completionSummaryEntitlementBlocked()) {
$blockReason = is_string($decision['block_reason'] ?? null) ? $decision['block_reason'] : null;
if ($blockReason !== null && $blockReason !== '') {
$message = $blockReason;
}
}
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') {
$message .= ' Rationale: '.$rationale;
}
return $message;
}
private function completionSummaryEntitlementColor(): string
{
return $this->completionSummaryEntitlementBlocked() ? 'warning' : 'success';
}
/**
* @param array<string, mixed> $decision
*/
private function completionSummaryEntitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$label = $decision['plan_profile_label'] ?? null;
return is_string($label) && $label !== ''
? sprintf('%s plan profile', $label)
: 'plan profile default';
}
private function completionActionTooltip(): ?string
{
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
return 'Owner required to complete onboarding.';
}
if ($this->completionSummaryEntitlementBlocked()) {
return $this->completionSummaryEntitlementDetail();
}
return null;
}
private function completionSummaryTenantLine(): string
{
$tenant = $this->currentManagedTenantRecord();
@ -4863,6 +4980,16 @@ public function completeOnboarding(): void
return;
}
if ($this->completionSummaryEntitlementBlocked()) {
Notification::make()
->title('Activation limit reached')
->body($this->completionSummaryEntitlementDetail())
->warning()
->send();
return;
}
$run = $this->verificationRun();
$verificationSucceeded = $this->verificationHasSucceeded();
$verificationCanProceed = $this->verificationCanProceed();

View File

@ -2,6 +2,8 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
@ -10,6 +12,7 @@
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -45,6 +48,8 @@
class ReviewPackResource extends Resource
{
use ResolvesPanelTenantContext;
protected static ?string $model = ReviewPack::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -102,9 +107,9 @@ public static function canView(Model $record): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
@ -350,14 +355,37 @@ public static function table(Table $table): Table
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
->emptyStateIcon('heroicon-o-document-arrow-down')
->emptyStateActions([
UiEnforcement::forAction(
Actions\Action::make('generate_first')
->label('Generate first pack')
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
]);
}
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
{
$action = UiEnforcement::forAction(
Actions\Action::make($name)
->label($label)
->icon('heroicon-o-plus')
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
->action(function (array $data): void {
static::executeGeneration($data);
})
->form([
->form(static::reviewPackGenerationFormSchema())
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
return $action;
}
/**
* @return array<int, Section>
*/
public static function reviewPackGenerationFormSchema(): array
{
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
@ -369,22 +397,20 @@ public static function table(Table $table): Table
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
]);
];
}
public static function getEloquentQuery(): Builder
{
$tenant = Filament::getTenant();
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
return parent::getEloquentQuery()
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
->where('tenant_id', (int) $tenant->getKey());
}
public static function getPages(): array
@ -458,6 +484,14 @@ public static function executeGeneration(array $data): void
try {
$reviewPack = $service->generate($tenant, $user, $options);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->warning()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->send();
return;
} catch (ReviewPackEvidenceResolutionException $exception) {
$reasons = $exception->result->reasons;
@ -493,4 +527,55 @@ public static function executeGeneration(array $data): void
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function currentTenantContext(): ?Tenant
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
return $tenant instanceof Tenant ? $tenant : null;
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::currentTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant);
}
}

View File

@ -3,12 +3,7 @@
namespace App\Filament\Resources\ReviewPackResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Section;
class ListReviewPacks extends ListRecords
{
@ -17,29 +12,13 @@ class ListReviewPacks extends ListRecords
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('generate_pack')
->label('Generate Pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
ReviewPackResource::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
ReviewPackResource::generatePackAction()
->visible(fn (): bool => $this->tableHasRecords()),
];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
}

View File

@ -19,20 +19,12 @@ class ViewReviewPack extends ViewRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
UiEnforcement::forAction(
$regenerateAction = UiEnforcement::forAction(
Actions\Action::make('regenerate')
->label('Regenerate')
->icon('heroicon-o-arrow-path')
->color('primary')
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
->requiresConfirmation()
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
->action(function (array $data): void {
@ -67,7 +59,21 @@ protected function getHeaderActions(): array
})
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
->preserveDisabled()
->apply();
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
return [
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
$regenerateAction,
];
}
}

View File

@ -7,6 +7,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
@ -15,6 +16,7 @@
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -241,6 +243,25 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
return $table
->defaultSort('generated_at', 'desc')
->persistFiltersInSession()
@ -287,20 +308,7 @@ public static function table(Table $table): Table
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
])
->actions([
UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
$exportExecutivePackAction,
])
->bulkActions([])
->emptyStateHeading('No tenant reviews yet')
@ -423,6 +431,50 @@ public static function executeCreateReview(array $data): void
$toast->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::panelTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant);
}
public static function executeExport(TenantReview $review): void
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
@ -457,6 +509,10 @@ public static function executeExport(TenantReview $review): void
'include_pii' => true,
'include_operations' => true,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
return;
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();

View File

@ -232,7 +232,7 @@ private function publishReviewAction(): Actions\Action
private function exportExecutivePackAction(): Actions\Action
{
return UiEnforcement::forAction(
$action = UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
@ -241,11 +241,17 @@ private function exportExecutivePackAction(): Actions\Action
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
return $action;
}
private function createNextReviewAction(): Actions\Action

View File

@ -9,6 +9,7 @@
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog;
@ -85,6 +86,14 @@ public function runsUrl(): string
return SystemOperationRunLinks::index();
}
/**
* @return array<string, mixed>
*/
public function workspaceEntitlementSummary(): array
{
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},

View File

@ -4,6 +4,7 @@
namespace App\Filament\Widgets\Tenant;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\Tenant;
@ -18,6 +19,7 @@
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class TenantReviewPackCard extends Widget
@ -66,6 +68,18 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
/** @var ReviewPackService $service */
$service = app(ReviewPackService::class);
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
if ((bool) ($decision['is_blocked'] ?? false)) {
Notification::make()
->title('Review pack generation unavailable')
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
->warning()
->send();
return;
}
$activeRun = $service->checkActiveRun($tenant)
? OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
@ -90,10 +104,20 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return;
}
try {
$reviewPack = $service->generate($tenant, $user, [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->warning()
->send();
return;
}
$runUrl = $reviewPack->operationRun
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
@ -130,6 +154,14 @@ protected function getViewData(): array
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$service = app(ReviewPackService::class);
$generationEntitlement = $canManage
? $service->reviewPackGenerationDecisionForTenant($tenant)
: null;
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
? $generationEntitlement['block_reason']
: null;
$latestPack = ReviewPack::query()
->with(['tenantReview', 'operationRun'])
@ -146,6 +178,8 @@ protected function getViewData(): array
'pollingInterval' => null,
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'downloadUrl' => null,
'failedReason' => null,
'reviewUrl' => null,
@ -194,6 +228,8 @@ protected function getViewData(): array
'pollingInterval' => self::resolvePollingInterval($latestPack),
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
@ -224,6 +260,8 @@ private function emptyState(): array
'pollingInterval' => null,
'canView' => false,
'canManage' => false,
'generationBlocked' => false,
'generationBlockReason' => null,
'downloadUrl' => null,
'failedReason' => null,
'failedReasonDetail' => null,

View File

@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use Carbon\CarbonInterface;
final class WorkspaceEntitlementResolver
{
public const SETTING_DOMAIN = 'entitlements';
public const SETTING_PLAN_PROFILE = 'plan_profile';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
public function __construct(
private SettingsResolver $settingsResolver,
private WorkspacePlanProfileCatalog $planProfileCatalog,
) {}
/**
* @return array{
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions: array<string, array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }>
* }
*/
public function summary(Workspace $workspace): array
{
$planProfile = $this->resolvePlanProfile($workspace);
return [
'plan_profile' => $planProfile,
'decisions' => [
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
],
];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolvePlanProfile(Workspace $workspace): array
{
$planProfileId = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_PLAN_PROFILE,
);
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
{
$planProfile ??= $this->resolvePlanProfile($workspace);
$lastChanged = $this->lastChangedMetadata($workspace);
return match ($key) {
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
};
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int,
* remaining_capacity: int,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
);
$effectiveValue = is_int($overrideValue['value'])
? $overrideValue['value']
: (int) $planProfile['managed_tenant_limit_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$currentUsage = Tenant::activeQuery()
->where('workspace_id', (int) $workspace->getKey())
->count();
$remainingCapacity = $effectiveValue - $currentUsage;
$isBlocked = $currentUsage >= $effectiveValue;
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => $currentUsage,
'remaining_capacity' => $remainingCapacity,
'is_blocked' => $isBlocked,
'block_reason' => $isBlocked
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
: null,
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: null,
* remaining_capacity: null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
);
$effectiveValue = is_bool($overrideValue['value'])
? $overrideValue['value']
: (bool) $planProfile['review_pack_generation_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => null,
'remaining_capacity' => null,
'is_blocked' => ! $effectiveValue,
'block_reason' => $effectiveValue
? null
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_PLAN_PROFILE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
])
->whereNotNull('updated_by_user_id')
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
{
$prefix = $source === 'workspace_override'
? 'This workspace override currently allows'
: sprintf('The %s plan profile currently allows', $planProfile['label']);
$message = sprintf(
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
$prefix,
$effectiveValue,
$effectiveValue === 1 ? '' : 's',
$currentUsage,
$currentUsage === 1 ? '' : 's',
);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
{
$message = $source === 'workspace_override'
? 'Review pack generation is disabled by workspace override.'
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
}

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
final class WorkspacePlanProfileCatalog
{
/**
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
private const PROFILES = [
'starter' => [
'id' => 'starter',
'label' => 'Starter',
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
'managed_tenant_limit_default' => 1,
'review_pack_generation_default' => false,
'is_default' => false,
],
'standard' => [
'id' => 'standard',
'label' => 'Standard',
'description' => 'Balanced defaults for most managed workspaces.',
'managed_tenant_limit_default' => 25,
'review_pack_generation_default' => true,
'is_default' => true,
],
'scale' => [
'id' => 'scale',
'label' => 'Scale',
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
'managed_tenant_limit_default' => 100,
'review_pack_generation_default' => true,
'is_default' => false,
],
];
/**
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
public function all(): array
{
return array_values(self::PROFILES);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function default(): array
{
return self::PROFILES[self::defaultProfileId()];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
*/
public function find(?string $id): ?array
{
if ($id === null) {
return null;
}
return self::PROFILES[$id] ?? null;
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolve(?string $id): array
{
return $this->find($id) ?? $this->default();
}
/**
* @return array<string, string>
*/
public function optionLabels(): array
{
return array_map(
static fn (array $profile): string => $profile['label'],
self::PROFILES,
);
}
/**
* @return list<string>
*/
public static function profileIds(): array
{
return array_keys(self::PROFILES);
}
public static function defaultProfileId(): string
{
foreach (self::PROFILES as $id => $profile) {
if ($profile['is_default']) {
return $id;
}
}
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
}
}

View File

@ -4,6 +4,7 @@
namespace App\Services;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot;
@ -13,6 +14,7 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId;
@ -28,6 +30,7 @@ public function __construct(
private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger,
private WorkspaceEntitlementResolver $workspaceEntitlementResolver,
private ProductTelemetryRecorder $productTelemetryRecorder,
) {}
@ -49,6 +52,8 @@ public function __construct(
*/
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
{
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$snapshot = $this->resolveSnapshot($tenant);
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
@ -138,6 +143,8 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
}
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$fingerprint = $this->computeFingerprintForReview($review, $options);
$existing = $this->findExistingPackForReview($review, $fingerprint);
@ -239,6 +246,17 @@ public function generateDownloadUrl(ReviewPack $pack): string
);
}
/**
* @return array<string, mixed>
*/
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{
return $this->workspaceEntitlementResolver->resolve(
$tenant->workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
{
$this->productTelemetryRecorder->record(
@ -314,6 +332,17 @@ private function normalizeOptions(array $options): array
];
}
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
{
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return;
}
throw new WorkspaceEntitlementBlockedException($decision);
}
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
{
$data = [

View File

@ -5,6 +5,7 @@
namespace App\Support\Settings;
use App\Models\Finding;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry
{
@ -218,6 +219,91 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
rules: ['required', 'integer', 'min:0', 'max:10080'],
normalizer: static fn (mixed $value): int => (int) $value,
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'plan_profile',
type: 'string',
systemDefault: WorkspacePlanProfileCatalog::defaultProfileId(),
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspacePlanProfileCatalog::profileIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_value',
type: 'int',
systemDefault: null,
rules: ['nullable', 'integer', 'min:0'],
normalizer: static function (mixed $value): ?int {
if ($value === null || $value === '') {
return null;
}
return (int) $value;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_value',
type: 'bool',
systemDefault: null,
rules: ['nullable', 'boolean'],
normalizer: static function (mixed $value): ?bool {
if ($value === null || $value === '') {
return null;
}
return filter_var($value, FILTER_VALIDATE_BOOL);
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
}
/**

View File

@ -39,6 +39,7 @@
use App\Filament\System\Pages\Dashboard as SystemDashboard;
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
use App\Filament\System\Pages\Ops\Controls;
use App\Filament\System\Pages\Ops\Runbooks;
use App\Filament\System\Pages\Ops\ViewRun;
use App\Filament\System\Pages\RepairWorkspaceOwners;
@ -661,6 +662,32 @@ public static function spec195ResidualSurfaceInventory(): array
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
Controls::class => [
'surfaceKey' => 'system_ops_controls',
'surfaceName' => 'System Ops Controls',
'pageClass' => Controls::class,
'panelPlane' => 'system',
'surfaceKind' => 'system_utility',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'Operational controls is a dedicated system control workbench with confirmation-backed pause, resume, and history actions plus restore-gate coupling, so it remains governed by focused workflow tests instead of the generic declaration-backed contract.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsControls/OperationalControlManagementTest.php',
'proves' => 'The controls page keeps capability-gated operational-control actions, confirmation semantics, scope previews, and audited pause or resume behavior under dedicated coverage.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php',
'proves' => 'Restore execution stays coupled to the shared operational-control workflow, including blocked execution and non-retroactive pause behavior after acceptance.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
RepairWorkspaceOwners::class => [
'surfaceKey' => 'repair_workspace_owners',
'surfaceName' => 'Repair Workspace Owners',

View File

@ -4,6 +4,11 @@
$customerHealthDecision = $this->customerHealthDecision();
$tenants = $this->workspaceTenants();
$runs = $this->recentRuns();
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
$managedTenantDecision = $entitlementDecisions['managed_tenant_activation_limit'] ?? null;
$reviewPackDecision = $entitlementDecisions['review_pack_generation_enabled'] ?? null;
@endphp
<x-filament-panels::page>
@ -35,6 +40,58 @@
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
<x-filament::section>
<x-slot name="heading">
Workspace entitlements
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Plan profile</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $planProfile['label'] }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $planProfile['description'] }}</p>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last changed</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $managedTenantDecision['last_changed_by'] ?? 'Not set' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['last_changed_at']?->diffForHumans() ?? 'No entitlement override recorded yet.' }}</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">Managed tenant activation limit</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['current_usage'] }} active of {{ $managedTenantDecision['effective_value'] }} allowed</p>
</div>
<x-filament::badge :color="$managedTenantDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
{{ $managedTenantDecision['source'] === 'workspace_override' ? 'workspace override' : ($managedTenantDecision['plan_profile_label'].' plan profile') }}
</x-filament::badge>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['rationale'] }}</p>
</div>
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">Review pack generation</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['effective_value'] ? 'Enabled' : 'Disabled' }}</p>
</div>
<x-filament::badge :color="$reviewPackDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
{{ $reviewPackDecision['source'] === 'workspace_override' ? 'workspace override' : ($reviewPackDecision['plan_profile_label'].' plan profile') }}
</x-filament::badge>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['rationale'] }}</p>
</div>
</div>
</x-filament::section>
@endif
<x-filament::section>
<x-slot name="heading">
Tenants summary

View File

@ -9,6 +9,8 @@
/** @var ?string $pollingInterval */
/** @var bool $canView */
/** @var bool $canManage */
/** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */
/** @var ?string $downloadUrl */
/** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
@ -24,6 +26,12 @@
@endif
>
<x-filament::section heading="Review Pack">
@if ($canManage && $generationBlocked && $generationBlockReason)
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
{{ $generationBlockReason }}
</div>
@endif
@if (! $pack)
{{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center">
@ -37,12 +45,15 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate pack
</x-filament::button>
@endif
</div>
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
@endif
@if ($pack && ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating))
{{-- State 2: Queued / Generating --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -63,7 +74,9 @@
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
</div>
</div>
@elseif ($statusEnum === ReviewPackStatus::Ready)
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Ready)
{{-- State 3: Ready --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -116,13 +129,16 @@
color="gray"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate new
</x-filament::button>
@endif
</div>
</div>
@elseif ($statusEnum === ReviewPackStatus::Failed)
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Failed)
{{-- State 4: Failed --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -163,12 +179,15 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Retry
</x-filament::button>
@endif
</div>
@elseif ($statusEnum === ReviewPackStatus::Expired)
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Expired)
{{-- State 5: Expired --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -189,6 +208,7 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate new
</x-filament::button>

View File

@ -5,12 +5,14 @@
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\RestoreRun;
use App\Models\User;
use App\Models\Workspace;
@ -240,6 +242,47 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
expect($headerCreate?->isVisible())->toBeTrue();
});
it('shows generate only in empty state when review packs table is empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateGenerate = getPlacementEmptyStateAction($component, 'generate_first');
expect($emptyStateGenerate)->not->toBeNull();
expect($emptyStateGenerate?->getLabel())->toBe('Generate first pack');
$headerGenerate = getHeaderAction($component, 'generate_pack');
expect($headerGenerate)->not->toBeNull();
expect($headerGenerate?->isVisible())->toBeFalse();
});
it('shows generate only in header when review packs table is not empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::test(ListReviewPacks::class)
->assertCountTableRecords(1);
$headerGenerate = getHeaderAction($component, 'generate_pack');
expect($headerGenerate)->not->toBeNull();
expect($headerGenerate?->isVisible())->toBeTrue();
expect($headerGenerate?->getLabel())->toBe('Generate Pack');
});
it('shows create only in empty state when tenants table is empty', function (): void {
$workspace = Workspace::factory()->create([
'archived_at' => now(),

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
/**
* @return array{0: Workspace, 1: User}
*/
function entitlementSettingsManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
it('saves entitlement plan profile and override pairs through the workspace settings page', function (): void {
[$workspace, $user] = entitlementSettingsManager();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful()
->assertSee('Workspace entitlements');
$component = Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.entitlements_plan_profile', null)
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null)
->assertSet('data.entitlements_review_pack_generation_override_value', null)
->assertSet('data.entitlements_review_pack_generation_override_reason', null)
->set('data.entitlements_plan_profile', 'starter')
->set('data.entitlements_managed_tenant_limit_override_value', 2)
->set('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
->set('data.entitlements_review_pack_generation_override_value', '0')
->set('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.entitlements_plan_profile', 'starter')
->assertSet('data.entitlements_managed_tenant_limit_override_value', 2)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
->assertSet('data.entitlements_review_pack_generation_override_value', '0')
->assertSet('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only');
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
expect($summary['plan_profile']['id'])->toBe('starter')
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
->toMatchArray([
'effective_value' => 2,
'source' => 'workspace_override',
'rationale' => 'Temporary support-approved exception',
'last_changed_by' => $user->name,
])
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED])
->toMatchArray([
'effective_value' => false,
'source' => 'workspace_override',
'rationale' => 'Workspace is temporarily limited to manual reporting only',
'last_changed_by' => $user->name,
]);
$component
->mountFormComponentAction('entitlements_managed_tenant_limit_override_value', 'reset_entitlements_managed_tenant_limit_override_value', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null);
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
expect($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
->toMatchArray([
'effective_value' => 1,
'source' => 'plan_profile_default',
'rationale' => 'Minimal allowance for early workspace access and low-volume operations.',
]);
});
it('requires an override reason when a workspace entitlement override value is set', function (): void {
[, $user] = entitlementSettingsManager();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.entitlements_managed_tenant_limit_override_value', 3)
->set('data.entitlements_managed_tenant_limit_override_reason', '')
->callAction('save')
->assertHasErrors(['data.entitlements_managed_tenant_limit_override_reason']);
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.entitlements_review_pack_generation_override_value', '0')
->set('data.entitlements_review_pack_generation_override_reason', '')
->callAction('save')
->assertHasErrors(['data.entitlements_review_pack_generation_override_reason']);
});

View File

@ -950,6 +950,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,

View File

@ -35,6 +35,7 @@ function spec195FormattedIssues(array $issues): string
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
@ -67,6 +68,7 @@ function spec195FormattedIssues(array $issues): string
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Ops\Controls::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
@ -76,6 +78,7 @@ function spec195FormattedIssues(array $issues): string
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,

View File

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
*/
function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array
{
Queue::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
if ($activeTenantCount > 0) {
Tenant::factory()->count($activeTenantCount)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
}
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Ready connection',
'is_default' => true,
'consent_status' => 'granted',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
],
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'bootstrap',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
if ($limitOverride !== null) {
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: $limitOverride,
);
if ($overrideReason !== null) {
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
value: $overrideReason,
);
}
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
]);
return compact('workspace', 'user', 'tenant', 'draft', 'component');
}
it('allows onboarding activation when the workspace is within its managed tenant limit', function (): void {
$context = readyOnboardingEntitlementContext(activeTenantCount: 0);
$context['component']->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeTrue();
});
it('blocks onboarding activation with a business-state reason when the workspace is at limit', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 1,
limitOverride: 1,
overrideReason: 'Customer currently allows one active tenant',
);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$context['workspace'],
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision['is_blocked'])->toBeTrue();
$context['component']
->assertSee('Activation entitlement')
->assertSee('Blocked')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeFalse();
});
it('allows onboarding activation when a workspace override raises the limit above current usage', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 1,
limitOverride: 2,
overrideReason: 'Temporary support-approved exception',
);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$context['workspace'],
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision)
->toMatchArray([
'source' => 'workspace_override',
'effective_value' => 2,
'current_usage' => 1,
'is_blocked' => false,
'rationale' => 'Temporary support-approved exception',
]);
$context['component']->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
});

View File

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
beforeEach(function (): void {
Storage::fake('exports');
});
function seedEntitlementReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
{
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => ['required_count' => 1, 'granted_count' => 1],
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
'payload' => ['roles' => [['displayName' => 'Global Administrator']]],
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
OperationRun::factory()->forTenant($tenant)->create();
/** @var EvidenceSnapshotService $service */
$service = app(EvidenceSnapshotService::class);
$payload = $service->buildSnapshotPayload($tenant);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'fingerprint' => $payload['fingerprint'],
'completeness_state' => $payload['completeness'],
'summary' => $payload['summary'],
'generated_at' => now(),
]);
foreach ($payload['items'] as $item) {
$snapshot->items()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'dimension_key' => $item['dimension_key'],
'state' => $item['state'],
'required' => $item['required'],
'source_kind' => $item['source_kind'],
'source_record_type' => $item['source_record_type'],
'source_record_id' => $item['source_record_id'],
'source_fingerprint' => $item['source_fingerprint'],
'measured_at' => $item['measured_at'],
'freshness_at' => $item['freshness_at'],
'summary_payload' => $item['summary_payload'],
'sort_order' => $item['sort_order'],
]);
}
return $snapshot;
}
function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, string $reason): void
{
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: $reason,
);
}
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
});
it('blocks executive pack export before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEntitlementReviewPackSnapshot($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generateFromReview($review, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
});
it('shows the blocked reason on the review pack card and keeps existing pack downloads accessible', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Workspace is temporarily limited to manual reporting only')
->assertSee('Generate pack')
->call('generatePack', true, true)
->assertHasNoErrors();
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
$filePath = 'review-packs/entitlement-download-test.zip';
Storage::disk('exports')->put($filePath, 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => $filePath,
'file_disk' => 'exports',
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee('Download');
});

View File

@ -9,11 +9,13 @@
use App\Services\ReviewPackService;
use App\Support\Auth\UiTooltips;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
use Livewire\Features\SupportTesting\Testable;
uses(RefreshDatabase::class);
@ -21,6 +23,17 @@
Storage::fake('exports');
});
function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
// ─── Non-Member Access ───────────────────────────────────────
it('returns 404 for non-member on list page', function (): void {
@ -124,11 +137,15 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack')
->assertActionDisabled('generate_pack')
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
});
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
@ -137,6 +154,12 @@
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);

View File

@ -13,16 +13,19 @@
use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\UiTooltips;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunLinks;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Livewire\Features\SupportTesting\Testable;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
@ -31,6 +34,31 @@
Storage::fake('exports');
});
function getReviewPackEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function getReviewPackHeaderAction(Testable $component, string $name): ?Action
{
$instance = $component->instance();
$instance->cacheInteractsWithHeaderActions();
foreach ($instance->getCachedHeaderActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
{
StoredReport::factory()->create([
@ -130,8 +158,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
'tenant_id' => (int) $otherTenant->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
@ -150,32 +177,112 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
->assertSee('No review packs yet');
});
// ─── List Page Header Action ─────────────────────────────────
// ─── List Page Start CTA Placement ───────────────────────────
it('shows the generate_pack header action for a MANAGE user', function (): void {
it('shows generate only in the empty state when no review packs exist', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
$headerAction = getReviewPackHeaderAction($component, 'generate_pack');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->getLabel())->toBe('Generate first pack')
->and($headerAction)->not->toBeNull()
->and($headerAction?->isVisible())->toBeFalse();
});
it('shows generate in the header once review packs exist', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack');
});
it('disables the generate_pack action for a readonly user', function (): void {
it('disables the generate_first action for a readonly user in the empty state', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack')
->assertActionDisabled('generate_pack')
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
});
it('disables review pack generation actions when the workspace entitlement blocks them', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: 'Workspace is temporarily limited to manual reporting only',
);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
expect(ReviewPackResource::reviewPackGenerationActionTooltip($tenant))
->toBe('Review pack generation is disabled by workspace override. Reason: Workspace is temporarily limited to manual reporting only');
$listPage = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($listPage, 'generate_first');
$headerAction = getReviewPackHeaderAction($listPage, 'generate_pack');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($headerAction)->not->toBeNull()
->and($headerAction?->isVisible())->toBeFalse();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
Livewire::actingAs($user)
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
->assertActionVisible('regenerate')
->assertActionDisabled('regenerate');
});
it('reuses an existing ready pack instead of starting a new run', function (): void {
@ -225,6 +332,12 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->failed()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
@ -236,7 +349,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
])
->assertNotified();
expect(ReviewPack::query()->count())->toBe(0);
expect(ReviewPack::query()->count())->toBe(1);
Queue::assertNothingPushed();
});

View File

@ -11,6 +11,9 @@
use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\ReasonTranslation\ReasonPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -48,7 +51,13 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
OperationRun::factory()->forTenant($tenant)->create();
OperationRun::factory()->forTenant($tenant)->create([
'type' => OperationRunType::TenantReviewCompose->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'started_at' => now()->subMinute(),
'completed_at' => now(),
]);
/** @var EvidenceSnapshotService $service */
$service = app(EvidenceSnapshotService::class);

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Directory\ViewWorkspace;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
$manager = User::factory()->create(['name' => 'Workspace Manager']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $manager->getKey(),
'role' => 'manager',
]);
Tenant::factory()->count(2)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'plan_profile',
value: 'starter',
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'managed_tenant_limit_override_value',
value: 2,
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'managed_tenant_limit_override_reason',
value: 'Pilot workspace',
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: 'Escalation only',
);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Workspace entitlements')
->assertSee('Starter')
->assertSee('Pilot workspace')
->assertSee('Escalation only')
->assertSee('workspace override')
->assertDontSee('Save');
});

View File

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function entitledWorkspaceManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
return [$workspace, $user];
}
it('falls back to the default plan profile when a workspace has no entitlement settings', function (): void {
[$workspace] = entitledWorkspaceManager();
$resolver = app(WorkspaceEntitlementResolver::class);
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
expect($managedTenantLimit)
->toMatchArray([
'plan_profile_id' => 'standard',
'key' => WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
'effective_value' => 25,
'source' => 'plan_profile_default',
'current_usage' => 0,
'remaining_capacity' => 25,
'is_blocked' => false,
])
->and($managedTenantLimit['rationale'])->toBe('Balanced defaults for most managed workspaces.')
->and($managedTenantLimit['last_changed_at'])->toBeNull()
->and($managedTenantLimit['last_changed_by'])->toBeNull();
expect($reviewPackGeneration)
->toMatchArray([
'plan_profile_id' => 'standard',
'key' => WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
'effective_value' => true,
'source' => 'plan_profile_default',
'is_blocked' => false,
]);
});
it('applies the selected plan profile defaults when no explicit override is set', function (): void {
[$workspace, $user] = entitledWorkspaceManager();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
value: 'starter',
);
$resolver = app(WorkspaceEntitlementResolver::class);
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
expect($managedTenantLimit)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => 1,
'source' => 'plan_profile_default',
'current_usage' => 0,
'remaining_capacity' => 1,
'is_blocked' => false,
])
->and($managedTenantLimit['last_changed_by'])->toBe($user->name)
->and($managedTenantLimit['last_changed_at'])->not->toBeNull();
expect($reviewPackGeneration)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => false,
'source' => 'plan_profile_default',
'is_blocked' => true,
])
->and($reviewPackGeneration['block_reason'])->toContain('Starter');
});
it('applies workspace override values, rationale, and usage-aware blocking', function (): void {
[$workspace, $user] = entitledWorkspaceManager();
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
value: 'starter',
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: 2,
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
value: 'Temporary support-approved exception',
);
Tenant::factory()->count(2)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => 2,
'source' => 'workspace_override',
'rationale' => 'Temporary support-approved exception',
'current_usage' => 2,
'remaining_capacity' => 0,
'is_blocked' => true,
'last_changed_by' => $user->name,
])
->and($decision['last_changed_at'])->not->toBeNull()
->and($decision['block_reason'])->toContain('workspace override')
->and($decision['block_reason'])->toContain('Temporary support-approved exception');
});

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
it('exposes a bounded profile catalog with exactly one default profile', function (): void {
$catalog = app(WorkspacePlanProfileCatalog::class);
$profiles = $catalog->all();
expect($profiles)
->toHaveCount(3)
->and(collect($profiles)->where('is_default', true))->toHaveCount(1)
->and(WorkspacePlanProfileCatalog::defaultProfileId())->toBe('standard')
->and($catalog->default()['label'])->toBe('Standard');
});
it('resolves known profiles and falls back to the default for unknown identifiers', function (): void {
$catalog = app(WorkspacePlanProfileCatalog::class);
expect($catalog->resolve('starter'))
->toMatchArray([
'id' => 'starter',
'managed_tenant_limit_default' => 1,
'review_pack_generation_default' => false,
])
->and($catalog->resolve('missing-profile')['id'])->toBe('standard')
->and($catalog->optionLabels())
->toMatchArray([
'starter' => 'Starter',
'standard' => 'Standard',
'scale' => 'Scale',
]);
});

View File

@ -0,0 +1,61 @@
# Specification Quality Checklist: Plans, Entitlements & Billing Readiness
**Purpose**: Validate full preparation-package completeness and implementation readiness after planning and task generation
**Created**: 2026-04-27
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Business value and operator outcomes stay explicit
- [x] The first slice is bounded to one workspace plan profile, two entitlement keys, two first enforcement points, and one read-only system summary
- [x] Runtime-governance sections are present for a future runtime feature, not treated as docs-only
- [x] All mandatory sections are completed
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Requirements are testable and unambiguous
- [x] Acceptance scenarios are defined for the primary user journeys
- [x] Edge cases are identified, including over-limit workspaces, existing artifacts, and unchanged queued runs
- [x] Scope is clearly bounded away from checkout, invoices, payment providers, proration, trials, grace periods, and a customer-account domain
- [x] Dependencies, assumptions, risks, and follow-up candidates are identified
## Feature Readiness
- [x] The first slice is small enough for bounded later planning
- [x] Concrete repo surfaces are named for settings, onboarding, review-pack generation, and system visibility
- [x] Follow-up commercial work is separated from the current slice instead of hidden inside it
- [x] No unresolved product question blocks `/speckit.implement` once artifact analysis passes
- [x] The selected candidate remains recognizable while the implementation slice stays narrow
## Governance Readiness
- [x] Workspace-owned settings are explicitly chosen over a new billing/account persistence model
- [x] Capability-first RBAC and 404 versus 403 semantics remain explicit
- [x] Entitlement denials are separated from RBAC denials and described as truthful product-state blocks
- [x] System-plane visibility is read-only and auditable in the first slice
- [x] Operator-facing surfaces include the required UI contract sections and action matrix
- [x] Livewire v4 compliance, unchanged provider registration location, no global-search changes, confirmation expectations for destructive actions, and no asset-strategy changes are explicit in the package
## Test Governance Review
- [x] Lane fit stays in focused unit plus feature validation only
- [x] Fixture and helper growth stays local to workspace, tenant, review-pack, and platform-directory contexts
- [x] No browser or heavy-governance family is introduced implicitly
- [x] Minimal validation commands are explicit in the spec
- [x] Runtime impact is treated as a real future feature, not as a documentation-only update
## Review Outcome
- [x] Review outcome class: `keep`
- [x] Workflow outcome: `keep`
- [x] Next command readiness: `/speckit.implement` after analyze issues are cleared
## Notes
- This checklist now validates the full preparation package: spec, plan, supporting design artifacts, and tasks. It does not imply that application code already exists.
- The first slice intentionally stops before trial or grace lifecycle state, payment integration, broader plan matrices, or any customer-account domain.
- System-plane mutation is deferred on purpose; the first slice keeps system visibility read-only to avoid creating a second commercial source of truth.
- Implementation close-out note (2026-04-27): the bounded slice has now been implemented and the focused validation lanes completed. The final post-format review-pack and system-directory lane passed at `31 passed (133 assertions)`.
- Browser smoke close-out note (2026-04-27): an integrated-browser smoke attempt was made because the slice changed user-facing Filament surfaces, but the environment could not provide a reliable authenticated tenant/system panel context. The smoke result is therefore classified as environment-blocked rather than pass/fail.
- Shared-surface note (2026-04-27): the final proof for blocked review-pack actions relies on the shared operator-facing tooltip helper text plus disabled action state. Direct tooltip-object inspection on the wrapped recordless Filament header action was not stable enough to serve as the final regression check.

View File

@ -0,0 +1,456 @@
openapi: 3.0.3
info:
title: TenantPilot Admin/System — Workspace Entitlements Foundation (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the workspace-first entitlement foundation.
NOTE: These routes are implemented as existing Filament pages, widgets,
resources, and Livewire-backed actions. The exact Livewire payload shape is
not part of this contract. This file captures the user-visible routes,
logical action boundaries, and the required 404 / 403 / business-state
blocking semantics for the first slice.
servers:
- url: /admin
- url: /system
paths:
/settings/workspace:
get:
summary: View workspace entitlement settings
description: |
Renders the existing workspace settings singleton page with one new
entitlement section.
Behavior:
- No workspace selected: redirect to `/admin/choose-workspace`
- Non-member or wrong workspace: 404
- Workspace member without `workspace_settings.view`: 403
- Authorized member: render plan profile, effective entitlements,
source labels, rationale, and current usage summary
responses:
'200':
description: Workspace settings page rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/WorkspaceEntitlementSettingsView'
'302':
description: Redirect to choose-workspace when no workspace is active
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/settings/workspace/actions/save-entitlements:
post:
summary: Save plan profile and explicit entitlement overrides
description: |
Conceptual contract for the existing singleton settings save action.
The save reuses existing workspace-setting persistence and audit logging.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WorkspaceEntitlementSettingsCommand'
responses:
'204':
description: Settings saved successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/settings/workspace/actions/reset-entitlement-override/{entitlementKey}:
post:
summary: Reset one explicit entitlement override and rationale
description: |
Conceptual contract for a confirmation-protected override reset action.
Resetting returns effective truth to the selected plan profile or the
code-owned default profile.
parameters:
- $ref: '#/components/parameters/EntitlementKey'
responses:
'204':
description: Override reset successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}:
get:
summary: View onboarding workflow with entitlement-aware completion state
description: |
Renders the existing managed-tenant onboarding wizard. The completion
step must include managed-tenant activation entitlement truth.
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'200':
description: Onboarding wizard rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/OnboardingEntitlementView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}/actions/complete:
post:
summary: Complete onboarding when entitlement and existing readiness allow
description: |
Conceptual contract for the existing confirmation-protected completion
action. The entitlement gate must run before any tenant activation
mutation occurs.
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'204':
description: Onboarding completed
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/actions/generate:
post:
summary: Generate a review pack from the current tenant context
description: |
Conceptual contract for the tenant dashboard widget and review-pack list
generate action family. Existing dedupe and queued-start behavior remain
unchanged when entitlement allows execution.
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewPackGenerationCommand'
responses:
'202':
description: Generation accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/tenant-reviews/{tenantReview}/actions/export-executive-pack:
post:
summary: Export an executive pack from an existing tenant review
description: |
Conceptual contract for the review register and tenant review detail
export action family. The entitlement gate must run before any new
`ReviewPack` or `OperationRun` is created.
parameters:
- $ref: '#/components/parameters/TenantReviewId'
responses:
'202':
description: Export accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/{reviewPack}/actions/regenerate:
post:
summary: Regenerate an existing review pack
description: |
Conceptual contract for the existing review-pack detail regenerate
action. Existing confirmation and reuse behavior remain in place.
parameters:
- $ref: '#/components/parameters/ReviewPackId'
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewPackGenerationCommand'
responses:
'202':
description: Regeneration accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/directory/workspaces/{workspace}:
get:
summary: View read-only workspace entitlement summary in the system plane
description: |
Renders the existing system directory workspace detail page with a
read-only entitlement summary.
Behavior:
- Platform user with `platform.directory.view`: 200
- Platform user without that capability: 403
- Wrong-plane or non-platform actor: 404 semantics at the panel boundary
parameters:
- $ref: '#/components/parameters/WorkspaceId'
responses:
'200':
description: System workspace detail rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/SystemWorkspaceEntitlementView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
components:
parameters:
WorkspaceId:
name: workspace
in: path
required: true
schema:
type: integer
OnboardingDraftId:
name: onboardingDraft
in: path
required: true
schema:
type: integer
TenantReviewId:
name: tenantReview
in: path
required: true
schema:
type: integer
ReviewPackId:
name: reviewPack
in: path
required: true
schema:
type: integer
EntitlementKey:
name: entitlementKey
in: path
required: true
schema:
type: string
enum:
- managed_tenant_activation_limit
- review_pack_generation_enabled
responses:
Forbidden:
description: Member or platform user lacks the required capability in an already established scope
NotFound:
description: Wrong plane, non-member scope, or inaccessible record
BusinessStateBlocked:
description: Actor is otherwise authorized, but the workspace is not entitled for the requested action
content:
application/json:
schema:
$ref: '#/components/schemas/EntitlementBlockResponse'
ValidationError:
description: Submitted entitlement settings failed validation
schemas:
WorkspaceEntitlementSettingsCommand:
type: object
required:
- plan_profile
- entitlements
properties:
plan_profile:
type: string
nullable: true
description: Null means use the code-owned default profile
entitlements:
type: array
items:
$ref: '#/components/schemas/EntitlementOverrideInput'
EntitlementOverrideInput:
type: object
required:
- key
properties:
key:
type: string
enum:
- managed_tenant_activation_limit
- review_pack_generation_enabled
override_value:
oneOf:
- type: integer
- type: boolean
nullable: true
rationale:
type: string
nullable: true
ReviewPackGenerationCommand:
type: object
properties:
include_pii:
type: boolean
include_operations:
type: boolean
WorkspaceEntitlementSettingsView:
type: object
required:
- workspace_id
- effective_plan_profile
- entitlements
- primary_action
properties:
workspace_id:
type: integer
effective_plan_profile:
$ref: '#/components/schemas/PlanProfileSummary'
entitlements:
type: array
items:
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
last_changed:
$ref: '#/components/schemas/LastChangedAttribution'
nullable: true
primary_action:
$ref: '#/components/schemas/NextAction'
OnboardingEntitlementView:
type: object
required:
- draft_id
- completion_decision
properties:
draft_id:
type: integer
completion_decision:
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
primary_action:
$ref: '#/components/schemas/NextAction'
blocked_reason:
type: string
nullable: true
SystemWorkspaceEntitlementView:
type: object
required:
- workspace_id
- effective_plan_profile
- entitlements
properties:
workspace_id:
type: integer
effective_plan_profile:
$ref: '#/components/schemas/PlanProfileSummary'
entitlements:
type: array
items:
$ref: '#/components/schemas/WorkspaceEntitlementDecision'
last_changed:
$ref: '#/components/schemas/LastChangedAttribution'
nullable: true
PlanProfileSummary:
type: object
required:
- id
- label
properties:
id:
type: string
label:
type: string
description:
type: string
nullable: true
source:
type: string
enum: [workspace_selection, code_default]
WorkspaceEntitlementDecision:
type: object
required:
- key
- effective_value
- source
- is_blocked
properties:
key:
type: string
enum:
- managed_tenant_activation_limit
- review_pack_generation_enabled
effective_value:
oneOf:
- type: integer
- type: boolean
source:
type: string
enum: [plan_profile_default, workspace_override]
rationale:
type: string
nullable: true
current_usage:
type: integer
nullable: true
remaining_capacity:
type: integer
nullable: true
is_blocked:
type: boolean
block_reason:
type: string
nullable: true
LastChangedAttribution:
type: object
required:
- at
- by
properties:
at:
type: string
format: date-time
by:
type: string
EntitlementBlockResponse:
type: object
required:
- key
- reason
properties:
key:
type: string
reason:
type: string
source:
type: string
enum: [plan_profile_default, workspace_override]
current_usage:
type: integer
nullable: true
effective_value:
oneOf:
- type: integer
- type: boolean
NextAction:
type: object
required:
- label
- kind
properties:
label:
type: string
kind:
type: string
enum:
- save_entitlements
- reset_override
- complete_onboarding
- generate_pack
- export_executive_pack
- regenerate_pack
- open_admin_workspace
action_name:
type: string
nullable: true
url:
type: string
nullable: true

View File

@ -0,0 +1,148 @@
# Data Model: Plans, Entitlements & Billing Readiness
**Date**: 2026-04-27
**Branch**: `247-plans-entitlements-billing-readiness`
## Overview
This slice adds no new table. Persisted truth stays in existing `workspace_settings` rows, while plan defaults and effective entitlement decisions remain derived.
## Persisted Truth
### 1. Workspace Entitlement Settings Aggregate
**Persistence**: Existing `App\Models\WorkspaceSetting` rows
**Ownership**: Workspace-owned
**Scope**: One workspace, no tenant-owned persistence, no system-plane mutation
The slice reuses explicit settings keys under an `entitlements` domain.
| Setting key | Type | Nullable | Validation | Notes |
|-------------|------|----------|------------|-------|
| `entitlements.plan_profile` | string | yes | must match a code-owned plan-profile identifier when present | `null` means use the code-owned default profile |
| `entitlements.managed_tenant_limit_override_value` | int | yes | integer, `>= 0` | Explicit override for onboarding activation limit |
| `entitlements.managed_tenant_limit_override_reason` | string | yes | required when the paired override value is present; trimmed; max 500 chars | Operator-entered rationale shown on admin and system surfaces |
| `entitlements.review_pack_generation_override_value` | bool | yes | boolean | Explicit override for whether new `Generate pack`, `Regenerate`, and `Export executive pack` actions are allowed |
| `entitlements.review_pack_generation_override_reason` | string | yes | required when the paired override value is present; trimmed; max 500 chars | Operator-entered rationale shown on admin and system surfaces |
**Write rules**:
- Saving the section may update several `WorkspaceSetting` rows in one page submission, but each row continues to use the existing `SettingsWriter` audit path.
- Resetting an override clears both the override value and its rationale, returning effective truth to the selected plan profile or code-owned default profile.
- Lowering the managed-tenant limit below current usage does not mutate tenant records; it only changes future activation eligibility.
**Relationships**:
- `workspace_settings.workspace_id` anchors all persisted truth to a workspace.
- `workspace_settings.updated_by_user_id` remains the attribution source for last change metadata.
## Code-Owned Truth
### 2. Workspace Plan Profile Catalog Entry
**Persistence**: none, code-owned
**Ownership**: Product/runtime configuration
**Scope**: first-slice only
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | string | yes | Stable internal identifier stored in `entitlements.plan_profile` |
| `label` | string | yes | Operator-facing plan profile label on settings and system surfaces |
| `description` | string | yes | Concise explanation of what the profile allows |
| `managed_tenant_limit_default` | int | yes | Default active managed-tenant activation limit |
| `review_pack_generation_default` | bool | yes | Default allow/block state for new review-pack generation |
| `is_default` | bool | yes | Exactly one profile is the code-owned fallback when no workspace setting exists |
**Rules**:
- The catalog is intentionally bounded to the first slice and must not grow into a broader entitlement matrix in this feature.
- The catalog is not operator-editable and is not a contract, invoice, or subscription record.
## Derived Truth
### 3. Effective Workspace Entitlement Decision
**Persistence**: none, derived at runtime
**Owner**: bounded `WorkspaceEntitlementResolver`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `workspace_id` | int | yes | Workspace being evaluated |
| `plan_profile_id` | string | yes | Effective profile after applying the code-owned default fallback |
| `key` | string | yes | One of the two first-slice entitlement keys |
| `effective_value` | int or bool | yes | Final value after plan defaults plus any workspace override |
| `source` | string | yes | `plan_profile_default` or `workspace_override` |
| `rationale` | string | no | Override reason when source is `workspace_override`; otherwise optional plan-profile description |
| `current_usage` | int | no | Active managed-tenant count for the limit-based key; `null` for the boolean key |
| `remaining_capacity` | int | no | Derived only for the limit-based key |
| `is_blocked` | bool | yes | Whether the current action should stop for business-state reasons |
| `block_reason` | string | no | Operator-facing explanation used on onboarding and review-pack surfaces when blocked |
| `last_changed_at` | datetime | no | Derived from the most recent entitlement-related `WorkspaceSetting` row if present |
| `last_changed_by` | string | no | Derived actor attribution for settings and system visibility |
**Key catalog**:
| Entitlement key | Value type | Used by |
|-----------------|------------|---------|
| `managed_tenant_activation_limit` | int | `ManagedTenantOnboardingWizard` completion eligibility and summary |
| `review_pack_generation_enabled` | bool | `ReviewPackService`, tenant dashboard widget, review register, tenant review view, review-pack list/detail actions |
**Behavior rules**:
- `managed_tenant_activation_limit` compares `current_usage` to the effective limit and blocks only future onboarding activation.
- `review_pack_generation_enabled=false` blocks new generate, regenerate, and executive-pack export attempts before `ReviewPack` or `OperationRun` creation.
- Existing review-pack downloads and already-generated artifacts remain outside this entitlement decision.
## Supporting Derived View Models
### 4. Workspace Entitlement Section Read Model
**Persistence**: none
**Consumer**: `App\Filament\Pages\Settings\WorkspaceSettings`
Contains:
- effective plan profile label and description
- both entitlement decisions
- editable override values plus rationale inputs
- current managed-tenant usage summary
- last changed attribution for the `entitlements` domain
### 5. System Workspace Entitlement Summary Read Model
**Persistence**: none
**Consumer**: `App\Filament\System\Pages\Directory\ViewWorkspace` and `resources/views/filament/system/pages/directory/view-workspace.blade.php`
Contains:
- read-only effective plan profile label
- both entitlement decisions with source and rationale
- last changed attribution
- current managed-tenant usage summary
## Derived Query Dependencies
| Need | Source | Notes |
|------|--------|-------|
| Active managed-tenant usage | existing tenant/workspace runtime truth | Count active managed tenants for the current workspace only; no persisted counter needed |
| Last change attribution | existing `workspace_settings.updated_by_user_id` and timestamps | Derived from entitlement-related settings rows only |
| Review-pack run creation proof | existing `review_packs` and `operation_runs` behavior | Used only in tests to prove blocked attempts create no new run |
## State Transitions
No new persisted lifecycle state is introduced.
Derived runtime states for the limit-based entitlement:
| State | Trigger | Consequence |
|-------|---------|-------------|
| `within_limit` | `current_usage < effective_value` | Onboarding completion may proceed if all other existing checks pass |
| `at_limit` | `current_usage >= effective_value` | Future onboarding completion is blocked with a truthful reason |
| `over_limit_after_lowering` | Workspace limit is lowered below current usage | Existing tenants stay active; future onboarding completion remains blocked until usage or limit changes |
Derived runtime states for the review-pack entitlement:
| State | Trigger | Consequence |
|-------|---------|-------------|
| `enabled` | effective boolean value is `true` | Existing review-pack start flow proceeds unchanged |
| `disabled` | effective boolean value is `false` | New generate/regenerate/export attempts block before run creation |

View File

@ -0,0 +1,275 @@
# Implementation Plan: Plans, Entitlements & Billing Readiness
**Branch**: `247-plans-entitlements-billing-readiness` | **Date**: 2026-04-27 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Extend the existing workspace settings foundation with one bounded workspace entitlement slice: one code-owned plan-profile catalog, two first-slice entitlement keys, explicit workspace override values plus rationale, and one derived decision path that surfaces effective value, source, rationale, and usage truth.
- Reuse `WorkspaceSetting`, `SettingsResolver`, `SettingsWriter`, `WorkspaceSettings`, the capability registries, `ManagedTenantOnboardingWizard`, `ReviewPackService`, the current review-pack Filament entry points, and the system directory workspace detail page rather than introducing a billing/account domain.
- Hard enforcement remains narrow: block onboarding activation before tenant lifecycle mutation and block review-pack generation before `ReviewPack` or `OperationRun` creation, while preserving existing queued-start UX when entitlement allows execution.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4 + Laravel 12, existing workspace settings stack, `ReviewPackService`, capability registries
**Storage**: PostgreSQL via existing `workspace_settings` persistence; no new table or billing/account model
**Testing**: Pest feature and unit tests via Laravel Sail
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Monorepo Laravel web application in `apps/platform`, using Filament admin and system panels
**Project Type**: web
**Performance Goals**: Resolve entitlement truth from existing settings plus one scoped usage aggregate; perform no new external calls during page render; preserve existing review-pack dedupe and queued-start behavior when allowed
**Constraints**: Keep scope to one workspace plan profile, two entitlement keys, explicit workspace overrides with rationale, read-only system visibility, 404 for non-members/wrong plane, 403 for members missing capability, truthful business-state block for otherwise authorized actors, and no checkout/invoice/provider/subscription lifecycle work
**Scale/Scope**: One new bounded entitlement resolver, one entitlement section on the existing workspace settings page, one onboarding completion gate, one review-pack action family gate, one read-only system summary, and focused Sail/Pest coverage
## Filament v5 / Panel Notes
- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, and Livewire-backed actions. No Livewire v3 assumptions or compatibility work are introduced.
- **Provider registration location**: No panel/provider registration changes are planned. Existing Laravel 12 + Filament provider registration remains in `bootstrap/providers.php`.
- **Global search**: No new globally searchable resource is introduced. Touched existing resources already have dedicated view pages where applicable, and current global-search behavior remains unchanged.
- **Destructive and high-impact actions**: Existing onboarding draft cancellation/deletion remain `->requiresConfirmation()` plus capability enforcement. Any new override-reset action must also require confirmation because it can change runtime access. Entitlement denials themselves are non-destructive business-state blocks, not hidden RBAC failures.
- **Asset strategy**: No new panel or shared assets are planned. Deployment remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets are deployed.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mixed
- **Shared-family relevance**: workspace settings, action gating/helper text, review-pack queued-start UX, read-only system diagnostics
- **State layers in scope**: page, detail
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first on workspace settings, onboarding completion, and review-pack entry points; diagnostics-second on the read-only system page; no raw payload disclosure in the first slice
- **Raw/support gating plan**: capability-gated system-plane diagnostics only; no new raw/support payload section on admin surfaces
- **One-primary-action / duplicate-truth control**: workspace settings remains the only mutation surface for commercial posture; onboarding and review-pack surfaces show only the decision truth needed for the current action; the system directory mirrors resolved truth read-only
- **Handling modes by drift class or surface**: review-mandatory for shared action-family gating and cross-plane wording consistency
- **Repository-signal treatment**: review-mandatory because the slice spans admin and system planes plus an OperationRun-starting workflow family
- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none planned; all first-slice surfaces must consume the same resolved decision object or a thin projection from it
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `WorkspaceSetting`, `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, workspace settings audit logging, `ManagedTenantOnboardingWizard`, `ReviewPackService`, current review-pack action surfaces, `OperationUxPresenter`, `OperationRunLinks`, `Capabilities`, `PlatformCapabilities`, and the system directory workspace detail page
- **Shared abstractions reused**: `SettingsResolver`, `SettingsWriter`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Auth\Capabilities`, `App\Support\Auth\PlatformCapabilities`, `App\Support\Rbac\UiEnforcement`, `ReviewPackService`, `OperationUxPresenter`, `OperationRunLinks`
- **New abstraction introduced? why?**: one bounded `WorkspaceEntitlementResolver` is justified because existing settings helpers resolve individual keys but do not provide plan-profile defaults, override rationale, usage context, or action-ready allow/block truth across multiple surfaces
- **Why the existing abstraction was sufficient or insufficient**: existing settings infrastructure is sufficient for persistence, validation, and audit; it is insufficient for multi-surface commercial decision truth because the same effective result must drive settings readback, onboarding activation gating, review-pack gating, and system diagnostics
- **Bounded deviation / spread control**: hard enforcement for review packs belongs in `ReviewPackService` and onboarding activation belongs in the existing wizard action; UI surfaces may project the same decision but must not create page-local entitlement rules
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes
- **Central contract reused**: existing shared review-pack OperationRun start UX through `ReviewPackService`, `OperationUxPresenter`, and `OperationRunLinks`
- **Delegated UX behaviors**: queued toast, run link, run-enqueued browser event, dedupe messaging, blocked-before-start behavior, tenant/workspace-safe URL resolution, and existing terminal notifications remain on the current shared path when entitlement allows generation
- **Surface-owned behavior kept local**: workspace settings helper text, onboarding completion blocked explanation, and review-pack helper text/disabled state remain local projections of the resolved decision
- **Queued DB-notification policy**: unchanged explicit opt-in only; blocked attempts create no run and therefore no queued notification
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: workspace commercial vocabulary, plan profile labels, entitlement source labels, override rationale, read-only support visibility
- **Neutral platform terms / contracts preserved**: `workspace`, `plan profile`, `managed tenant limit`, `review pack generation`, `override reason`, `source`
- **Retained provider-specific semantics and why**: none; review-pack generation is provider-backed operationally, but the new entitlement vocabulary remains platform-core and provider-neutral
- **Bounded extraction or follow-up path**: none
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS - this slice adds workspace-owned product truth, not new inventory or snapshot semantics.
- Read/write separation: PASS - settings writes stay inside the existing audited settings path; onboarding and review-pack enforcement block before mutation or run creation; high-impact reset actions remain confirmation-protected.
- Graph contract path: PASS - no new Graph calls are introduced; entitlement evaluation is local to settings, workspace counts, and existing review-pack start logic.
- Deterministic capabilities: PASS - capability checks remain registry-backed through `Capabilities` and `PlatformCapabilities`.
- RBAC-UX: PASS - `/admin` and `/system` stay separated; wrong-plane and non-member access remain 404; member-without-capability remains 403; no raw capability strings are introduced.
- Workspace isolation: PASS - workspace membership and workspace context remain required for admin-plane surfaces.
- RBAC-UX destructive confirmation: PASS - existing onboarding destructive actions already confirm, and any new override-reset action must also use `->requiresConfirmation()`.
- RBAC-UX global search: PASS - no new searchable resource or search scope is added.
- Tenant isolation: PASS - onboarding and review-pack surfaces remain tenant-safe; no cross-tenant leakage is introduced.
- Run observability: PASS - review-pack generation keeps the existing `OperationRun` path when allowed, and blocked attempts stop before `OperationRun` creation.
- OperationRun start UX: PASS - shared review-pack start UX is preserved; no local queued-toast composition is planned.
- Ops-UX 3-surface feedback: PASS - existing review-pack feedback stays toast + progress surfaces + terminal notification only when a run exists.
- Ops-UX lifecycle: PASS - no new `OperationRun` transitions are introduced.
- Ops-UX summary counts: N/A - no summary-count shape change is planned.
- Ops-UX guards: N/A - no new OperationRun guard rule is required for the planning slice.
- Ops-UX system runs: N/A - no initiator-null behavior is touched.
- Automation: N/A - no new queued or scheduled workflow family is introduced.
- Data minimization: PASS - no secrets, billing payloads, or provider credentials are persisted for entitlements.
- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit plus feature lanes with explicit commands and local fixtures only.
- Proportionality (PROP-001): PASS - persistence stays in existing workspace settings; only one bounded resolver is added for multi-surface truth.
- No premature abstraction (ABSTR-001): PASS - no new registry, interface, or framework is planned; the profile catalog remains plain code-owned data.
- Persisted truth (PERSIST-001): PASS - no new table or durable artifact is introduced; all new truth stays in existing `workspace_settings` rows.
- Behavioral state (STATE-001): PASS - over-limit and blocked states remain derived behavior, not a new persisted lifecycle model.
- UI semantics (UI-SEM-001): PASS - the design prefers direct mapping from resolved decision truth to UI helper text or summary rows.
- Shared pattern first (XCUT-001): PASS - the design reuses existing settings, audit, review-pack, and capability paths first.
- Provider boundary (PROV-001): PASS - the entitlement vocabulary remains platform-core and provider-neutral.
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - the narrow shape is explicit settings keys plus one resolver and thin UI projections.
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the only added structural element is one resolver, justified below.
- Badge semantics (BADGE-001): PASS - if source or availability is badged later, implementation must reuse existing badge infrastructure or stay text-only; no page-local badge taxonomy is planned.
- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages, widgets, resources, and the existing system detail view.
- Filament-native UI local Blade/Tailwind: PASS - the only custom view touch remains the current system directory Blade view, which must preserve existing Filament visual language.
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - existing singleton settings, guided workflow, action family, and read-only detail surface types remain intact.
- Decision-first operating model (DECIDE-001): PASS - workspace settings remains primary, onboarding and review packs stay contextual decision points, and the system page remains tertiary diagnostics.
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - settings and action surfaces stay operator-first, while the system page is support-platform and read-only.
- UI/UX inspect model (UI-HARD-001): PASS - no duplicate inspect affordances are added.
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - no new parallel action hierarchy is introduced; current action families remain primary where already present.
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - product-facing labels remain narrow and non-billing.
- UI/UX placeholder ban (UI-HARD-001): PASS - no placeholder action groups are planned.
- UI naming (UI-NAMING-001): PASS - labels remain `Plan profile`, `Managed tenant limit`, `Review pack generation`, and `Override reason`.
- Operator surfaces (OPSURF-001): PASS - mutation scope is explicit and system-plane visibility remains read-only.
- Operator surface page contract: PASS - the spec already defines the required page and action contracts.
- Filament UI Action Surface Contract: PASS - touched surfaces already have action contracts or exemptions; the plan preserves them while adding entitlement truth.
- Filament UI UX-001 (Layout & IA): PASS - no new page shell or alternate layout is planned.
- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - workspace settings remains the primary configuration surface; review-pack generation remains the primary reporting action where already present.
- UI review workflow: PASS - guardrail, shared-family, and exception posture remain explicit in this plan.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for the bounded resolver and profile defaults; `Feature` for workspace settings, onboarding, review-pack entry surfaces, and the system directory page
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: the business truth is a deterministic resolver plus existing Filament/Livewire action paths; browser and heavy-governance coverage would add cost without proving extra risk for this bounded slice
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
- **Fixture / helper / factory / seed / context cost risks**: local workspace, workspace membership, active managed-tenant count, tenant review/review-pack context, and platform-user fixtures only
- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and scope helpers with opt-in entitlement fixtures
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native relief for workspace settings and onboarding; shared-detail-family coverage for review-pack entry points; one read-only system detail assertion for the system plane
- **Closing validation and reviewer handoff**: rerun the exact targeted Sail/Pest commands above and verify 404/403/business-state semantics separately, verify blocked review-pack attempts create no `OperationRun`, and verify lowered limits do not mutate existing tenants
- **Budget / baseline / trend follow-up**: none expected beyond normal feature-local growth
- **Review-stop questions**: lane fit, hidden fixture cost, service-level bypass risk on review-pack generation, and cross-plane wording drift
- **Escalation path**: none
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the testing cost stays local to one resolver and four existing surface families; no new heavy family or platform-wide harness is introduced
## Project Structure
### Documentation (this feature)
```text
specs/247-plans-entitlements-billing-readiness/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── workspace-entitlements-foundation.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── Reviews/ReviewRegister.php
│ │ │ ├── Settings/WorkspaceSettings.php
│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php
│ │ ├── Resources/
│ │ │ ├── ReviewPackResource.php
│ │ │ ├── ReviewPackResource/Pages/ListReviewPacks.php
│ │ │ ├── ReviewPackResource/Pages/ViewReviewPack.php
│ │ │ ├── TenantReviewResource.php
│ │ │ └── TenantReviewResource/Pages/ViewTenantReview.php
│ │ ├── System/Pages/Directory/ViewWorkspace.php
│ │ └── Widgets/Tenant/TenantReviewPackCard.php
│ ├── Models/WorkspaceSetting.php
│ ├── Services/
│ │ ├── ReviewPackService.php
│ │ ├── Settings/SettingsResolver.php
│ │ ├── Settings/SettingsWriter.php
│ │ └── Entitlements/WorkspaceEntitlementResolver.php # likely new bounded service
│ ├── Support/
│ │ ├── Auth/Capabilities.php
│ │ ├── Auth/PlatformCapabilities.php
│ │ └── Settings/SettingsRegistry.php
├── tests/
│ ├── Feature/
│ └── Unit/
└── resources/views/filament/system/pages/directory/view-workspace.blade.php
```
**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded entitlement resolver and changes limited to existing settings, onboarding, review-pack, and system-directory surfaces plus focused Pest coverage.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New bounded entitlement resolver | Multiple existing surfaces need the same effective plan-default versus override decision, source attribution, rationale, and usage truth | Surface-local checks in `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, and the review-pack action family would drift immediately and duplicate business-state wording |
## Proportionality Review
- **Current operator problem**: The product cannot currently answer, in one auditable path, whether a workspace may activate another managed tenant or generate a review pack, nor can it show why a manual override exists.
- **Existing structure is insufficient because**: raw settings rows and direct capability checks do not produce plan-profile defaults, source attribution, override rationale, usage context, or reusable allow/block truth for multiple surfaces.
- **Narrowest correct implementation**: persist only workspace-selected plan profile and explicit override values plus rationale through existing `WorkspaceSetting` rows, keep plan defaults code-owned, and add one bounded resolver that derives effective decisions for the four affected surface families.
- **Ownership cost created**: one small default catalog plus one resolver require focused unit tests and wording discipline across settings, onboarding, review-pack, and system visibility.
- **Alternative intentionally rejected**: a new `Plan`, `Subscription`, `CustomerAccount`, or broad entitlement matrix domain was rejected because the spec only needs workspace-owned current-release truth for two entitlement keys.
- **Release truth**: current-release truth
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/research.md`
Goals:
- Confirm the narrowest reuse of the existing workspace settings stack for plan profile and override persistence.
- Confirm the exact service and page-level enforcement points that prevent onboarding activation or review-pack run creation before mutation.
- Confirm how to preserve existing review-pack OperationRun UX while inserting entitlement checks ahead of run creation.
- Confirm system/admin plane separation and read-only directory visibility requirements for support users.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
Design focus:
- Represent workspace commercial truth with explicit `WorkspaceSetting` keys under an `entitlements` domain rather than a new model family.
- Keep the plan-profile catalog code-owned and small, and keep the new logic surface to one bounded `WorkspaceEntitlementResolver` rather than a registry or framework.
- Extend `WorkspaceSettings` with one entitlement section that edits plan profile and override/rationale pairs using existing settings write and reset patterns.
- Use the same decision path in `ManagedTenantOnboardingWizard` completion state and in review-pack generation entry surfaces, with hard enforcement centralized in `completeOnboarding()` and `ReviewPackService` before any mutation or run creation occurs.
- Extend `App\Filament\System\Pages\Directory\ViewWorkspace` plus its Blade view with a read-only entitlement summary instead of adding a second mutation plane.
## Phase 1 — Agent Context Update
After Phase 1 artifacts are generated, update Copilot context from the completed plan:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created later by `/speckit.tasks`)
- Add bounded entitlement settings definitions and the plan-profile default map without creating new persistence tables.
- Implement `WorkspaceEntitlementResolver` to merge code-owned plan defaults, workspace overrides, override rationale, and current usage for the managed-tenant limit.
- Extend `WorkspaceSettings` with a plan profile selector, two override controls, rationale inputs, resolved-source helper text, and confirmed reset actions.
- Gate onboarding completion in `ManagedTenantOnboardingWizard` using the shared entitlement decision and preserve existing confirmation plus audit semantics.
- Gate every current review-pack generate/regenerate/export entry point through the shared decision, with service-level enforcement in `ReviewPackService` to prevent bypass and preserve existing OperationRun UX when allowed.
- Add a read-only entitlement summary to the system directory workspace detail page and preserve system-plane-only visibility.
- Add focused Sail/Pest unit and feature coverage for resolver behavior, settings save/reset, onboarding blocking, review-pack no-run blocking, and system visibility.
## Constitution Check (Post-Design)
Re-check result: PASS. The design keeps persistence inside existing workspace settings, adds only one bounded resolver, preserves Filament v5 + Livewire v4 surfaces, keeps panel provider registration unchanged in `bootstrap/providers.php`, leaves global search and asset strategy unchanged, enforces 404/403 semantics separately from business-state blocks, and preserves existing review-pack `OperationRun` UX by gating before run creation instead of replacing shared run infrastructure.
## Guardrail Close-Out
- Outcome: keep
- Livewire v4.0+ compliance remained intact across the touched Filament v5 pages, widgets, resources, and Livewire-backed actions.
- Provider registration location remains unchanged in `bootstrap/providers.php`; no panel registration changes were needed.
- Global-search scope remains unchanged; no new searchable resources were introduced.
- Destructive actions remain confirmation-protected where applicable. The existing `regenerate` review-pack action keeps its confirmation requirement, while the new entitlement denials are non-destructive business-state blocks enforced before `ReviewPack` or `OperationRun` creation.
- Asset strategy remains unchanged. No new Filament assets were added; deploy behavior still uses `cd apps/platform && php artisan filament:assets` when registered assets are shipped.
- Validation lanes completed:
- Targeted unit entitlement lane: completed earlier in the feature implementation loop for `WorkspaceEntitlementResolver` and `WorkspacePlanProfileCatalog`.
- Targeted settings and onboarding feature lane: completed earlier in the feature implementation loop for workspace settings and managed-tenant onboarding gating.
- Targeted review-pack and system-directory feature lane: `31 passed (133 assertions)` on the final post-format run.
- Browser smoke note: attempted because changed surfaces were user-facing, but classified as environment-blocked. The integrated browser could reach `/admin` with a synthetic session cookie, but tenant-panel route resolution stayed on 404s and the system panel continued redirecting to `/system/login`, so no reliable PASS/FAIL smoke result could be established.
- Document-in-feature note: shared review-pack blocked-state wording remains centralized in the resource/service helper path. Direct tooltip introspection on the wrapped recordless Filament header action was not stable proof in the test harness, so the final assertion strategy validates the shared tooltip helper text and disabled UI state instead of comparing the wrapped action tooltip object directly.

View File

@ -0,0 +1,99 @@
# Quickstart: Plans, Entitlements & Billing Readiness
**Date**: 2026-04-27
**Branch**: `247-plans-entitlements-billing-readiness`
This quickstart is the intended reviewer flow after implementation. It stays bounded to the first slice described in the spec.
## Prerequisites
1. Start the local platform stack.
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
2. Ensure one workspace member has `workspace_settings.manage`, one workspace owner can complete onboarding, one reporting operator can manage review packs, and one platform user has `platform.directory.view`.
3. Seed or factory-create:
- one workspace with no entitlement overrides
- one workspace at or above the managed-tenant activation limit
- one managed-tenant onboarding draft in the target workspace
- one tenant and one tenant review capable of review-pack generation
## Scenario 1: Configure workspace commercial truth
1. Open `/admin/settings/workspace` as a workspace manager.
2. Confirm the page shows a `Plan profile` selector and the two first-slice entitlement controls.
3. Save a plan profile with no overrides.
4. Confirm the page shows:
- the effective managed-tenant limit
- whether review-pack generation is enabled
- source labels pointing to the plan profile
- current managed-tenant usage
5. Add an explicit override and rationale for one entitlement.
6. Save again and confirm the effective source switches to workspace override and the rationale is visible.
7. Reset the override and confirm the effective value returns to the plan-profile default.
## Scenario 2: Gate managed-tenant onboarding activation
1. Open `/admin/onboarding/{onboardingDraft}` for a workspace that is within limit.
2. Confirm the completion step shows the current active managed-tenant usage and allows `Complete onboarding`.
3. Repeat with a workspace at or above its limit.
4. Confirm:
- the completion action remains visible for an otherwise authorized actor
- the action explains why onboarding is blocked
- no tenant activation occurs
5. Repeat with a workspace override that raises the limit and confirm the source label changes to workspace override.
## Scenario 3: Gate review-pack generation without creating a run
1. Use a workspace where review-pack generation is enabled.
2. Trigger generation from each current entry family:
- tenant dashboard review-pack card
- review register export action
- tenant review detail export action
- review-pack list header generate action
- review-pack detail regenerate action
3. Confirm the current queued-start UX remains unchanged when allowed.
4. Switch to a workspace where review-pack generation is disabled.
5. Repeat the same actions and confirm:
- each surface shows the same entitlement-based reason
- no new `ReviewPack` row is created
- no new `OperationRun` row is created
- existing `View` and `Download` access to already-generated review packs still works under current artifact permissions
## Scenario 4: Inspect the read-only system summary
1. Open `/system/directory/workspaces/{workspace}` as a platform user with `platform.directory.view`.
2. Confirm the page shows:
- the effective plan profile
- both entitlement decisions
- source labels
- override rationale when present
- last changed attribution
3. Confirm there are no mutation controls on the system page.
## RBAC and Plane Semantics Checks
1. Access admin-plane entitlement surfaces as a non-member or wrong-workspace actor and confirm 404.
2. Access the same surfaces as a workspace member lacking the relevant capability and confirm 403.
3. Access the action as an otherwise authorized actor whose workspace is not entitled and confirm a truthful business-state block instead of 403 or 404.
4. Access the system page as an admin-plane actor and confirm wrong-plane behavior does not leak workspace entitlement truth.
## Targeted Validation Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Out of Scope Confirmations
While validating this slice, confirm that the implementation does not add or imply:
- checkout or invoice UI
- payment-provider credentials or adapters
- customer-account, subscription, or contract records
- trial, grace-period, suspension, or renewal lifecycle states
- broader entitlement matrices outside the two first-slice keys

View File

@ -0,0 +1,76 @@
# Research: Plans, Entitlements & Billing Readiness
**Date**: 2026-04-27
**Branch**: `247-plans-entitlements-billing-readiness`
## Decision 1: Persist workspace commercial truth in existing `workspace_settings`
- **Decision**: Store the first-slice workspace commercial truth through explicit `WorkspaceSetting` keys in an `entitlements` domain, reusing `SettingsRegistry`, `SettingsResolver`, and `SettingsWriter`.
- **Rationale**: The repo already has validated, audited workspace-scoped settings persistence and a singleton workspace settings page. Reusing that path keeps the slice narrow, keeps audit behavior consistent, and avoids inventing a billing or account persistence model.
- **Alternatives considered**:
- New `plans`, `subscriptions`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope.
- One nested JSON blob for all entitlement fields: rejected because explicit keys better fit existing page save/reset patterns, validation, and audit attribution.
## Decision 2: Keep the plan-profile catalog code-owned and bounded
- **Decision**: Represent plan-profile defaults as a small code-owned catalog with one code-owned default profile and bounded named profile identifiers, not as operator-editable data.
- **Rationale**: The first slice needs deterministic defaults when no workspace-specific selection exists, but it does not need a management UI, a billing backoffice, or a pricing model. Code-owned defaults are the narrowest current-release truth.
- **Alternatives considered**:
- Database-backed plan catalog: rejected because there is no current product workflow for editing plans.
- External billing/provider sync: rejected because the spec explicitly excludes payment providers and subscription lifecycle work.
## Decision 3: Introduce one bounded `WorkspaceEntitlementResolver`
- **Decision**: Add one bounded resolver that projects effective entitlement decisions from plan defaults, workspace overrides, override rationale, and current usage.
- **Rationale**: Existing settings helpers resolve raw setting values but do not answer the operator question the feature actually needs: what is the effective value, where did it come from, why is it overridden, what is current usage, and may this action proceed now?
- **Alternatives considered**:
- Rebuild the logic independently on `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, and each review-pack entry surface: rejected because it would immediately create wording drift and inconsistent enforcement.
- Extend `SettingsResolver` to absorb entitlement-specific usage logic: rejected because that would over-specialize a generic settings utility.
## Decision 4: Keep hard enforcement at the existing mutation and run-start boundaries
- **Decision**: Enforce onboarding entitlement in `ManagedTenantOnboardingWizard::canCompleteOnboarding()` and `completeOnboarding()`, and enforce review-pack entitlement inside `ReviewPackService::generate()` and `generateFromReview()`, while UI surfaces render the same decision state ahead of action execution.
- **Rationale**: Review-pack generation already fans out through several Filament actions, but those surfaces converge on `ReviewPackService`. Putting hard enforcement at the service boundary prevents bypass. Onboarding completion is already owned by the wizard page and should remain there.
- **Alternatives considered**:
- UI-only disabling on each action surface: rejected because it would not protect direct Livewire action execution.
- A second cross-cutting action framework for entitlement checks: rejected because the slice only needs one bounded business decision path, not a new platform hook system.
## Decision 5: Preserve explicit RBAC versus business-state semantics
- **Decision**: Keep 404 for non-members and wrong-plane actors, keep 403 for members missing capability, and model entitlement denial as a visible business-state block for otherwise authorized actors.
- **Rationale**: The repo constitution already distinguishes membership isolation from capability denial. Entitlements are neither. Treating entitlement denial as 403 or 404 would erase the operator-visible truth this slice exists to provide.
- **Alternatives considered**:
- Hide blocked actions completely: rejected because the spec requires operator-visible rationale.
- Return 403 for entitlement denial: rejected because it conflates product policy with authorization.
## Decision 6: Keep system visibility read-only on the existing workspace directory page
- **Decision**: Expose the resolved plan profile, entitlement values, source, and last-changed attribution on `App\Filament\System\Pages\Directory\ViewWorkspace` and its existing Blade view, with no system-plane mutation control.
- **Rationale**: Platform support needs visibility into current workspace commercial truth, but introducing a second mutation plane would immediately create duplicate truth and cross-plane drift.
- **Alternatives considered**:
- New system resource or admin-like settings page: rejected because the first slice is explicitly read-only on `/system`.
- Linking support users back to `/admin` without any local visibility: rejected because it keeps support dependent on plane switching and tribal knowledge.
## Decision 7: Keep review-pack shared OperationRun UX unchanged when entitled
- **Decision**: Preserve existing `OperationUxPresenter`, `OperationRunLinks`, dedupe behavior, and queued background generation semantics whenever review-pack generation is entitled.
- **Rationale**: The feature is about whether generation is allowed, not about rebuilding review-pack run UX. The right insertion point is before run creation, not inside the shared run lifecycle.
- **Alternatives considered**:
- Localize new review-pack blocked/queued UX per surface: rejected because the repo already centralizes the run-start UX.
- Add a new entitlement-specific notification family: rejected because blocked attempts should stop quietly with truthful local action messaging and no new run.
## Decision 8: Prove the slice with focused Sail/Pest unit and feature coverage only
- **Decision**: Cover the new resolver/profile defaults with unit tests and prove settings, onboarding, review-pack gating, and system visibility with focused feature tests run through Sail.
- **Rationale**: The business risk is decision correctness and action enforcement, not browser layout or broad workflow orchestration. Unit plus feature lanes are enough to prove the slice without dragging in heavy-governance or browser cost.
- **Alternatives considered**:
- Browser tests: rejected because no browser-only interaction or layout risk is introduced.
- Heavy-governance suite expansion: rejected because the scope is bounded and local to existing surfaces.
## Decision 9: Leave Filament panel registration, global search, and assets unchanged
- **Decision**: Do not add panels, providers, global-search resources, or new Filament asset registrations as part of this slice.
- **Rationale**: The feature is workspace-first entitlement truth inside existing admin and system surfaces. Filament infrastructure changes would widen scope without helping the first release.
- **Alternatives considered**:
- New commercial panel or system sub-panel: rejected because the slice reuses current surfaces.
- Asset-backed custom billing UI components: rejected because native Filament components and the existing system Blade page are sufficient.

View File

@ -0,0 +1,327 @@
# Feature Specification: Plans, Entitlements & Billing Readiness
**Feature Branch**: `247-plans-entitlements-billing-readiness`
**Created**: 2026-04-27
**Status**: Draft
**Input**: User description: "Update the existing candidate as a workspace-first entitlement foundation. Keep the candidate title recognizable, but scope the first slice to one workspace-owned plan profile, explicit entitlement overrides with rationale, operator-visible decision truth, and first enforcement on managed-tenant onboarding activation plus review-pack generation. Reuse existing workspace settings, capability registries, onboarding, review-pack, and system-directory surfaces. Do not assume checkout, invoices, payment providers, proration, or a separate customer-account domain."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot has no product-owned workspace truth for what a workspace is commercially allowed to do, so plan availability and limit decisions live in founder memory, ad hoc support explanations, or scattered future guesses instead of one auditable runtime decision path.
- **Today's failure**: Authorized operators can reach activation or review-pack entry points without any product-side entitlement explanation, while the product cannot truthfully answer why a feature is unavailable, whether a workspace is over its allowed managed-tenant count, or which manual override currently applies.
- **User-visible improvement**: Workspace admins can set one plan profile and explicit override values once, and operators then see a calm but truthful allow-or-block reason directly on onboarding activation, review-pack generation, and system support views.
- **Smallest enterprise-capable version**: Extend the existing workspace settings foundation with one workspace plan profile, two first-slice entitlement keys, explicit workspace override values with rationale, one derived entitlement resolver, read-only system visibility, and server-enforced checks on managed-tenant onboarding activation plus review-pack generation.
- **Explicit non-goals**: No customer-account domain, no subscription lifecycle engine, no invoices, no checkout, no payment provider integration, no proration, no public pricing surface, no trial/grace/suspension workflow, no seat or report-retention matrix, no tenant/user/export/deletion entitlement spread beyond the two first-slice checks, and no platform-wide backoffice billing framework.
- **Permanent complexity imported**: One bounded plan-profile catalog, one bounded first-slice entitlement key catalog, one derived entitlement decision/resolution layer, one new entitlement section on the existing workspace settings page, one read-only system summary, and focused unit plus feature coverage.
- **Why now**: This candidate is upstream of customer lifecycle communication, demo and trial readiness, and broader commercial readiness. Adjacent self-service and support candidates are already specced, and they need a truthful entitlement source before later commercial workflows can remain narrow.
- **Why not local**: The same commercial truth must drive workspace settings, onboarding activation, review-pack generation, and system operator visibility. Local conditionals on each surface would drift immediately and recreate the current manual explanation problem.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New semantic axis, foundation-sounding theme, and multi-surface touchpoint. Defense: this slice is explicitly limited to existing workspace settings, two entitlement keys, two runtime enforcement points, and one read-only system summary. It does not introduce a customer-account model, payment flow, or broad plan matrix.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/settings/workspace` on `App\Filament\Pages\Settings\WorkspaceSettings`
- `/admin/onboarding/{onboardingDraft}` on `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`
- `/admin/reviews` on `App\Filament\Pages\Reviews\ReviewRegister`
- existing review-pack generation entry surfaces on the tenant dashboard, tenant review detail pages, and review-pack registry/detail surfaces backed by `App\Services\ReviewPackService`
- `/system/directory/workspaces/{workspace}` on `App\Filament\System\Pages\Directory\ViewWorkspace`
- **Data Ownership**: Current-release entitlement truth is workspace-owned and stored through existing `WorkspaceSetting` records plus code-owned plan-profile defaults. Managed-tenant counts, review-pack runs, and existing artifacts remain derived from current workspace and tenant truth. No new billing/account/customer-subscription table is introduced.
- **RBAC**: Workspace membership remains the isolation boundary for `/admin`. `Capabilities::WORKSPACE_SETTINGS_VIEW` and `Capabilities::WORKSPACE_SETTINGS_MANAGE` govern configuration visibility and mutation. `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE` and `Capabilities::REVIEW_PACK_MANAGE` remain the execution capabilities on the first enforcement surfaces. `PlatformCapabilities::DIRECTORY_VIEW` governs read-only system visibility. Non-members and wrong-plane actors receive 404. Members missing capability receive 403. Members with capability but without entitlement receive a truthful business-state block rather than a hidden surface or false 403.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice is workspace-owned and does not introduce a tenantless cross-tenant collection that filters tenant records.
- **Explicit entitlement checks preventing cross-tenant leakage**: N/A - existing tenant access rules remain authoritative. The new workspace entitlement truth never reveals tenant-owned records outside the current workspace or platform directory visibility.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: configuration settings, status messaging, action gating/helper text, review-pack start UX, read-only system diagnostics
- **Systems touched**: `WorkspaceSetting`, `SettingsResolver`, `SettingsWriter`, workspace audit logging, canonical capability registries, managed-tenant onboarding activation, review-pack generation entry surfaces, and the system directory workspace detail view
- **Existing pattern(s) to extend**: existing workspace settings update/reset + audit pattern, existing capability-gated Filament actions, existing review-pack queued-start UX, and existing system directory detail summaries
- **Shared contract / presenter / builder / renderer to reuse**: `App\Services\Settings\SettingsResolver`, `App\Services\Settings\SettingsWriter`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Auth\Capabilities`, `App\Support\Auth\PlatformCapabilities`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`; one new bounded `WorkspaceEntitlementResolver` (or equivalently named resolver) is introduced because there is no existing shared commercial-decision path
- **Why the existing shared path is sufficient or insufficient**: The existing settings stack is already sufficient for workspace-owned persistence, validation, and audit. It is insufficient for runtime commercial truth because multiple surfaces need the same resolved plan-default versus override decision, source attribution, and usage context without duplicating business rules.
- **Allowed deviation and why**: none. The feature must not create page-local entitlement checks, local billing copy, or a second support-facing commercial summary.
- **Consistency impact**: Plan profile labels, entitlement source labels, blocked copy, override rationale labels, and current-usage summaries must mean the same thing on workspace settings, onboarding activation, review-pack generation, and the system directory page.
- **Review focus**: Reviewers must verify that the same resolved decision object drives all first-slice surfaces, that no surface invents its own commercial vocabulary, and that existing review-pack operation UX remains unchanged when entitlement allows execution.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes
- **Shared OperationRun UX contract/layer reused**: Existing review-pack generation continues to use `App\Services\ReviewPackService`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`. Managed-tenant onboarding activation remains a confirmed, audited page mutation and does not introduce a new `OperationRun`.
- **Delegated start/completion UX behaviors**: When review-pack generation is entitled, queued toast, `Open operation` link, dedupe handling, browser event dispatch, and terminal lifecycle notifications stay on the existing shared path. When generation is not entitled, no run is created and no queued or terminal notification is emitted.
- **Local surface-owned behavior that remains**: Workspace settings save and reset actions, onboarding completion helper text and callouts, and blocked reason presentation on review-pack entry surfaces remain surface-owned.
- **Queued DB-notification policy**: unchanged. The feature adds no new queued DB notification behavior and no new review-pack run type.
- **Terminal notification path**: central lifecycle mechanism for existing review-pack generation only
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary touched. The new plan and entitlement vocabulary is platform-core and must remain provider-neutral even when it gates provider-backed review-pack generation.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Workspace settings entitlement section | yes | Native Filament + existing singleton settings page | settings, status messaging, helper text | page, section, resolved settings summary | no | Extends the existing singleton page instead of creating a new admin surface |
| Managed tenant onboarding completion gate | yes | Native Filament wizard + existing completion action | action gating, callouts, helper text | wizard step, confirmation action | no | Reuses the existing completion step and keeps onboarding calm |
| Review-pack generation entry family | yes | Native Filament widget/resource actions | operation start gating, helper text, queued-start UX | widget action, detail action, list/header action | no | One entitlement decision must cover all current `Generate pack`, `Regenerate`, and `Export executive pack` entry points |
| System directory workspace entitlement summary | yes | Native Filament system detail page | read-only diagnostics, support/commercial visibility | detail section/card | no | Read-only only in the first slice |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | Primary Decision Surface | Workspace owner or manager decides what this workspace is commercially allowed to do | Current plan profile, effective entitlements, source, rationale, and current usage summary | Audit attribution and related affected surfaces | Primary because this is the one configuration point that changes later runtime behavior | Configuration first, enforcement second | Removes ad hoc founder-only plan decisions and scattered explanations |
| Managed tenant onboarding completion gate | Primary Decision Surface | Operator decides whether the tenant may be activated now | Activation eligibility, current active managed-tenant usage versus allowed limit, and the one next action | Existing verification and bootstrap diagnostics remain secondary | Primary because onboarding completion is the actual high-impact decision point for tenant activation | Keeps commercial truth inside the onboarding workflow instead of forcing cross-page lookup | Prevents silent failure or false calmness at the moment of activation |
| Review-pack generation entry family | Secondary Context Surface | Operator decides whether to start or retry review-pack generation from the current tenant or review context | Allow-or-block state, source, and the next step when blocked | Existing operation detail, review-pack status, and artifact truth stay secondary | Not primary because the surface exists to continue reporting/review workflows, not to manage commercial posture | Stays inside the existing reporting workflow | Avoids back-and-forth to support or settings just to understand why generation is blocked |
| System directory workspace entitlement summary | Tertiary Evidence / Diagnostics Surface | Platform operator or support user verifies what entitlement truth is active for a workspace | Resolved plan profile, effective entitlement values, source, and last changed attribution | Existing tenant counts, recent runs, and admin workspace link stay supporting context | Not primary because system operators inspect rather than change commercial posture in this slice | Supports support and escalation workflows without adding a second mutation plane | Avoids separate manual lookup across admin pages, audit logs, and founder memory |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | operator-MSP | Plan profile, two first-slice entitlements, current usage, source, and saveable override inputs | Last modified attribution and reset state | none | `Save` | Any future billing/account metadata and broader commercial lifecycle fields stay out of scope | The same resolved values shown here are reused on downstream surfaces instead of reworded locally |
| Managed tenant onboarding completion gate | operator-MSP | Activation eligibility, active managed-tenant count, limit source, and concise blocked reason | Verification and operability detail already present on the wizard | none | `Complete onboarding` or a clear blocked explanation when unavailable | Broader commercial configuration stays off the onboarding page | The onboarding step shows only the one commercial fact needed for activation and does not restate full settings data |
| Review-pack generation entry family | operator-MSP | Review-pack generation availability, source, and the next step when blocked | Existing queued/run state and artifact status remain secondary | none | The in-context start action: `Generate pack`, `Regenerate`, or `Export executive pack` when allowed | Full workspace plan configuration stays off these surfaces | The same entitlement reason object is rendered consistently across the widget, Review Register, tenant review detail, and review-pack resource actions |
| System directory workspace entitlement summary | support-platform | Read-only plan profile, effective entitlement values, source, and last changed attribution | Tenant counts, recent runs, and admin workspace link | none | `Open admin workspace` | Mutation controls and raw settings payload stay hidden in the system plane | The system page mirrors resolved truth only and does not become a second editable source |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | Config / Settings / Singleton | Workspace configuration page | Save or reset an entitlement-related setting | In-page settings section | forbidden | Per-field reset actions and helper text stay inside the section | None beyond confirmed reset of overrides if added | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Plan profile / Entitlements | Effective values, source, rationale, and usage | Singleton-settings exception already exists and remains bounded |
| Managed tenant onboarding completion gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because the workspace is over limit | In-page completion section | forbidden | Back-navigation and linked-tenant navigation stay secondary | Existing `Cancel draft` and `Delete draft` header actions remain destructive and confirmation-protected | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context plus linked tenant identity | Onboarding entitlement | Activation eligibility and current limit usage | Guided-workflow exception remains valid |
| Review-pack generation entry family | Contextual action family | Tenant widget plus Review Register, tenant review detail, and tenant-scoped review-pack registry/detail actions | Start or retry review-pack generation when allowed | Explicit action on the current tenant or review context | mixed - forbidden on widget/detail actions, existing clickable row remains on the registry | Existing `View` and `Download` actions remain secondary and outside the entitlement gate; `Generate pack`, `Regenerate`, and `Export executive pack` are the only in-scope gated actions | Existing expire or similar destructive actions remain where the current resource contract places them | current tenant dashboard, `/admin/reviews`, tenant review detail, and tenant review-pack registry | current tenant dashboard, tenant review detail, and tenant review-pack registry/view | Active workspace, active tenant, and current review or review-pack context | Review pack generation entitlement | Allowed or blocked state and why | Grouped-action family exception documented here to avoid divergent gating |
| System directory workspace entitlement summary | System / Detail / Diagnostics | Read-only workspace detail page | Inspect current workspace commercial truth | Dedicated workspace detail page | forbidden | Existing admin-workspace and runs links stay secondary | none | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity and tenant count | Workspace entitlement summary | Effective plan profile, source, and last change attribution | Read-only system diagnostic surface |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | Workspace owner or manager | Set or clear the workspace commercial posture for the first slice | Singleton settings page | What is this workspace allowed to do, and do I need to override the default? | Current plan profile, managed-tenant activation limit, review-pack generation availability, source, rationale, and current usage | Last modified attribution and reset availability | commercial profile, entitlement source, current usage | TenantPilot only | Save, Reset override | none |
| Managed tenant onboarding completion gate | Workspace owner completing managed tenant onboarding | Decide whether onboarding may be completed now | Guided workflow step | Can I activate this managed tenant under the current workspace entitlements? | Active managed-tenant usage, allowed limit, source, blocked reason, and existing completion prerequisites | Existing verification/operability diagnostics | onboarding readiness, entitlement eligibility | TenantPilot only for completion state; Microsoft tenant only for existing provider actions already on the page | Complete onboarding | Cancel draft, Delete draft |
| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether to start or retry review-pack generation | Widget/action family | Can I start `Generate pack`, `Regenerate`, or `Export executive pack` from this workspace under the current entitlements? | Review-pack generation availability, source, and blocked reason | Existing run state, artifact truth, and review status; `View` and `Download` stay outside the entitlement decision | entitlement availability, run state, artifact status | TenantPilot only until the existing generation flow starts; then existing review-pack run semantics apply | Generate pack, Export executive pack, Regenerate | Existing destructive actions remain unchanged and out of scope |
| System directory workspace entitlement summary | Platform support or operations user | Verify workspace commercial truth without switching planes | Read-only detail page | What plan and overrides are currently in effect for this workspace? | Resolved plan profile, entitlement values, source, and last changed attribution | Recent runs, tenant counts, and admin workspace link | commercial profile, entitlement source | none | Open admin workspace | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes - workspace-owned plan profile and explicit entitlement override truth become current-release business truth, but they are stored in the existing workspace settings mechanism rather than a new table
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded resolver for effective entitlement decisions across multiple surfaces
- **New enum/state/reason family?**: yes - one bounded plan-profile identifier set, one bounded first-slice entitlement key catalog, and a small entitlement source vocabulary
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators and support users cannot truthfully explain whether a workspace is allowed to activate more managed tenants or generate review packs, and the product currently has no auditable commercial decision path.
- **Existing structure is insufficient because**: Generic settings storage alone does not provide a consistent runtime decision path, source attribution, usage context, or blocked-action explanation across onboarding, reporting, and system support surfaces.
- **Narrowest correct implementation**: Keep persistence inside existing workspace settings, limit the catalog to two entitlement keys, derive current usage from existing workspace and artifact truth, add one bounded resolver, and gate only two existing runtime actions in the first slice.
- **Ownership cost**: One new resolver and small catalog need ongoing tests and vocabulary review. One settings section and one system summary need ongoing UX discipline. No new tables, background workflows, or billing-provider seams are introduced.
- **Alternative intentionally rejected**: A new `Plan`, `Subscription`, or `CustomerAccount` model family was rejected because the repo has no current account or payment domain, and the first slice only needs workspace-owned runtime entitlement truth.
- **Release truth**: current-release truth with explicit follow-up candidates for later billing lifecycle work
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: Unit coverage proves plan-profile defaults, override merging, source attribution, and current-usage calculation. Focused feature coverage proves the existing Filament settings page, onboarding completion gate, review-pack generation gate, and system directory visibility without adding browser or heavy-governance scope.
- **New or expanded test families**: one new `Entitlements` unit family plus focused feature coverage for workspace settings, onboarding, review-pack generation, and system-directory visibility
- **Fixture / helper cost impact**: Add only workspace, membership, active managed-tenant, review-pack-capable tenant/review, and platform-user fixtures required to prove the first-slice decisions. Avoid new browser harnesses, payment-provider mocks, or broad commercial seeds.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, monitoring-state-page, shared-detail-family
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the workspace settings page and onboarding wizard. Review-pack gating also needs monitoring-state assertions to prove that blocked attempts do not create a run, while the system directory page needs one read-only platform-plane detail assertion.
- **Reviewer handoff**: Reviewers must confirm that entitlement denials are distinct from 404 and 403 RBAC outcomes, blocked review-pack actions never create an `OperationRun`, lowered limits do not mutate existing tenants or packs, and the same reason text is reused across all first-slice surfaces.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
## Scope Boundaries *(required for this slice)*
### In Scope
- One workspace-owned plan profile selected and audited through the existing workspace settings surface
- Exactly two first-slice entitlement keys:
- active managed-tenant activation limit for onboarding completion
- review-pack generation availability for existing `Generate pack`, `Regenerate`, and `Export executive pack` entry points
- Explicit workspace override values for those keys with operator-entered rationale and reset-to-default behavior
- One derived effective entitlement decision path showing value, source, rationale, and current usage where applicable
- Read-only system-plane visibility of the resolved workspace commercial truth on the existing workspace directory page
### Non-Goals
- Trial, grace, suspension, cancellation, or renewal lifecycle states
- Checkout, invoices, payment collection, taxes, proration, or billing-provider adapters
- A separate customer account, subscription, contract, or offer domain model
- Broader entitlement spread across seats, exports, retention, user counts, support SLAs, or feature flags
- Platform-plane mutation or emergency override controls in the first slice
- Customer-facing plan self-service or website pricing integration
## Assumptions
- Existing `WorkspaceSetting`, `SettingsResolver`, and `SettingsWriter` are sufficient persistence and audit primitives for the current-release commercial truth.
- The first slice may use a small code-owned plan-profile catalog because there is no existing billing or account model to import from.
- Managed-tenant activation limit is measured against the current workspace's active managed tenants and blocks future activation only; it does not retroactively deactivate existing tenants.
- Review-pack generation entitlement governs new `Generate pack`, `Regenerate`, and `Export executive pack` attempts only; existing `View` and `Download` access to already-generated artifacts continues to follow current artifact and capability rules.
## Risks
- Plan-profile naming can become prematurely productized if the catalog expands beyond the two first-slice entitlements.
- Partial gating would be misleading if one review-pack entry point enforces entitlement while another bypasses it.
- Lowering a managed-tenant limit below current usage creates an over-limit workspace that must be explained carefully so operators are not misled into expecting retroactive enforcement.
- Support users could misread the system view as a second source of truth if the admin and system surfaces drift in wording or source labels.
## Follow-up Candidates
- Customer lifecycle communication driven by plan and entitlement state
- Demo and trial readiness built on top of the same workspace commercial truth
- Broader entitlement keys for exports, retention, seats, and support-plan limits
- External billing, subscription, or contract integration once a real account domain exists
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Configure workspace commercial truth in one place (Priority: P1)
As a workspace owner or manager, I want to set the workspace plan profile and any first-slice overrides on the existing workspace settings page so later runtime behavior is predictable and attributable.
**Why this priority**: The first slice fails immediately if the product cannot define one authoritative workspace entitlement posture before runtime enforcement begins.
**Independent Test**: Open the existing workspace settings page, save a plan profile and one override with rationale, then verify that the resolved values, source, and current usage summary update without touching any onboarding or reporting surface.
**Acceptance Scenarios**:
1. **Given** a workspace manager can access workspace settings, **When** they save a plan profile with no overrides, **Then** the page shows the resolved first-slice entitlements from that profile and records the change through the existing workspace-setting audit path.
2. **Given** a workspace manager sets an explicit override for one entitlement, **When** they save the change with rationale, **Then** the resolved source changes to workspace override and the rationale is visible on the page.
3. **Given** a workspace manager resets an override, **When** the reset completes, **Then** the effective value returns to the plan-profile default and the reset is attributable in audit history.
---
### User Story 2 - Truthfully gate managed-tenant activation (Priority: P1)
As an authorized onboarding operator, I want the final onboarding step to tell me whether the workspace may activate another managed tenant and why, so I do not complete onboarding under a false assumption.
**Why this priority**: Managed-tenant activation is the highest-risk first-slice lifecycle mutation. It needs a truthful commercial gate before later trial or lifecycle specs build on top of it.
**Independent Test**: Seed workspaces under limit, at limit, and over limit, open the existing onboarding completion step, and verify that the same action is allowed or blocked with the right reason before any tenant activation mutation occurs.
**Acceptance Scenarios**:
1. **Given** a workspace is within its allowed managed-tenant limit and the actor has onboarding activation capability, **When** they reach the existing completion step, **Then** the step shows the current limit usage and allows completion.
2. **Given** a workspace is at or above its allowed managed-tenant limit, **When** the same actor reaches the completion step, **Then** the action remains visible but blocked with a truthful explanation and no tenant activation occurs.
3. **Given** the workspace has an explicit override that increases the allowed limit, **When** the actor reaches the completion step, **Then** the action uses the override value and labels the source accordingly.
---
### User Story 3 - Truthfully gate review-pack generation and expose the reason to support (Priority: P2)
As a reporting operator or platform support user, I want review-pack generation to use the same entitlement decision everywhere and be inspectable from the system directory so I can explain blocked behavior without guesswork.
**Why this priority**: Review-pack generation is an existing shared product workflow with several entry points. If it is not gated consistently, the product will immediately create conflicting commercial behavior.
**Independent Test**: Seed a workspace where review-pack generation is disabled, attempt generation from current tenant or review surfaces, confirm no new run is created, and then verify the same resolved reason on the read-only system workspace page.
**Acceptance Scenarios**:
1. **Given** review-pack generation is enabled for a workspace and the actor has `review_pack.manage`, **When** they start generation from an existing entry surface, **Then** the existing queued review-pack flow continues unchanged.
2. **Given** review-pack generation is disabled for that workspace, **When** the same actor attempts generation or regeneration from any current entry surface, **Then** the action is blocked before any `OperationRun` or `ReviewPack` is created and the reason matches the workspace entitlement decision.
3. **Given** a platform user with `platform.directory.view` opens the system workspace detail page, **When** they inspect the workspace entitlement summary, **Then** they can see the resolved plan profile, source, effective values, and last changed attribution without changing the admin-plane truth.
### Edge Cases
- A workspace with no explicit plan profile override must still resolve deterministically from the system default plan profile so operators never see an unset commercial state.
- Lowering the managed-tenant limit below the workspace's current active count must not deactivate existing tenants; it only blocks future onboarding activation and must show the workspace as over limit.
- Disabling review-pack generation must not remove access to already-generated review packs, downloads, or run history that remain allowed under existing artifact permissions.
- If review-pack generation is already queued and the workspace later becomes not entitled, existing runs may complete, but new `Generate pack`, `Regenerate`, or `Export executive pack` attempts must block from that point forward.
- A user who is a workspace member but lacks `workspace_settings.manage`, `workspace_managed_tenant.onboard.activate`, or `review_pack.manage` must still receive 403 for those actions even when the workspace itself is entitled.
- A non-member or wrong-plane actor must not learn whether a workspace is over limit or review-pack generation is disabled; those requests continue to resolve as 404.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds runtime-changing workspace-owned product truth and mutating settings writes, but it does not add Microsoft Graph calls, new provider dispatch, or a new queued workflow family. Workspace plan-profile and override changes use the existing workspace-setting audit path. Review-pack generation continues to rely on the existing `OperationRun`-backed flow only when entitlement allows the action.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces new workspace-owned commercial truth and one bounded resolver because current-release operator workflows need one consistent decision across settings, onboarding, reporting, and system support. A narrower approach would still scatter plan logic across surfaces. The feature deliberately avoids a new table, customer-account model, or billing lifecycle state machine.
**Constitution alignment (XCUT-001):** All first-slice surfaces must use the same effective entitlement decision object for value, source, rationale, and current usage. No surface may invent local blocked copy or local plan semantics.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Settings and action surfaces must show only the one commercial fact needed for the current decision. Support or audit detail remains secondary. No surface may present a neutral or success-like state when the workspace is actually blocked by entitlement or limit usage.
**Constitution alignment (PROV-001):** Plan profile, entitlement, override, and current-usage vocabulary remain platform-neutral. The feature must not introduce provider-shaped plan keys or account semantics.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes. New fixtures remain local to workspace, tenant, review-pack, and platform-directory contexts. No browser or heavy-governance family is justified for this slice.
**Constitution alignment (OPS-UX):** The feature does not add a new run family. Existing review-pack generation keeps the current queued toast, progress, and terminal notification path. Blocked review-pack attempts must not create a run, and therefore must not emit run lifecycle notifications.
**Constitution alignment (OPS-UX-START-001):** Review-pack generation entry surfaces continue delegating queued-start UX and canonical links to the shared review-pack and operation-run path. The new entitlement gate must sit before run creation rather than replacing that shared path.
**Constitution alignment (RBAC-UX):** Two authorization planes are involved: tenant/admin `/admin` and system `/system`. Wrong-plane or non-member access remains 404. Members missing capability remain 403. Entitlement denial for an otherwise authorized actor is a product-state block, not a membership failure. Existing destructive-like actions such as onboarding draft cancellation and deletion remain confirmation-protected. Any new override reset action must also use explicit confirmation if it materially changes runtime access.
**Constitution alignment (BADGE-001):** If the slice introduces status or source badges for entitlement state, those semantics must be centralized and reused across admin and system views rather than implemented with page-local color logic.
**Constitution alignment (UI-FIL-001):** The first slice extends existing native Filament pages, widgets, actions, sections, callouts, and detail views. No custom backoffice shell or local billing panel is allowed.
**Constitution alignment (UI-NAMING-001):** Primary operator labels must stay product-facing and specific: `Plan profile`, `Managed tenant limit`, `Review pack generation`, `Override reason`, `Complete onboarding`, `Generate pack`, and `Open admin workspace`. Terms such as subscription, checkout, invoice, proration, or Stripe must not appear.
**Constitution alignment (DECIDE-001):** Workspace settings remains the one primary commercial decision surface. Onboarding and review-pack surfaces remain contextual decision points that show only the commercial truth needed for the action at hand. The system directory page remains read-only evidence.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing singleton settings, guided workflow, grouped review-pack actions, and read-only system detail patterns. It may not add redundant inspect actions, shadow settings routes, or mixed action groups that hide the blocked reason.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Settings mutation stays on the workspace settings page. Onboarding completion remains the primary action at the completion step. Review-pack generation remains the primary reporting action where already present. Navigation and diagnostics stay secondary.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin decision layer is justified because direct reads from raw settings would still force each surface to rebuild merge rules, source attribution, and current usage. Tests must target business outcomes such as allowed versus blocked execution and correct source labeling, not cosmetic rendering alone.
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with documented existing exceptions for the singleton settings page and onboarding wizard. The review-pack generation entry family must keep the existing `Generate pack`, `Regenerate`, and `Export executive pack` start actions in scope, leave existing `View` and `Download` affordances outside the entitlement gate, and must not add redundant inspect affordances or placeholder action groups.
**Constitution alignment (UX-001 - Layout & Information Architecture):** The settings changes stay inside the existing sectioned workspace settings page. Onboarding and review-pack surfaces keep the current layout and only add bounded decision truth. The system directory page remains a detail/information surface rather than a second settings panel.
### Functional Requirements
- **FR-247-001 Workspace-owned truth**: The system MUST represent first-slice commercial truth at workspace scope by storing one selected plan profile and optional explicit workspace overrides through the existing workspace settings infrastructure instead of introducing a new billing or customer-account persistence model.
- **FR-247-002 First-slice entitlement catalog**: The first slice MUST resolve exactly two entitlement keys: active managed-tenant activation limit and review-pack generation availability. No other entitlement family is required in this spec.
- **FR-247-003 Effective decision shape**: The system MUST derive an effective decision for each first-slice entitlement that includes the effective value, source, operator-visible rationale, and current usage when the entitlement is limit-based.
- **FR-247-004 Plan profile defaults**: The system MUST provide a bounded, code-owned plan-profile catalog whose defaults drive the two first-slice entitlement keys. The catalog is a product configuration artifact, not a customer contract or payment record.
- **FR-247-005 Explicit overrides**: Authorized workspace managers MUST be able to set or reset explicit override values for each first-slice entitlement together with rationale, and the resulting change MUST be attributable through the existing workspace-setting audit path.
- **FR-247-006 Workspace settings visibility**: The existing workspace settings page MUST show the current plan profile, effective first-slice entitlements, source labels, rationale, and current usage summary after save without requiring the operator to inspect code, logs, or system pages.
- **FR-247-007 Managed-tenant activation enforcement**: The existing onboarding completion action MUST consult the active managed-tenant entitlement decision before activation. If the workspace is over limit, the action MUST remain visible to otherwise authorized actors, explain why completion is blocked, and stop before tenant activation mutates runtime state.
- **FR-247-008 Review-pack generation enforcement**: All current `Generate pack`, `Regenerate`, and `Export executive pack` entry points MUST consult the same review-pack entitlement decision before creating or reusing a `ReviewPack` or `OperationRun`. If blocked, the action MUST stop before any run or artifact is created.
- **FR-247-009 Existing artifact access unchanged**: Disabling review-pack generation MUST NOT revoke view or download access to existing review packs that remain accessible under current artifact permissions.
- **FR-247-010 Over-limit behavior**: Lowering a managed-tenant limit below current active usage MUST mark the workspace as over limit for future activation attempts, but MUST NOT deactivate or archive existing tenants.
- **FR-247-011 System-plane visibility**: The existing system directory workspace page MUST show a read-only summary of the resolved plan profile, effective first-slice entitlements, source labels, and last changed attribution to platform users with `platform.directory.view`.
- **FR-247-012 Entitlement versus RBAC semantics**: Non-members and wrong-plane actors MUST continue to receive 404. Members missing the relevant capability MUST receive 403. Actors who are otherwise authorized but whose workspace is not entitled MUST receive a truthful product-state block and no silent bypass.
- **FR-247-013 No hidden commercial platform**: The first slice MUST NOT introduce checkout, invoices, payment collection, proration, trial/grace/suspension lifecycle state, customer-account records, or any external billing-provider seam.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace settings entitlement section | `app/Filament/Pages/Settings/WorkspaceSettings.php` | `Save` | N/A - singleton settings page | none | none | N/A | N/A | `Save`; per-setting `Reset` actions for plan profile and overrides | yes - existing workspace-setting update/reset audit path | Existing singleton-page exemption remains valid |
| Managed tenant onboarding completion gate | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | `Back to workspace`, `Back to onboarding`, `View tenant`, existing `Cancel draft`, existing `Delete draft` | N/A - guided workflow | none | none | existing onboarding start state remains unchanged | same header actions apply on the route-bound page | `Complete onboarding` remains confirmation-protected and now also entitlement-gated | yes - existing onboarding audit semantics remain | Existing wizard exception remains valid |
| Review-pack generation entry family | `app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php` | current `Generate pack`, `Regenerate`, or `Export executive pack` entry actions stay primary | Existing clickable row remains only on the review-pack registry | existing `Download` remains the only direct row shortcut on the registry and stays outside the entitlement gate | none | existing `Generate` CTA remains on empty states where already present | existing `Download` and `Regenerate` header actions stay in place; only `Generate pack`, `Regenerate`, and `Export executive pack` are gated | N/A - action family, not a create/edit form | existing review-pack generation behavior unchanged; no new entitlement-block audit required | Grouped action family documented here so all in-scope start actions share one gate while `View` and `Download` remain unaffected |
| System directory workspace entitlement summary | `app/Filament/System/Pages/Directory/ViewWorkspace.php` | none | dedicated page route only | none | none | N/A | existing admin-workspace and runs links remain secondary navigation | N/A | no new audit action; read-only visibility only | Read-only system detail surface |
### Key Entities *(include if feature involves data)*
- **Workspace Plan Profile**: A bounded workspace-owned plan identifier that maps to the two first-slice entitlement defaults. It is not a subscription, contract, invoice, or customer account.
- **Workspace Entitlement Override**: An explicit workspace-scoped override value for one first-slice entitlement key together with operator rationale and audit attribution, persisted through the existing workspace settings stack.
- **Effective Entitlement Decision**: A derived runtime decision containing effective value, source, rationale, and current usage summary, reused by settings, onboarding, review-pack, and system visibility surfaces.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Authorized workspace managers can set or reset the first-slice commercial posture from one workspace settings page and see the resolved values, source, and current usage immediately after saving.
- **SC-002**: Authorized operators can determine from the onboarding completion step or a review-pack entry surface in under 30 seconds whether the action is allowed, blocked by plan profile, or blocked by current limit usage, without opening logs or asking support.
- **SC-003**: 100% of first-slice blocked executions stop before tenant activation or review-pack run creation and show a truthful reason instead of silently hiding the action or implying success.
- **SC-004**: Platform operators with read-only directory access can inspect the effective workspace plan profile, entitlement source, and last changed attribution from one system detail page without switching to a second source of truth.

View File

@ -0,0 +1,169 @@
---
description: "Task list for feature implementation"
---
# Tasks: Plans, Entitlements & Billing Readiness
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` and `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Align the bounded first slice, validation entry points, and repo guardrails before touching runtime code.
- [x] T001 Review the bounded slice, explicit non-goals, and guardrail outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md`
- [x] T002 [P] Review the logical route and action boundaries for workspace settings, onboarding completion, review-pack generation, and system-directory visibility in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/contracts/workspace-entitlements-foundation.logical.openapi.yaml`
- [x] T003 [P] Start or confirm the focused validation environment through `apps/platform/vendor/bin/sail` and keep the planned proof commands aligned with `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared entitlement primitives that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Add the first-slice entitlement setting keys, validation metadata, and operator-facing labels to `apps/platform/app/Support/Settings/SettingsRegistry.php`
- [x] T005 [P] Add the bounded code-owned plan profile catalog for the two first-slice entitlement defaults in `apps/platform/app/Services/Entitlements/WorkspacePlanProfileCatalog.php`
- [x] T006 Implement the shared effective entitlement decision shape, source vocabulary, rationale projection, and managed-tenant usage aggregation in `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
- [x] T007 Wire the new entitlement keys through the existing audited settings stack in `apps/platform/app/Services/Settings/SettingsResolver.php` and `apps/platform/app/Services/Settings/SettingsWriter.php`
**Checkpoint**: Foundation ready. User story work can now proceed independently.
---
## Phase 3: User Story 1 - Configure Workspace Commercial Truth In One Place (Priority: P1) 🎯 MVP
**Goal**: Let a workspace manager set one plan profile and two bounded entitlement overrides with rationale from the existing workspace settings page.
**Independent Test**: Open `/admin/settings/workspace`, save a plan profile and one override with rationale, then confirm the page shows the resolved values, source, rationale, and current usage summary without touching onboarding or review-pack surfaces.
### Tests for User Story 1
- [x] T008 [P] [US1] Add plan-profile and resolver unit coverage for default fallback, override merge, source attribution, rationale, and over-limit calculation in `apps/platform/tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php` and `apps/platform/tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php`
- [x] T009 [P] [US1] Add workspace-settings feature coverage for save, reset, rationale validation, source labels, and audit attribution in `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`
### Implementation for User Story 1
- [x] T010 [US1] Extend the existing workspace settings section with a plan profile selector, two override inputs, and rationale inputs in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
- [x] T011 [US1] Add confirmation-protected override reset actions that clear both override value and rationale on `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`
- [x] T012 [US1] Persist plan-profile and override writes through the existing audited settings path in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Services/Settings/SettingsWriter.php`
- [x] T013 [US1] Render the shared resolver output back onto the settings page as effective source, rationale, and current usage summary in `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` and `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
**Checkpoint**: User Story 1 is independently functional and ready for focused settings validation.
---
## Phase 4: User Story 2 - Truthfully Gate Managed-Tenant Activation (Priority: P1)
**Goal**: Keep the onboarding completion action visible to authorized actors while blocking activation with a truthful entitlement reason whenever the workspace is at or over its managed-tenant limit.
**Independent Test**: Seed workspaces within limit, at limit, and over limit, open the existing onboarding completion step, and confirm the action is either allowed or blocked with the correct reason before any tenant activation mutation occurs.
### Tests for User Story 2
- [x] T014 [P] [US2] Add onboarding feature coverage for within-limit, at-limit, over-limit, override-source, and 404 versus 403 versus business-state semantics in `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
### Implementation for User Story 2
- [x] T015 [US2] Project the shared managed-tenant entitlement decision onto the onboarding completion step in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T016 [US2] Enforce the managed-tenant activation entitlement before any onboarding completion mutation occurs in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T017 [US2] Keep the onboarding helper text, source labels, and business-state block logic sourced from `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php` inside `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
**Checkpoint**: User Story 2 is independently functional and preserves truthful activation gating without retroactive tenant mutation.
---
## Phase 5: User Story 3 - Truthfully Gate Review-Pack Generation And Expose The Reason To Support (Priority: P2)
**Goal**: Reuse one entitlement decision for all current `Generate pack`, `Regenerate`, and `Export executive pack` entry points, while exposing the same resolved truth read-only on the system workspace page.
**Independent Test**: Seed a workspace where review-pack generation is disabled, attempt `Generate pack`, `Regenerate`, and `Export executive pack` from the existing entry families, confirm no new `ReviewPack` or `OperationRun` is created, confirm existing `View`/`Download` access still works, and then confirm the same resolved reason on the system workspace detail page.
### Tests for User Story 3
- [x] T018 [P] [US3] Add review-pack and system-directory feature coverage for blocked `Generate pack`, `Regenerate`, and `Export executive pack` actions, preserved allowed flow, explicit `View`/`Download` no-regression behavior, no-run enforcement, and read-only entitlement visibility in `apps/platform/tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php` and `apps/platform/tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
### Implementation for User Story 3
- [x] T019 [US3] Enforce review-pack entitlement before `ReviewPack` or `OperationRun` creation for `Generate pack`, `Regenerate`, and `Export executive pack` flows while preserving existing queued-start UX and leaving `View`/`Download` behavior unchanged in `apps/platform/app/Services/ReviewPackService.php`
- [x] T020 [P] [US3] Gate the tenant dashboard review-pack card and the Review Register `Export executive pack` entry point with resolver-backed allow-or-block messaging in `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
- [x] T021 [P] [US3] Gate only the in-scope `Generate pack`, `Regenerate`, and `Export executive pack` actions on tenant review and review-pack resource surfaces with the same shared decision projection, while leaving existing `View` and `Download` access unchanged, in `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
- [x] T022 [P] [US3] Add the read-only resolved entitlement summary to the system workspace detail surface in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php`
**Checkpoint**: User Story 3 is independently functional and keeps review-pack gating and system visibility on the same decision truth.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish guardrail close-out, run the narrow validation commands, and format touched files without widening scope.
- [x] T023 Record the final guardrail close-out, lane result, and any bounded `document-in-feature` note for shared entitlement wording or surface exceptions in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/checklists/requirements.md`
- [x] T024 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Unit/Entitlements/WorkspaceEntitlementResolverTest.php` and `apps/platform/tests/Unit/Entitlements/WorkspacePlanProfileCatalogTest.php`
- [x] T025 Run the targeted settings and onboarding Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` and `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`
- [x] T026 Run the targeted review-pack and system-directory Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md` against `apps/platform/tests/Feature/ReviewPacks/ReviewPackEntitlementEnforcementTest.php` and `apps/platform/tests/Feature/System/Directory/ViewWorkspaceEntitlementsTest.php`
- [x] T027 Run dirty-only formatting for touched platform files through `apps/platform/vendor/bin/sail` using the Pint command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/247-plans-entitlements-billing-readiness/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**, **Phase 4 (US2)**, and **Phase 5 (US3)**: each depends on Phase 2 and is independently testable after the shared settings and resolver primitives exist.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: starts after Phase 2 and does not depend on US2 or US3.
- **US2 (P1)**: starts after Phase 2 and reuses the shared resolver, but does not require US1 UI work to be complete.
- **US3 (P2)**: starts after Phase 2 and reuses the shared resolver plus settings keys, but does not require onboarding work to be complete.
### Within Each User Story
- Write the listed Pest tests first and make them fail for the intended behavior gap.
- Complete shared service enforcement before wiring multiple entry points that depend on it.
- Keep each story shippable on its own before moving to the next story.
---
## Parallel Execution Examples
### User Story 1
- Run T008 and T009 in parallel.
- After T010 starts the settings section, keep T011 and T012 coordinated because both touch `WorkspaceSettings.php`.
### User Story 2
- Run T014 in parallel with any remaining US1 validation work after Phase 2 is complete.
- Keep T015, T016, and T017 sequential because they all tighten the same onboarding completion boundary.
### User Story 3
- Run T018 first, then complete T019 before splitting T020, T021, and T022 across separate files.
- T020, T021, and T022 can proceed in parallel once the service-level gate in `ReviewPackService.php` is in place.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **User Story 1** only. It establishes the workspace-owned plan profile, two entitlement keys, explicit overrides with rationale, and the shared decision truth that every later gate depends on.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate the settings-backed commercial truth.
3. Deliver US2 and validate onboarding activation gating.
4. Deliver US3 and validate review-pack gating plus read-only system visibility.
5. Finish with the Phase 6 guardrail close-out, focused validation commands, and formatting.