Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Darrazi
d51df2800b feat: implement platform localization v1
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
2026-04-28 21:27:22 +02:00
Ahmed Darrazi
4f7c1a6c94 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 15:41:58 +02:00
7ee4909212 feat: commercial lifecycle overlay for workspace entitlements (#292)
Some checks failed
Main Confidence / confidence (push) Failing after 1m45s
## Summary
- add the bounded workspace commercial lifecycle overlay from spec 251 on top of the existing entitlement substrate
- expose audited commercial state inspection and mutation on the system workspace detail surface
- gate onboarding activation and review-pack start actions through the shared lifecycle decision while preserving suspended read-only access to existing review, evidence, and generated-pack history
- add focused Pest coverage plus the spec/plan/tasks/data-model/contract artifacts for the feature

## Validation
- targeted Pest unit and feature lanes for lifecycle resolution, system-plane mutation, onboarding gating, review-pack enforcement, download preservation, customer review workspace access, and evidence snapshot access
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated browser smoke on the system workspace detail and the preserved read-only review/evidence/review-pack surfaces

## Notes
- branch: `251-commercial-entitlements-billing-state`
- base: `dev`
- commit: `606e9760`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #292
2026-04-28 13:39:33 +00:00
86 changed files with 6460 additions and 259 deletions

View File

@ -262,6 +262,8 @@ ## Active Technologies
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack) - PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace) - PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace)
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace) - PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -296,9 +298,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services - 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` - 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -9,4 +9,9 @@
class Login extends BaseLogin class Login extends BaseLogin
{ {
protected string $view = 'filament.pages.auth.login'; protected string $view = 'filament.pages.auth.login';
public function getTitle(): string
{
return __('localization.auth.sign_in_microsoft');
}
} }

View File

@ -57,6 +57,21 @@ class CustomerReviewWorkspace extends Page implements HasTable
protected string $view = 'filament.pages.reviews.customer-review-workspace'; protected string $view = 'filament.pages.reviews.customer-review-workspace';
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');
}
public static function getNavigationLabel(): string
{
return __('localization.review.customer_reviews');
}
public function getTitle(): string
{
return __('localization.review.customer_review_workspace');
}
public static function tenantPrefilterUrl(Tenant $tenant): string public static function tenantPrefilterUrl(Tenant $tenant): string
{ {
$tenantIdentifier = filled($tenant->external_id) $tenantIdentifier = filled($tenant->external_id)
@ -84,7 +99,7 @@ protected function getHeaderActions(): array
{ {
return [ return [
Action::make('clear_filters') Action::make('clear_filters')
->label('Clear filters') ->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->hasActiveFilters()) ->visible(fn (): bool => $this->hasActiveFilters())
@ -105,9 +120,9 @@ public function table(Table $table): Table
->persistSortInSession() ->persistSortInSession()
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([ ->columns([
TextColumn::make('name')->label('Tenant')->searchable()->sortable(), TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
TextColumn::make('latest_review') TextColumn::make('latest_review')
->label('Latest review') ->label(__('localization.review.latest_review'))
->badge() ->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record)) ->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)) ->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
@ -116,25 +131,25 @@ public function table(Table $table): Table
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record)) ->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(), ->wrap(),
TextColumn::make('finding_summary') TextColumn::make('finding_summary')
->label('Key findings') ->label(__('localization.review.key_findings'))
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record)) ->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(), ->wrap(),
TextColumn::make('accepted_risk_summary') TextColumn::make('accepted_risk_summary')
->label('Accepted risks') ->label(__('localization.review.accepted_risks'))
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record)) ->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(), ->wrap(),
TextColumn::make('published_at') TextColumn::make('published_at')
->label('Published') ->label(__('localization.review.published'))
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString()) ->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime() ->dateTime()
->placeholder('—'), ->placeholder('—'),
TextColumn::make('review_pack_state') TextColumn::make('review_pack_state')
->label('Review pack') ->label(__('localization.review.review_pack'))
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)), ->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
]) ])
->filters([ ->filters([
SelectFilter::make('tenant_id') SelectFilter::make('tenant_id')
->label('Tenant') ->label(__('localization.review.tenant'))
->options(fn (): array => $this->tenantFilterOptions()) ->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter()) ->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder { ->query(function (Builder $query, array $data): Builder {
@ -148,25 +163,25 @@ public function table(Table $table): Table
]) ])
->actions([ ->actions([
Action::make('open_latest_review') Action::make('open_latest_review')
->label('Open latest review') ->label(__('localization.review.open_latest_review'))
->icon('heroicon-o-arrow-top-right-on-square') ->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview), ->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
Action::make('download_review_pack') Action::make('download_review_pack')
->label('Download review pack') ->label(__('localization.review.download_review_pack'))
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record)) ->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
->openUrlInNewTab() ->openUrlInNewTab()
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))), ->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No entitled tenants match this view') ->emptyStateHeading(__('localization.review.no_entitled_tenants'))
->emptyStateDescription(fn (): string => $this->hasActiveFilters() ->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.' ? __('localization.review.clear_filters_description')
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.') : __('localization.review.adjust_filters_description'))
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters_empty') Action::make('clear_filters_empty')
->label('Clear filters') ->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->hasActiveFilters()) ->visible(fn (): bool => $this->hasActiveFilters())
@ -387,7 +402,7 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
private function latestReviewStateLabel(Tenant $tenant): string private function latestReviewStateLabel(Tenant $tenant): string
{ {
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review'; return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
} }
private function latestReviewStateColor(Tenant $tenant): string private function latestReviewStateColor(Tenant $tenant): string
@ -410,7 +425,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason; $primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
@ -427,7 +442,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
return $primaryReason; return $primaryReason;
} }
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.'); return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
} }
private function findingSummary(Tenant $tenant): string private function findingSummary(Tenant $tenant): string
@ -435,7 +450,7 @@ private function findingSummary(Tenant $tenant): string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$summary = is_array($review->summary) ? $review->summary : []; $summary = is_array($review->summary) ? $review->summary : [];
@ -444,14 +459,17 @@ private function findingSummary(Tenant $tenant): string
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes); $terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingCount === 0) { if ($findingCount === 0) {
return 'No findings recorded in the published review.'; return __('localization.review.no_findings_recorded');
} }
if ($terminalOutcomes === null) { if ($terminalOutcomes === null) {
return sprintf('%d findings summarized in the published review.', $findingCount); return __('localization.review.findings_count_summary', ['count' => $findingCount]);
} }
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes); return __('localization.review.findings_count_with_outcomes', [
'count' => $findingCount,
'outcomes' => $terminalOutcomes,
]);
} }
private function acceptedRiskSummary(Tenant $tenant): string private function acceptedRiskSummary(Tenant $tenant): string
@ -459,7 +477,7 @@ private function acceptedRiskSummary(Tenant $tenant): string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$summary = is_array($review->summary) ? $review->summary : []; $summary = is_array($review->summary) ? $review->summary : [];
@ -469,10 +487,10 @@ private function acceptedRiskSummary(Tenant $tenant): string
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0); $warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
return match (true) { return match (true) {
$statusMarkedCount === 0 => 'No accepted risks recorded.', $statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount), $warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount), $validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
default => sprintf('%d accepted risks are on record.', $statusMarkedCount), default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
}; };
} }
@ -481,17 +499,17 @@ private function reviewPackAvailability(Tenant $tenant): string
$pack = $this->latestReviewPack($tenant); $pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) { if (! $pack instanceof ReviewPack) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
if ($pack->status !== ReviewPackStatus::Ready->value) { if ($pack->status !== ReviewPackStatus::Ready->value) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
if ($pack->expires_at !== null && $pack->expires_at->isPast()) { if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
return 'Available'; return __('localization.review.available');
} }
} }

View File

@ -178,9 +178,23 @@ public function table(Table $table): Table
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant) && auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true)) && in_array($record->status, ['ready', 'published'], true))
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)) ->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) ->tooltip(function (TenantReview $record): ?string {
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '') $decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
: null)
if ((bool) ($decision['is_blocked'] ?? false)) {
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
if ((bool) ($decision['is_warning'] ?? false)) {
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
return null;
})
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)), ->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
]) ])
->bulkActions([]) ->bulkActions([])

View File

@ -12,6 +12,7 @@
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -58,6 +59,7 @@ class WorkspaceSettings extends Page
*/ */
private const SETTING_FIELDS = [ private const SETTING_FIELDS = [
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'], 'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
'localization_default_locale' => ['domain' => 'localization', 'key' => 'default_locale', 'type' => 'string'],
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'], 'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
@ -153,17 +155,22 @@ protected function getHeaderActions(): array
{ {
return [ return [
Action::make('save') Action::make('save')
->label('Save') ->label(__('localization.workspace.save'))
->action(function (): void { ->action(function (): void {
$this->save(); $this->save();
}) })
->disabled(fn (): bool => ! $this->currentUserCanManage()) ->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage() ->tooltip(fn (): ?string => $this->currentUserCanManage()
? null ? null
: 'You do not have permission to manage workspace settings.'), : __('localization.workspace.no_manage_permission')),
]; ];
} }
public function getTitle(): string
{
return __('localization.workspace.title');
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -208,6 +215,18 @@ public function content(Schema $schema): Schema
return $schema return $schema
->statePath('data') ->statePath('data')
->schema([ ->schema([
Section::make(__('localization.workspace.section'))
->description($this->sectionDescription('localization', __('localization.workspace.section_description')))
->schema([
Select::make('localization_default_locale')
->label(__('localization.workspace.default_locale_label'))
->options(LocaleResolver::localeOptions())
->placeholder(__('localization.workspace.default_locale_placeholder'))
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->localeDefaultHelperText())
->hintAction($this->makeResetAction('localization_default_locale')),
]),
Section::make('Workspace entitlements') Section::make('Workspace entitlements')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.')) ->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2) ->columns(2)
@ -507,7 +526,7 @@ public function save(): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save') ->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged'))
->success() ->success()
->send(); ->send();
} }
@ -526,7 +545,7 @@ public function resetSetting(string $field): void
if ($this->workspaceOverrideForField($field) === null) { if ($this->workspaceOverrideForField($field) === null) {
Notification::make() Notification::make()
->title('Setting already uses default') ->title(__('localization.notifications.setting_already_default'))
->success() ->success()
->send(); ->send();
@ -543,7 +562,7 @@ public function resetSetting(string $field): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title('Workspace setting reset to default') ->title(__('localization.notifications.workspace_setting_reset'))
->success() ->success()
->send(); ->send();
} }
@ -692,18 +711,17 @@ private function sectionDescription(string $domain, string $baseDescription): st
/** @var Carbon $updatedAt */ /** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at']; $updatedAt = $meta['updated_at'];
return sprintf( return __('localization.workspace.last_modified_by', [
'%s — Last modified by %s, %s.', 'description' => $baseDescription,
$baseDescription, 'user' => $meta['user_name'],
$meta['user_name'], 'time' => $updatedAt->diffForHumans(),
$updatedAt->diffForHumans(), ]);
);
} }
private function makeResetAction(string $field): Action private function makeResetAction(string $field): Action
{ {
return Action::make('reset_'.$field) return Action::make('reset_'.$field)
->label('Reset') ->label(__('localization.workspace.reset'))
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->action(function () use ($field): void { ->action(function () use ($field): void {
@ -718,15 +736,15 @@ private function makeResetAction(string $field): Action
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field)) ->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
->tooltip(function () use ($field): ?string { ->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) { if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.'; return __('localization.workspace.no_manage_permission');
} }
if (! $this->canResetField($field)) { if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) { if ($this->isEntitlementOverrideValueField($field)) {
return 'No workspace override to reset.'; return __('localization.workspace.no_workspace_override');
} }
return 'No workspace override to reset.'; return __('localization.workspace.no_workspace_override');
} }
return null; return null;
@ -948,6 +966,29 @@ private function helperTextFor(string $field): string
return sprintf('Effective value: %s.', $effectiveValue); return sprintf('Effective value: %s.', $effectiveValue);
} }
private function localeDefaultHelperText(): string
{
$resolved = $this->resolvedSettings['localization_default_locale'] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveLocale = LocaleResolver::normalize($resolved['value'] ?? null) ?? 'en';
$localeLabel = LocaleResolver::localeOptions()[$effectiveLocale] ?? strtoupper($effectiveLocale);
if (! $this->hasWorkspaceOverride('localization_default_locale')) {
return __('localization.workspace.default_locale_helper_unset', [
'locale' => $localeLabel,
'source' => $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
]);
}
return __('localization.workspace.default_locale_helper_set', [
'locale' => $localeLabel,
]);
}
private function slaFieldHelperText(string $severity): string private function slaFieldHelperText(string $severity): string
{ {
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null; $resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
@ -1353,9 +1394,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
private function sourceLabel(string $source): string private function sourceLabel(string $source): string
{ {
return match ($source) { return match ($source) {
'workspace_override' => 'workspace override', 'workspace_override' => __('localization.source.workspace_override'),
'tenant_override' => 'tenant override', 'tenant_override' => 'tenant override',
default => 'system default', default => __('localization.source.system_default'),
}; };
} }

View File

@ -42,6 +42,11 @@ class TenantDashboard extends Dashboard
*/ */
public array $supportDiagnosticsAuditKeys = []; public array $supportDiagnosticsAuditKeys = [];
public function getTitle(): string
{
return __('localization.dashboard.tenant_title');
}
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -90,38 +95,38 @@ public function authorizeTenantSupportRequest(): void
private function requestSupportAction(): Action private function requestSupportAction(): Action
{ {
$action = Action::make('requestSupport') $action = Action::make('requestSupport')
->label('Request support') ->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane') ->icon('heroicon-o-paper-airplane')
->color('gray') ->color('gray')
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading('Request support') ->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.') ->modalDescription(__('localization.dashboard.support_request_description'))
->modalSubmitActionLabel('Submit request') ->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([ ->form([
Placeholder::make('included_context') Placeholder::make('included_context')
->label('Included context') ->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(), ->columnSpanFull(),
Select::make('severity') Select::make('severity')
->label('Severity') ->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions()) ->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL) ->default(SupportRequest::SEVERITY_NORMAL)
->required() ->required()
->native(false), ->native(false),
TextInput::make('summary') TextInput::make('summary')
->label('Summary') ->label(__('localization.dashboard.summary'))
->required() ->required()
->columnSpanFull(), ->columnSpanFull(),
Textarea::make('reproduction_notes') Textarea::make('reproduction_notes')
->label('Reproduction notes') ->label(__('localization.dashboard.reproduction_notes'))
->rows(4) ->rows(4)
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('contact_name') TextInput::make('contact_name')
->label('Contact name') ->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveDashboardActor()->name), ->default(fn (): ?string => $this->resolveDashboardActor()->name),
TextInput::make('contact_email') TextInput::make('contact_email')
->label('Contact email') ->label(__('localization.dashboard.contact_email'))
->email() ->email()
->default(fn (): ?string => $this->resolveDashboardActor()->email), ->default(fn (): ?string => $this->resolveDashboardActor()->email),
]) ])
@ -132,7 +137,7 @@ private function requestSupportAction(): Action
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data); $supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
Notification::make() Notification::make()
->title('Support request submitted') ->title(__('localization.dashboard.support_request_submitted'))
->body('Reference '.$supportRequest->internal_reference) ->body('Reference '.$supportRequest->internal_reference)
->success() ->success()
->send(); ->send();
@ -146,16 +151,16 @@ private function requestSupportAction(): Action
private function openSupportDiagnosticsAction(): Action private function openSupportDiagnosticsAction(): Action
{ {
$action = Action::make('openSupportDiagnostics') $action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics') ->label(__('localization.dashboard.open_support_diagnostics'))
->icon('heroicon-o-lifebuoy') ->icon('heroicon-o-lifebuoy')
->color('gray') ->color('gray')
->modal() ->modal()
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading('Support diagnostics') ->modalHeading(__('localization.dashboard.support_diagnostics'))
->modalDescription('Redacted tenant context from existing records.') ->modalDescription(__('localization.dashboard.support_diagnostics_description'))
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close')) ->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
->mountUsing(function (): void { ->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen(); $this->auditTenantSupportDiagnosticsOpen();
}) })

View File

@ -30,6 +30,7 @@
use App\Services\Onboarding\OnboardingDraftResolver; use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver; use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService; use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService; use App\Services\Providers\ProviderConnectionMutationService;
@ -4551,27 +4552,30 @@ private function completionSummaryEntitlementDecision(): array
return []; return [];
} }
return app(WorkspaceEntitlementResolver::class)->resolve( return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision(
$this->workspace, $this->workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION,
); );
} }
private function completionSummaryEntitlementBlocked(): bool private function completionSummaryEntitlementBlocked(): bool
{ {
return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false); return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK;
} }
private function completionSummaryEntitlementSummary(): string private function completionSummaryEntitlementSummary(): string
{ {
$decision = $this->completionSummaryEntitlementDecision(); $decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0); $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
$effectiveValue = (int) ($decision['effective_value'] ?? 0); $currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision); $effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
return sprintf( return sprintf(
'%s - %d active of %d allowed (%s)', '%s - %s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed', $this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$stateLabel,
$currentUsage, $currentUsage,
$effectiveValue, $effectiveValue,
$sourceLabel, $sourceLabel,
@ -4581,13 +4585,15 @@ private function completionSummaryEntitlementSummary(): string
private function completionSummaryEntitlementDetail(): string private function completionSummaryEntitlementDetail(): string
{ {
$decision = $this->completionSummaryEntitlementDecision(); $decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0); $entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
$effectiveValue = (int) ($decision['effective_value'] ?? 0); $currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0); $effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision); $remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null; $rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf( $message = sprintf(
'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.', '%s Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
(string) ($decision['message'] ?? 'Managed-tenant activation is available for this workspace commercial state.'),
$currentUsage, $currentUsage,
$currentUsage === 1 ? '' : 's', $currentUsage === 1 ? '' : 's',
$effectiveValue, $effectiveValue,
@ -4606,7 +4612,7 @@ private function completionSummaryEntitlementDetail(): string
} }
} }
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') { if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) {
$message .= ' Rationale: '.$rationale; $message .= ' Rationale: '.$rationale;
} }
@ -4982,7 +4988,7 @@ public function completeOnboarding(): void
if ($this->completionSummaryEntitlementBlocked()) { if ($this->completionSummaryEntitlementBlocked()) {
Notification::make() Notification::make()
->title('Activation limit reached') ->title('Activation unavailable')
->body($this->completionSummaryEntitlementDetail()) ->body($this->completionSummaryEntitlementDetail())
->warning() ->warning()
->send(); ->send();

View File

@ -75,8 +75,6 @@ class FindingResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Governance'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings';
public static function shouldRegisterNavigation(): bool public static function shouldRegisterNavigation(): bool
{ {
if (Filament::getCurrentPanel()?->getId() === 'admin') { if (Filament::getCurrentPanel()?->getId() === 'admin') {
@ -86,6 +84,26 @@ public static function shouldRegisterNavigation(): bool
return parent::shouldRegisterNavigation(); return parent::shouldRegisterNavigation();
} }
public static function getNavigationLabel(): string
{
return __('localization.navigation.findings');
}
public static function getNavigationGroup(): string
{
return __('localization.navigation.governance');
}
public static function getModelLabel(): string
{
return __('localization.navigation.findings');
}
public static function getPluralModelLabel(): string
{
return __('localization.navigation.findings');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();

View File

@ -77,15 +77,15 @@ public function getTabs(): array
$stats = FindingResource::findingStatsForCurrentTenant(); $stats = FindingResource::findingStatsForCurrentTenant();
return [ return [
'all' => Tab::make('All') 'all' => Tab::make(__('localization.findings.all'))
->icon('heroicon-m-list-bullet'), ->icon('heroicon-m-list-bullet'),
'needs_action' => Tab::make('Needs action') 'needs_action' => Tab::make(__('localization.findings.needs_action'))
->icon('heroicon-m-exclamation-triangle') ->icon('heroicon-m-exclamation-triangle')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery())) ->whereIn('status', Finding::openStatusesForQuery()))
->badge($stats['open'] > 0 ? $stats['open'] : null) ->badge($stats['open'] > 0 ? $stats['open'] : null)
->badgeColor('warning'), ->badgeColor('warning'),
'overdue' => Tab::make('Overdue') 'overdue' => Tab::make(__('localization.findings.overdue'))
->icon('heroicon-m-clock') ->icon('heroicon-m-clock')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery()) ->whereIn('status', Finding::openStatusesForQuery())
@ -93,11 +93,11 @@ public function getTabs(): array
->where('due_at', '<', now())) ->where('due_at', '<', now()))
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null) ->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
->badgeColor('danger'), ->badgeColor('danger'),
'risk_accepted' => Tab::make('Risk accepted') 'risk_accepted' => Tab::make(__('localization.findings.risk_accepted'))
->icon('heroicon-m-shield-check') ->icon('heroicon-m-shield-check')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', Finding::STATUS_RISK_ACCEPTED)), ->where('status', Finding::STATUS_RISK_ACCEPTED)),
'resolved' => Tab::make('Resolved') 'resolved' => Tab::make(__('localization.findings.resolved'))
->icon('heroicon-m-archive-box') ->icon('heroicon-m-archive-box')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])), ->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),

View File

@ -44,7 +44,7 @@ protected function getHeaderActions(): array
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false)) ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
->color('gray'), ->color('gray'),
Actions\Action::make('open_approval_queue') Actions\Action::make('open_approval_queue')
->label('Open approval queue') ->label(__('localization.findings.open_approval_queue'))
->icon('heroicon-o-arrow-top-right-on-square') ->icon('heroicon-o-arrow-top-right-on-square')
->color('gray') ->color('gray')
->visible(function (): bool { ->visible(function (): bool {
@ -61,7 +61,7 @@ protected function getHeaderActions(): array
: null; : null;
}), }),
Actions\ActionGroup::make(FindingResource::workflowActions()) Actions\ActionGroup::make(FindingResource::workflowActions())
->label('Actions') ->label(__('localization.findings.actions'))
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
->color('gray'), ->color('gray'),
]); ]);

View File

@ -575,6 +575,19 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $reason : null; return is_string($reason) && $reason !== '' ? $reason : null;
} }
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{ {
$tenant ??= static::currentTenantContext(); $tenant ??= static::currentTenantContext();
@ -584,6 +597,7 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission(); return AuthUiTooltips::insufficientPermission();
} }
return static::reviewPackGenerationBlockReason($tenant); return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
} }
} }

View File

@ -85,6 +85,26 @@ public static function shouldRegisterNavigation(): bool
return Filament::getCurrentPanel()?->getId() === 'tenant'; return Filament::getCurrentPanel()?->getId() === 'tenant';
} }
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');
}
public static function getNavigationLabel(): string
{
return __('localization.review.reviews');
}
public static function getModelLabel(): string
{
return __('localization.review.review');
}
public static function getPluralModelLabel(): string
{
return __('localization.review.reviews');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -153,7 +173,7 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
Section::make('Outcome summary') Section::make(__('localization.review.outcome_summary'))
->schema([ ->schema([
ViewEntry::make('artifact_truth') ViewEntry::make('artifact_truth')
->hiddenLabel() ->hiddenLabel()
@ -162,7 +182,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Review') Section::make(__('localization.review.review'))
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
@ -171,23 +191,23 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextEntry::make('completeness_state') TextEntry::make('completeness_state')
->label('Completeness') ->label(__('localization.review.completeness'))
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextEntry::make('tenant.name')->label('Tenant'), TextEntry::make('tenant.name')->label(__('localization.review.tenant')),
TextEntry::make('generated_at')->dateTime()->placeholder('—'), TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('published_at')->dateTime()->placeholder('—'), TextEntry::make('published_at')->dateTime()->placeholder('—'),
TextEntry::make('evidenceSnapshot.id') TextEntry::make('evidenceSnapshot.id')
->label('Evidence snapshot') ->label(__('localization.review.evidence_snapshot'))
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot ->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) ? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null), : null),
TextEntry::make('currentExportReviewPack.id') TextEntry::make('currentExportReviewPack.id')
->label('Current export') ->label(__('localization.review.current_export'))
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack ->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant) ? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
@ -201,7 +221,7 @@ public static function infolist(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Executive posture') Section::make(__('localization.review.executive_posture'))
->schema([ ->schema([
ViewEntry::make('review_summary') ViewEntry::make('review_summary')
->hiddenLabel() ->hiddenLabel()
@ -210,21 +230,21 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Sections') Section::make(__('localization.review.sections'))
->schema([ ->schema([
RepeatableEntry::make('sections') RepeatableEntry::make('sections')
->hiddenLabel() ->hiddenLabel()
->schema([ ->schema([
TextEntry::make('title'), TextEntry::make('title'),
TextEntry::make('completeness_state') TextEntry::make('completeness_state')
->label('Completeness') ->label(__('localization.review.completeness'))
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextEntry::make('measured_at')->dateTime()->placeholder('—'), TextEntry::make('measured_at')->dateTime()->placeholder('—'),
Section::make('Details') Section::make(__('localization.review.details'))
->schema([ ->schema([
ViewEntry::make('section_payload') ViewEntry::make('section_payload')
->hiddenLabel() ->hiddenLabel()
@ -246,7 +266,7 @@ public static function table(Table $table): Table
{ {
$exportExecutivePackAction = UiEnforcement::forTableAction( $exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack') Actions\Action::make('export_executive_pack')
->label('Export executive pack') ->label(__('localization.review.export_executive_pack'))
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [ ->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value, TenantReviewStatus::Ready->value,
@ -278,7 +298,7 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('outcome') Tables\Columns\TextColumn::make('outcome')
->label('Outcome') ->label(__('localization.review.outcome'))
->badge() ->badge()
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color) ->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
@ -289,10 +309,10 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\IconColumn::make('summary.has_ready_export') Tables\Columns\IconColumn::make('summary.has_ready_export')
->label('Export') ->label(__('localization.review.export'))
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('next_step') Tables\Columns\TextColumn::make('next_step')
->label('Next step') ->label(__('localization.review.next_step'))
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
->wrap(), ->wrap(),
Tables\Columns\TextColumn::make('fingerprint') Tables\Columns\TextColumn::make('fingerprint')
@ -306,18 +326,18 @@ public static function table(Table $table): Table
->all()), ->all()),
Tables\Filters\SelectFilter::make('completeness_state') Tables\Filters\SelectFilter::make('completeness_state')
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())), ->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), \App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
]) ])
->actions([ ->actions([
$exportExecutivePackAction, $exportExecutivePackAction,
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No tenant reviews yet') ->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.') ->emptyStateDescription(__('localization.review.create_first_review_description'))
->emptyStateActions([ ->emptyStateActions([
static::makeCreateReviewAction( static::makeCreateReviewAction(
name: 'create_first_review', name: 'create_first_review',
label: 'Create first review', label: __('localization.review.create_first_review'),
icon: 'heroicon-o-plus', icon: 'heroicon-o-plus',
), ),
]); ]);
@ -336,19 +356,23 @@ public static function makeCreateReviewAction(
string $label = 'Create review', string $label = 'Create review',
string $icon = 'heroicon-o-plus', string $icon = 'heroicon-o-plus',
): Actions\Action { ): Actions\Action {
$label = $label === 'Create review'
? __('localization.review.create_review')
: $label;
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make($name) Actions\Action::make($name)
->label($label) ->label($label)
->icon($icon) ->icon($icon)
->form([ ->form([
Section::make('Evidence basis') Section::make(__('localization.review.evidence_basis'))
->schema([ ->schema([
Select::make('evidence_snapshot_id') Select::make('evidence_snapshot_id')
->label('Evidence snapshot') ->label(__('localization.review.evidence_snapshot'))
->required() ->required()
->options(fn (): array => static::evidenceSnapshotOptions()) ->options(fn (): array => static::evidenceSnapshotOptions())
->searchable() ->searchable()
->helperText('Choose the anchored evidence snapshot for this review.'), ->helperText(__('localization.review.evidence_basis_helper')),
]), ]),
]) ])
->action(fn (array $data): mixed => static::executeCreateReview($data)), ->action(fn (array $data): mixed => static::executeCreateReview($data)),
@ -366,7 +390,7 @@ public static function executeCreateReview(array $data): void
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title('Unable to create review — missing context.')->send(); Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
return; return;
} }
@ -388,7 +412,7 @@ public static function executeCreateReview(array $data): void
: null; : null;
if (! $snapshot instanceof EvidenceSnapshot) { if (! $snapshot instanceof EvidenceSnapshot) {
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send(); Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send();
return; return;
} }
@ -396,7 +420,7 @@ public static function executeCreateReview(array $data): void
try { try {
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user); $review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send(); Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
return; return;
} }
@ -406,11 +430,11 @@ public static function executeCreateReview(array $data): void
if (! $review->wasRecentlyCreated) { if (! $review->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title('Review already available') ->title(__('localization.review.review_already_available'))
->body('A matching mutable review already exists for this evidence basis.') ->body(__('localization.review.review_already_available_body'))
->actions([ ->actions([
Actions\Action::make('view_review') Actions\Action::make('view_review')
->label('View review') ->label(__('localization.review.view_review'))
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)), ->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
]) ])
->send(); ->send();
@ -419,12 +443,12 @@ public static function executeCreateReview(array $data): void
} }
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value) $toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
->body('The review is being composed in the background.'); ->body(__('localization.review.review_composing_background'));
if ($review->operation_run_id) { if ($review->operation_run_id) {
$toast->actions([ $toast->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label(__('localization.review.open_operation'))
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)), ->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
]); ]);
} }
@ -464,6 +488,19 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $reason : null; return is_string($reason) && $reason !== '' ? $reason : null;
} }
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{ {
$tenant ??= static::panelTenantContext(); $tenant ??= static::panelTenantContext();
@ -473,7 +510,8 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission(); return AuthUiTooltips::insufficientPermission();
} }
return static::reviewPackGenerationBlockReason($tenant); return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
} }
public static function executeExport(TenantReview $review): void public static function executeExport(TenantReview $review): void
@ -482,7 +520,7 @@ public static function executeExport(TenantReview $review): void
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User || ! $review->tenant instanceof Tenant) { if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
Notification::make()->danger()->title('Unable to export review — missing context.')->send(); Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
return; return;
} }
@ -499,7 +537,7 @@ public static function executeExport(TenantReview $review): void
if ($service->checkActiveRunForReview($review)) { if ($service->checkActiveRunForReview($review)) {
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value) OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
->body('An executive pack export is already queued or running for this review.') ->body(__('localization.review.export_already_queued_body'))
->send(); ->send();
return; return;
@ -511,11 +549,11 @@ public static function executeExport(TenantReview $review): void
'include_operations' => true, 'include_operations' => true,
]); ]);
} catch (WorkspaceEntitlementBlockedException $exception) { } catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send(); Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
return; return;
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send(); Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send();
return; return;
} }
@ -526,11 +564,11 @@ public static function executeExport(TenantReview $review): void
if (! $pack->wasRecentlyCreated) { if (! $pack->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title('Executive pack already available') ->title(__('localization.review.executive_pack_already_available'))
->body('A matching executive pack already exists for this review.') ->body(__('localization.review.executive_pack_already_available_body'))
->actions([ ->actions([
Actions\Action::make('view_pack') Actions\Action::make('view_pack')
->label('View pack') ->label(__('localization.review.view_pack'))
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)), ->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
]) ])
->send(); ->send();
@ -539,7 +577,7 @@ public static function executeExport(TenantReview $review): void
} }
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body('The executive pack is being generated in the background.') ->body(__('localization.review.executive_pack_generating_background'))
->send(); ->send();
} }
@ -579,7 +617,7 @@ private static function evidenceSnapshotOptions(): array
'#%d · %s · %s', '#%d · %s · %s',
(int) $snapshot->getKey(), (int) $snapshot->getKey(),
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label, BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending' $snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending')
), ),
]) ])
->all(); ->all();
@ -603,7 +641,7 @@ private static function summaryPresentation(TenantReview $record): array
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : []; $findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
if ($findingOutcomeSummary !== null) { if ($findingOutcomeSummary !== null) {
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.'; $highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
} }
return [ return [
@ -615,12 +653,12 @@ private static function summaryPresentation(TenantReview $record): array
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record), 'context_links' => static::summaryContextLinks($record),
'metrics' => [ 'metrics' => [
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)], ['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], ['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], ['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], ['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
], ],
]; ];
} }
@ -634,37 +672,37 @@ private static function summaryContextLinks(TenantReview $record): array
if (is_numeric($record->operation_run_id)) { if (is_numeric($record->operation_run_id)) {
$links[] = [ $links[] = [
'title' => 'Operation', 'title' => __('localization.review.operation'),
'label' => 'Open operation', 'label' => __('localization.review.open_operation'),
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id), 'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
'description' => 'Inspect the latest review composition or refresh run.', 'description' => __('localization.review.operation_description'),
]; ];
} }
if ($record->currentExportReviewPack && $record->tenant) { if ($record->currentExportReviewPack && $record->tenant) {
$links[] = [ $links[] = [
'title' => 'Executive pack', 'title' => __('localization.review.executive_pack'),
'label' => 'View executive pack', 'label' => __('localization.review.view_executive_pack'),
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant), 'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
'description' => 'Open the current export that belongs to this review.', 'description' => __('localization.review.executive_pack_description'),
]; ];
} }
if ($record->tenant) { if ($record->tenant) {
$links[] = [ $links[] = [
'title' => 'Customer workspace', 'title' => __('localization.review.customer_workspace'),
'label' => 'Open customer workspace', 'label' => __('localization.review.open_customer_workspace'),
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant), 'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.', 'description' => __('localization.review.customer_workspace_description'),
]; ];
} }
if ($record->evidenceSnapshot && $record->tenant) { if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [ $links[] = [
'title' => 'Evidence snapshot', 'title' => __('localization.review.evidence_snapshot'),
'label' => 'View evidence snapshot', 'label' => __('localization.review.view_evidence_snapshot'),
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant), 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
'description' => 'Return to the evidence basis behind this review.', 'description' => __('localization.review.evidence_snapshot_description'),
]; ];
} }

View File

@ -28,6 +28,11 @@ class Dashboard extends BaseDashboard
{ {
public string $window = SystemConsoleWindow::LastDay; public string $window = SystemConsoleWindow::LastDay;
public function getTitle(): string
{
return __('localization.dashboard.system_title');
}
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -109,12 +114,12 @@ protected function getHeaderActions(): array
return [ return [
Action::make('set_window') Action::make('set_window')
->label('Time window') ->label(__('localization.dashboard.time_window'))
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->color('gray') ->color('gray')
->form([ ->form([
Select::make('window') Select::make('window')
->label('Window') ->label(__('localization.dashboard.window'))
->options(SystemConsoleWindow::options()) ->options(SystemConsoleWindow::options())
->default($this->window) ->default($this->window)
->required(), ->required(),
@ -130,7 +135,7 @@ protected function getHeaderActions(): array
}), }),
Action::make('enter_break_glass') Action::make('enter_break_glass')
->label('Enter break-glass mode') ->label(__('localization.dashboard.enter_break_glass'))
->color('danger') ->color('danger')
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -158,13 +163,13 @@ protected function getHeaderActions(): array
$breakGlass->start($user, (string) ($data['reason'] ?? '')); $breakGlass->start($user, (string) ($data['reason'] ?? ''));
Notification::make() Notification::make()
->title('Recovery mode enabled') ->title(__('localization.dashboard.recovery_mode_enabled'))
->success() ->success()
->send(); ->send();
}), }),
Action::make('exit_break_glass') Action::make('exit_break_glass')
->label('Exit break-glass') ->label(__('localization.dashboard.exit_break_glass'))
->color('gray') ->color('gray')
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -180,7 +185,7 @@ protected function getHeaderActions(): array
$breakGlass->exit($user); $breakGlass->exit($user);
Notification::make() Notification::make()
->title('Recovery mode ended') ->title(__('localization.dashboard.recovery_mode_ended'))
->success() ->success()
->send(); ->send();
}), }),

View File

@ -9,13 +9,19 @@
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery; use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks; use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow; use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -94,6 +100,77 @@ public function workspaceEntitlementSummary(): array
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace); return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
} }
/**
* @return array<string, mixed>
*/
public function workspaceCommercialLifecycleSummary(): array
{
return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('change_commercial_state')
->label('Change commercial state')
->icon('heroicon-o-adjustments-horizontal')
->color('warning')
->visible(fn (): bool => $this->canManageCommercialLifecycle())
->requiresConfirmation()
->modalHeading('Change commercial state')
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
->form([
Select::make('state')
->label('Commercial state')
->options(WorkspaceCommercialLifecycleResolver::stateLabels())
->required()
->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)),
Textarea::make('reason')
->label('Rationale')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, SettingsWriter $settingsWriter): void {
$actor = auth('platform')->user();
if (! $actor instanceof PlatformUser) {
abort(403);
}
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
abort(403);
}
$settingsWriter->updateWorkspaceCommercialLifecycle(
actor: $actor,
workspace: $this->workspace,
state: (string) ($data['state'] ?? ''),
reason: (string) ($data['reason'] ?? ''),
);
$this->workspace->refresh();
Notification::make()
->title('Commercial state updated')
->success()
->send();
}),
];
}
private function canManageCommercialLifecycle(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
}
/** /**
* @return array{ * @return array{
* overall: array{label: string, color: string, icon: string|null}, * overall: array{label: string, color: string, icon: string|null},

View File

@ -81,6 +81,14 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return; return;
} }
if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) {
Notification::make()
->title('Review pack generation allowed with warning')
->body($decision['warning_reason'])
->warning()
->send();
}
$activeRun = $service->checkActiveRun($tenant) $activeRun = $service->checkActiveRun($tenant)
? OperationRun::query() ? OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -163,6 +171,9 @@ protected function getViewData(): array
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null) $generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
? $generationEntitlement['block_reason'] ? $generationEntitlement['block_reason']
: null; : null;
$generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null)
? $generationEntitlement['warning_reason']
: null;
$latestPack = ReviewPack::query() $latestPack = ReviewPack::query()
->with(['tenantReview', 'operationRun']) ->with(['tenantReview', 'operationRun'])
@ -181,6 +192,7 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'generationBlocked' => $generationBlocked, 'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason, 'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
@ -232,6 +244,7 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'generationBlocked' => $generationBlocked, 'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason, 'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null, 'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => $downloadUrl, 'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason, 'failedReason' => $failedReason,
@ -265,6 +278,7 @@ private function emptyState(): array
'canManage' => false, 'canManage' => false,
'generationBlocked' => false, 'generationBlocked' => false,
'generationBlockReason' => null, 'generationBlockReason' => null,
'generationWarningReason' => null,
'customerWorkspaceUrl' => null, 'customerWorkspaceUrl' => null,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\Localization\LocaleResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
class LocalizationController extends Controller
{
public function context(Request $request, LocaleResolver $resolver): JsonResponse
{
$plane = $request->query('plane');
$context = $request->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
if (is_string($plane) && $plane !== '') {
$context = $resolver->resolve($request, $plane);
}
return response()->json(is_array($context) ? $context : $resolver->resolve($request));
}
public function updateOverride(Request $request): RedirectResponse
{
$locale = LocaleResolver::normalize($request->input('locale'));
if ($locale === null) {
throw ValidationException::withMessages([
'locale' => [__('localization.validation.unsupported_locale')],
]);
}
$request->session()->put(LocaleResolver::SESSION_OVERRIDE_KEY, $locale);
App::setLocale($locale);
return back()->with('status', __('localization.notifications.locale_override_saved'));
}
public function clearOverride(Request $request, LocaleResolver $resolver): RedirectResponse
{
$request->session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
App::setLocale($resolver->resolve($request)['locale']);
return back()->with('status', __('localization.notifications.locale_override_cleared'));
}
public function updateUserPreference(Request $request, LocaleResolver $resolver): RedirectResponse
{
$user = $request->user();
abort_unless($user instanceof User, Response::HTTP_NOT_FOUND);
$rawLocale = $request->input('preferred_locale');
$locale = $rawLocale === null || $rawLocale === ''
? null
: LocaleResolver::normalize($rawLocale);
if ($rawLocale !== null && $rawLocale !== '' && $locale === null) {
throw ValidationException::withMessages([
'preferred_locale' => [__('localization.validation.unsupported_locale')],
]);
}
$user->forceFill(['preferred_locale' => $locale])->save();
$user->refresh();
App::setLocale($resolver->resolve($request)['locale']);
return back()->with('status', $locale === null
? __('localization.notifications.user_preference_cleared')
: __('localization.notifications.user_preference_saved'));
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Localization\LocaleResolver;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class ApplyResolvedLocale
{
public function __construct(private LocaleResolver $resolver) {}
public function handle(Request $request, Closure $next, ?string $plane = null): Response
{
$context = $this->resolver->resolve($request, $plane);
App::setLocale($context['locale']);
Carbon::setLocale($context['locale']);
$request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context);
return $next($request);
}
}

View File

@ -39,6 +39,7 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
'password', 'password',
'entra_tenant_id', 'entra_tenant_id',
'entra_object_id', 'entra_object_id',
'preferred_locale',
]; ];
/** /**

View File

@ -79,16 +79,16 @@ public function panel(Panel $panel): Panel
]) ])
->navigationItems([ ->navigationItems([
WorkspaceOverview::navigationItem(), WorkspaceOverview::navigationItem(),
NavigationItem::make('Integrations') NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
->url(fn (): string => route('filament.admin.resources.provider-connections.index')) ->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
->icon('heroicon-o-link') ->icon('heroicon-o-link')
->group('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(15) ->sort(15)
->visible(fn (): bool => ProviderConnectionResource::canViewAny()), ->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
NavigationItem::make('Settings') NavigationItem::make(fn (): string => __('localization.navigation.settings'))
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
->icon('heroicon-o-cog-6-tooth') ->icon('heroicon-o-cog-6-tooth')
->group('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(20) ->sort(20)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -115,12 +115,12 @@ public function panel(Panel $panel): Panel
return $resolver->isMember($user, $workspace) return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
}), }),
NavigationItem::make('Manage workspaces') NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
->url(function (): string { ->url(function (): string {
return route('filament.admin.resources.workspaces.index'); return route('filament.admin.resources.workspaces.index');
}) })
->icon('heroicon-o-squares-2x2') ->icon('heroicon-o-squares-2x2')
->group('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(10) ->sort(10)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -136,15 +136,15 @@ public function panel(Panel $panel): Panel
->whereIn('role', $roles) ->whereIn('role', $roles)
->exists(); ->exists();
}), }),
NavigationItem::make('Operations') NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => route('admin.operations.index')) ->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list') ->icon('heroicon-o-queue-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10), ->sort(10),
NavigationItem::make('Audit Log') NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
->url(fn (): string => route('admin.monitoring.audit-log')) ->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list') ->icon('heroicon-o-clipboard-document-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -210,6 +210,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:admin'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ]);

View File

@ -42,6 +42,14 @@ public function panel(Panel $panel): Panel
PanelsRenderHook::BODY_START, PanelsRenderHook::BODY_START,
fn () => view('filament.system.components.break-glass-banner')->render(), fn () => view('filament.system.components.break-glass-banner')->render(),
) )
->renderHook(
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.locale-switcher', [
'plane' => 'system',
'showPreference' => false,
'embedded' => false,
])->render(),
)
->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages') ->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages')
->pages([ ->pages([
Dashboard::class, Dashboard::class,
@ -59,6 +67,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:system'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL, 'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,

View File

@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel
'primary' => Color::Indigo, 'primary' => Color::Indigo,
]) ])
->navigationItems([ ->navigationItems([
NavigationItem::make(OperationRunLinks::collectionLabel()) NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => route('admin.operations.index')) ->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list') ->icon('heroicon-o-queue-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10), ->sort(10),
NavigationItem::make('Alerts') NavigationItem::make(fn (): string => __('localization.navigation.alerts'))
->url(fn (): string => url('/admin/alerts')) ->url(fn (): string => url('/admin/alerts'))
->icon('heroicon-o-bell-alert') ->icon('heroicon-o-bell-alert')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(20), ->sort(20),
NavigationItem::make('Audit Log') NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
->url(fn (): string => route('admin.monitoring.audit-log')) ->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list') ->icon('heroicon-o-clipboard-document-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -111,6 +111,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:tenant'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ]);

View File

@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonInterface;
final class WorkspaceCommercialLifecycleResolver
{
public const SETTING_DOMAIN = WorkspaceEntitlementResolver::SETTING_DOMAIN;
public const SETTING_COMMERCIAL_LIFECYCLE_STATE = 'commercial_lifecycle_state';
public const SETTING_COMMERCIAL_LIFECYCLE_REASON = 'commercial_lifecycle_reason';
public const STATE_TRIAL = 'trial';
public const STATE_GRACE = 'grace';
public const STATE_ACTIVE_PAID = 'active_paid';
public const STATE_SUSPENDED_READ_ONLY = 'suspended_read_only';
public const SOURCE_DEFAULT_ACTIVE_PAID = 'default_active_paid';
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
public const ACTION_REVIEW_HISTORY_READ = 'review_history_read';
public const ACTION_EVIDENCE_READ = 'evidence_read';
public const ACTION_GENERATED_PACK_READ = 'generated_pack_read';
public const OUTCOME_ALLOW = 'allow';
public const OUTCOME_WARN = 'warn';
public const OUTCOME_BLOCK = 'block';
public const OUTCOME_ALLOW_READ_ONLY = 'allow_read_only';
public const REASON_FAMILY_ENTITLEMENT_SUBSTRATE = 'entitlement_substrate';
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
public function __construct(
private readonly SettingsResolver $settingsResolver,
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
) {}
/**
* @return list<string>
*/
public static function stateIds(): array
{
return [
self::STATE_TRIAL,
self::STATE_GRACE,
self::STATE_ACTIVE_PAID,
self::STATE_SUSPENDED_READ_ONLY,
];
}
/**
* @return array<string, string>
*/
public static function stateLabels(): array
{
return [
self::STATE_TRIAL => 'Trial',
self::STATE_GRACE => 'Grace',
self::STATE_ACTIVE_PAID => 'Active paid',
self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only',
];
}
/**
* @return array<string, string>
*/
public static function stateDescriptions(): array
{
return [
self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.',
self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.',
self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.',
self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.',
];
}
/**
* @return array<string, mixed>
*/
public function summary(Workspace $workspace): array
{
$lifecycle = $this->resolve($workspace);
return $lifecycle + [
'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace),
'action_decisions' => [
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle),
self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle),
self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle),
],
];
}
/**
* @return array{
* workspace_id: int,
* state: string,
* state_label: string,
* source: string,
* source_label: string,
* rationale: string|null,
* description: string,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace): array
{
$stateSetting = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$rawState = is_string($stateSetting['value'] ?? null)
? strtolower(trim((string) $stateSetting['value']))
: null;
$state = in_array($rawState, self::stateIds(), true)
? $rawState
: self::STATE_ACTIVE_PAID;
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
? self::SOURCE_WORKSPACE_SETTING
: self::SOURCE_DEFAULT_ACTIVE_PAID;
$rationale = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$labels = self::stateLabels();
$descriptions = self::stateDescriptions();
$lastChanged = $this->lastChangedMetadata($workspace);
return [
'workspace_id' => (int) $workspace->getKey(),
'state' => $state,
'state_label' => $labels[$state],
'source' => $source,
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
? 'workspace setting'
: 'default active paid',
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
'description' => $descriptions[$state],
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array<string, mixed>|null $lifecycle
* @return array<string, mixed>
*/
public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array
{
$lifecycle ??= $this->resolve($workspace);
return match ($actionKey) {
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ,
self::ACTION_EVIDENCE_READ,
self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle),
default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)),
};
}
/**
* @return array<string, mixed>
*/
public function reviewPackStartDecisionForTenant(Tenant $tenant): array
{
$tenant->loadMissing('workspace');
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Managed-tenant activation is available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_WARN,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Review-pack starts are available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function readOnlyDecision(string $actionKey, array $lifecycle): array
{
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW_READ_ONLY,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
substrateDecision: null,
);
}
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Read-only history remains available under current RBAC.',
substrateDecision: null,
);
}
/**
* @param array<string, mixed> $lifecycle
* @param array<string, mixed>|null $substrateDecision
* @return array<string, mixed>
*/
private function decision(
array $lifecycle,
string $actionKey,
string $outcome,
?string $reasonFamily,
string $message,
?array $substrateDecision,
): array {
return [
'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0),
'action_key' => $actionKey,
'outcome' => $outcome,
'is_blocked' => $outcome === self::OUTCOME_BLOCK,
'is_warning' => $outcome === self::OUTCOME_WARN,
'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null,
'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null,
'message' => $message,
'reason_family' => $reasonFamily,
'state' => (string) $lifecycle['state'],
'state_label' => (string) $lifecycle['state_label'],
'source' => (string) $lifecycle['source'],
'source_label' => (string) $lifecycle['source_label'],
'rationale' => $lifecycle['rationale'] ?? null,
'entitlement_decision' => $substrateDecision,
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$audit = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->where('resource_type', 'workspace_setting')
->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->latest('recorded_at')
->latest('id')
->first();
if ($audit instanceof AuditLog) {
return [
'last_changed_at' => $audit->recorded_at,
'last_changed_by' => $audit->actorDisplayLabel(),
];
}
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
])
->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,
];
}
}

View File

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Services\Localization;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Settings\SettingsResolver;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Request;
class LocaleResolver
{
public const SESSION_OVERRIDE_KEY = 'tenantpilot.locale_override';
public const REQUEST_ATTRIBUTE = 'tenantpilot.resolved_locale';
public const SETTING_DOMAIN = 'localization';
public const SETTING_DEFAULT_LOCALE = 'default_locale';
public const SOURCE_EXPLICIT_OVERRIDE = 'explicit_override';
public const SOURCE_USER_PREFERENCE = 'user_preference';
public const SOURCE_WORKSPACE_DEFAULT = 'workspace_default';
public const SOURCE_SYSTEM_DEFAULT = 'system_default';
/**
* @var list<string>
*/
private const SUPPORTED_LOCALES = ['en', 'de'];
public function __construct(
private SettingsResolver $settingsResolver,
private WorkspaceContext $workspaceContext,
) {}
/**
* @return list<string>
*/
public static function supportedLocales(): array
{
return self::SUPPORTED_LOCALES;
}
/**
* @return array<string, string>
*/
public static function localeOptions(): array
{
return [
'en' => __('localization.locales.en'),
'de' => __('localization.locales.de'),
];
}
public static function isSupported(mixed $locale): bool
{
return self::normalize($locale) !== null;
}
public static function normalize(mixed $locale): ?string
{
if (! is_string($locale)) {
return null;
}
$normalized = strtolower(trim($locale));
return in_array($normalized, self::SUPPORTED_LOCALES, true) ? $normalized : null;
}
/**
* @return array{
* locale: string,
* source: string,
* fallback_locale: string,
* user_preference_locale: ?string,
* workspace_default_locale: ?string,
* machine_artifacts_invariant: true
* }
*/
public function resolve(Request $request, ?string $plane = null): array
{
$plane = $this->normalizePlane($plane, $request);
$explicitOverride = $this->explicitOverride($request);
$systemDefault = (string) config('app.fallback_locale', 'en');
if ($plane === 'system') {
return $this->resolveFromSources(
explicitOverride: $explicitOverride,
userPreference: null,
workspaceDefault: null,
systemDefault: $systemDefault,
includeUserPreference: false,
includeWorkspaceDefault: false,
);
}
$user = $request->user();
$userPreference = $user instanceof User ? $user->preferred_locale : null;
$workspaceDefault = $this->workspaceDefault($request);
return $this->resolveFromSources(
explicitOverride: $explicitOverride,
userPreference: $userPreference,
workspaceDefault: $workspaceDefault,
systemDefault: $systemDefault,
includeUserPreference: true,
includeWorkspaceDefault: true,
);
}
/**
* @return array{
* locale: string,
* source: string,
* fallback_locale: string,
* user_preference_locale: ?string,
* workspace_default_locale: ?string,
* machine_artifacts_invariant: true
* }
*/
public function resolveFromSources(
mixed $explicitOverride,
mixed $userPreference,
mixed $workspaceDefault,
mixed $systemDefault,
bool $includeUserPreference = true,
bool $includeWorkspaceDefault = true,
): array {
$fallbackLocale = self::normalize(config('app.fallback_locale', 'en')) ?? 'en';
$candidates = [
self::SOURCE_EXPLICIT_OVERRIDE => self::normalize($explicitOverride),
];
if ($includeUserPreference) {
$candidates[self::SOURCE_USER_PREFERENCE] = self::normalize($userPreference);
}
if ($includeWorkspaceDefault) {
$candidates[self::SOURCE_WORKSPACE_DEFAULT] = self::normalize($workspaceDefault);
}
$candidates[self::SOURCE_SYSTEM_DEFAULT] = self::normalize($systemDefault) ?? $fallbackLocale;
foreach ($candidates as $source => $locale) {
if ($locale !== null) {
return [
'locale' => $locale,
'source' => $source,
'fallback_locale' => $fallbackLocale,
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
'machine_artifacts_invariant' => true,
];
}
}
return [
'locale' => $fallbackLocale,
'source' => self::SOURCE_SYSTEM_DEFAULT,
'fallback_locale' => $fallbackLocale,
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
'machine_artifacts_invariant' => true,
];
}
private function explicitOverride(Request $request): ?string
{
$queryLocale = self::normalize($request->query('locale'));
if ($queryLocale !== null) {
return $queryLocale;
}
if (! $request->hasSession()) {
return null;
}
return self::normalize($request->session()->get(self::SESSION_OVERRIDE_KEY));
}
private function workspaceDefault(Request $request): ?string
{
$workspace = $this->workspaceContext->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return null;
}
return self::normalize($this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_DEFAULT_LOCALE,
));
}
private function normalizePlane(?string $plane, Request $request): string
{
$plane = strtolower(trim((string) $plane));
if (in_array($plane, ['admin', 'tenant', 'system'], true)) {
return $plane;
}
return $request->is('system', 'system/*') ? 'system' : 'admin';
}
}

View File

@ -14,7 +14,7 @@
use App\Models\TenantReview; use App\Models\TenantReview;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceResolutionRequest; use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver; use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
@ -30,7 +30,7 @@ public function __construct(
private OperationRunService $operationRunService, private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver, private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger, private WorkspaceAuditLogger $auditLogger,
private WorkspaceEntitlementResolver $workspaceEntitlementResolver, private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver,
private ProductTelemetryRecorder $productTelemetryRecorder, private ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
@ -253,10 +253,22 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
*/ */
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{ {
return $this->workspaceEntitlementResolver->resolve( $tenant->loadMissing('workspace');
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
$tenant->workspace, $tenant->workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START,
); );
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
? $decision['entitlement_decision']
: [];
return $decision + [
'effective_value' => $entitlementDecision['effective_value'] ?? null,
'source' => $decision['source'] ?? null,
'current_usage' => $entitlementDecision['current_usage'] ?? null,
'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null,
];
} }
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void

View File

@ -4,6 +4,7 @@
namespace App\Services\Settings; namespace App\Services\Settings;
use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantSetting; use App\Models\TenantSetting;
use App\Models\User; use App\Models\User;
@ -11,11 +12,14 @@
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Settings\SettingDefinition; use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry; use App\Support\Settings\SettingsRegistry;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -33,27 +37,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
{ {
$this->authorizeManage($actor, $workspace); $this->authorizeManage($actor, $workspace);
$definition = $this->requireDefinition($domain, $key); $result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey());
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => (int) $actor->getKey(),
]);
$this->resolver->clearCache(); $this->resolver->clearCache();
@ -67,7 +51,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
'scope' => 'workspace', 'scope' => 'workspace',
'domain' => $domain, 'domain' => $domain,
'key' => $key, 'key' => $key,
'before_value' => $beforeValue, 'before_value' => $result['before_value'],
'after_value' => $afterValue, 'after_value' => $afterValue,
], ],
], ],
@ -76,7 +60,79 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
resourceId: $domain.'.'.$key, resourceId: $domain.'.'.$key,
); );
return $setting; return $result['setting'];
}
public function updateWorkspaceCommercialLifecycle(
PlatformUser $actor,
Workspace $workspace,
string $state,
string $reason,
): void {
$state = strtolower(trim($state));
$reason = trim($reason);
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
}
if ($reason === '') {
throw ValidationException::withMessages([
'reason' => ['A rationale is required when changing commercial lifecycle state.'],
]);
}
DB::transaction(function () use ($actor, $workspace, $state, $reason): void {
$stateResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
value: $state,
updatedByUserId: null,
);
$reasonResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
value: $reason,
updatedByUserId: null,
);
$this->resolver->clearCache();
$afterState = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$afterReason = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSettingUpdated->value,
context: [
'metadata' => [
'scope' => 'workspace',
'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
'before_state' => $stateResult['before_value'],
'after_state' => $afterState,
'before_reason' => $reasonResult['before_value'],
'after_reason' => $afterReason,
],
],
actor: $actor,
resourceType: 'workspace_setting',
resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
targetLabel: 'Commercial lifecycle state',
);
});
} }
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
@ -174,6 +230,39 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti
]); ]);
} }
/**
* @return array{setting: WorkspaceSetting, before_value: mixed}
*/
private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array
{
$definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => $updatedByUserId,
]);
return [
'setting' => $setting,
'before_value' => $beforeValue,
];
}
private function validatedValue(SettingDefinition $definition, mixed $value): mixed private function validatedValue(SettingDefinition $definition, mixed $value): mixed
{ {
$validator = Validator::make( $validator = Validator::make(

View File

@ -18,6 +18,8 @@ class PlatformCapabilities
public const DIRECTORY_VIEW = 'platform.directory.view'; public const DIRECTORY_VIEW = 'platform.directory.view';
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
public const OPERATIONS_VIEW = 'platform.operations.view'; public const OPERATIONS_VIEW = 'platform.operations.view';
public const OPERATIONS_MANAGE = 'platform.operations.manage'; public const OPERATIONS_MANAGE = 'platform.operations.manage';

View File

@ -57,6 +57,7 @@ final class BadgeCatalog
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class, BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class, BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,

View File

@ -48,6 +48,7 @@ enum BadgeDomain: string
case BaselineProfileStatus = 'baseline_profile_status'; case BaselineProfileStatus = 'baseline_profile_status';
case FindingType = 'finding_type'; case FindingType = 'finding_type';
case ReviewPackStatus = 'review_pack_status'; case ReviewPackStatus = 'review_pack_status';
case CommercialLifecycleState = 'commercial_lifecycle_state';
case EvidenceSnapshotStatus = 'evidence_snapshot_status'; case EvidenceSnapshotStatus = 'evidence_snapshot_status';
case EvidenceCompleteness = 'evidence_completeness'; case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status'; case TenantReviewStatus = 'tenant_review_status';

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class CommercialLifecycleStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
WorkspaceCommercialLifecycleResolver::STATE_TRIAL => new BadgeSpec('Trial', 'info', 'heroicon-m-clock'),
WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'),
WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'),
WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -4,8 +4,10 @@
namespace App\Support\Settings; namespace App\Support\Settings;
use App\Support\Ai\AiPolicyMode;
use App\Models\Finding; use App\Models\Finding;
use App\Services\Localization\LocaleResolver;
use App\Support\Ai\AiPolicyMode;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry final class SettingsRegistry
@ -28,6 +30,25 @@ public function __construct()
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)), normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
)); ));
$this->register(new SettingDefinition(
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
type: 'string',
systemDefault: null,
rules: [
'nullable',
'string',
'in:'.implode(',', LocaleResolver::supportedLocales()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
return LocaleResolver::normalize($value);
},
));
$this->register(new SettingDefinition( $this->register(new SettingDefinition(
domain: 'backup', domain: 'backup',
key: 'retention_keep_last_default', key: 'retention_keep_last_default',
@ -314,6 +335,44 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
return $normalized === '' ? null : $normalized; return $normalized === '' ? null : $normalized;
}, },
)); ));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
type: 'string',
systemDefault: null,
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspaceCommercialLifecycleResolver::stateIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = strtolower(trim((string) $value));
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_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

@ -749,12 +749,17 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery', 'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'harmless_special_case', 'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'read_mostly_context_detail', 'reasonCategory' => 'read_mostly_context_detail',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.', 'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.',
'evidence' => [ 'evidence' => [
[ [
'kind' => 'feature_livewire_test', 'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php', 'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.', 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php',
'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.',
], ],
[ [
'kind' => 'authorization_test', 'kind' => 'authorization_test',

View File

@ -8,6 +8,8 @@ final class WorkspaceResolver
{ {
public function resolve(string $value): ?Workspace public function resolve(string $value): ?Workspace
{ {
$value = $this->normalizeRouteValue($value);
$workspace = Workspace::query() $workspace = Workspace::query()
->where('slug', $value) ->where('slug', $value)
->first(); ->first();
@ -22,4 +24,37 @@ public function resolve(string $value): ?Workspace
return Workspace::query()->whereKey((int) $value)->first(); return Workspace::query()->whereKey((int) $value)->first();
} }
private function normalizeRouteValue(string $value): string
{
$value = trim($value);
if (! str_starts_with($value, '{')) {
return $value;
}
$decoded = json_decode($value, true);
if (! is_array($decoded)) {
return $value;
}
$slug = $decoded['slug'] ?? null;
if (is_string($slug) && $slug !== '') {
return $slug;
}
$id = $decoded['id'] ?? null;
if (is_int($id)) {
return (string) $id;
}
if (is_string($id) && ctype_digit($id)) {
return $id;
}
return $value;
}
} }

View File

@ -4,6 +4,7 @@
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\ApplyResolvedLocale;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests; use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests; use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
@ -24,7 +25,12 @@
UseSystemSessionCookieForLivewireRequests::class, UseSystemSessionCookieForLivewireRequests::class,
]); ]);
$middleware->web(append: [
ApplyResolvedLocale::class,
]);
$middleware->alias([ $middleware->alias([
'apply-resolved-locale' => ApplyResolvedLocale::class,
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('preferred_locale', 8)
->nullable()
->after('last_workspace_id')
->index();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('preferred_locale');
});
}
};

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
return [
'duplicate_warning_title' => 'Warnung',
'duplicate_warning_body_plural' => ':count Policies in diesem Tenant verwenden generische Anzeigenamen, dadurch entstehen :ambiguous_count mehrdeutige Subjekte. :app kann sie nicht sicher mit der Baseline abgleichen.',
'duplicate_warning_body_singular' => ':count Policy in diesem Tenant verwendet einen generischen Anzeigenamen, dadurch entsteht :ambiguous_count mehrdeutiges Subjekt. :app kann es nicht sicher mit der Baseline abgleichen.',
'stat_assigned_baseline' => 'Zugewiesene Baseline',
'stat_total_findings' => 'Findings gesamt',
'stat_last_compared' => 'Zuletzt verglichen',
'stat_last_compared_never' => 'Nie',
'stat_error' => 'Fehler',
'badge_snapshot' => 'Snapshot #:id',
'badge_coverage_ok' => 'Abdeckung: OK',
'badge_coverage_warnings' => 'Abdeckung: Warnungen',
'badge_fidelity' => 'Fidelity: :level',
'badge_evidence_gaps' => 'Evidence Gaps: :count',
'evidence_gaps_tooltip' => 'Wichtigste Gaps: :summary',
'evidence_gap_details_heading' => 'Evidence-Gap-Details',
'evidence_gap_details_description' => 'Durchsuchen Sie aufgezeichnete Gap-Subjekte nach Grund, Governed Subject, Subjektklasse, Ergebnis, naechster Aktion oder Subject Key, bevor Sie Rohdiagnosen verwenden.',
'evidence_gap_search_label' => 'Gap-Details suchen',
'evidence_gap_search_placeholder' => 'Nach Grund, Typ, Klasse, Ergebnis, Aktion oder Subject Key suchen',
'evidence_gap_search_help' => 'Filtert ueber Grund, Governed Subject, Subjektklasse, Ergebnis, naechste Aktion und Subject Key.',
'evidence_gap_bucket_help_ambiguous_match' => 'Mehrere Inventory-Datensaetze passten zum gleichen Policy-Subjekt. Pruefen Sie das Mapping.',
'evidence_gap_bucket_help_policy_record_missing' => 'Der erwartete Policy-Datensatz wurde im Baseline-Snapshot nicht gefunden. Pruefen Sie, ob die Policy im Tenant noch existiert.',
'evidence_gap_bucket_help_inventory_record_missing' => 'Fuer diese Subjekte konnte kein Inventory-Datensatz gefunden werden. Pruefen Sie, ob der Inventory Sync aktuell ist.',
'evidence_gap_bucket_help_foundation_not_policy_backed' => 'Diese Subjekte existieren in der Foundation-Schicht, sind aber nicht durch eine verwaltete Policy abgedeckt. Pruefen Sie, ob eine Policy erstellt werden sollte.',
'evidence_gap_bucket_help_capture_failed' => 'Evidence Capture ist fuer diese Subjekte fehlgeschlagen. Wiederholen Sie den Vergleich oder pruefen Sie die Graph-Konnektivitaet.',
'evidence_gap_bucket_help_default' => 'Diese Subjekte wurden beim Vergleich markiert. Pruefen Sie die betroffenen Zeilen.',
'evidence_gap_reason' => 'Grund',
'evidence_gap_reason_affected' => ':count betroffen',
'evidence_gap_reason_recorded' => ':count aufgezeichnet',
'evidence_gap_reason_missing_detail' => ':count ohne Detail',
'evidence_gap_structural' => 'Strukturell: :count',
'evidence_gap_operational' => 'Operativ: :count',
'evidence_gap_transient' => 'Temporaer: :count',
'evidence_gap_bucket_structural' => ':count strukturell',
'evidence_gap_bucket_operational' => ':count operativ',
'evidence_gap_bucket_transient' => ':count temporaer',
'evidence_gap_missing_details_title' => 'Fuer diesen Run wurden keine Detailzeilen aufgezeichnet',
'evidence_gap_missing_details_body' => 'Evidence Gaps wurden fuer diesen Compare Run gezaehlt, aber Details auf Subjektebene wurden nicht gespeichert. Pruefen Sie Rohdiagnosen oder wiederholen Sie den Vergleich.',
'evidence_gap_missing_reason_body' => ':count betroffene Subjekte wurden fuer diesen Grund gezaehlt, aber Detailzeilen wurden nicht aufgezeichnet.',
'evidence_gap_legacy_title' => 'Legacy-Development-Gap-Payload erkannt',
'evidence_gap_legacy_body' => 'Dieser Run verwendet noch die retired breite Grundform. Erzeugen Sie den Run neu oder bereinigen Sie alte lokale Development-Payloads.',
'evidence_gap_diagnostics_heading' => 'Baseline-Compare-Evidence',
'evidence_gap_diagnostics_description' => 'Rohdiagnosen bleiben fuer Support und tiefere Fehlersuche nach Operator-Zusammenfassung und Detailansicht verfuegbar.',
'evidence_gap_policy_type' => 'Governed Subject',
'evidence_gap_subject_class' => 'Subjektklasse',
'evidence_gap_outcome' => 'Ergebnis',
'evidence_gap_next_action' => 'Naechste Aktion',
'evidence_gap_subject_key' => 'Subject Key',
'evidence_gap_table_empty_heading' => 'Keine aufgezeichneten Gap-Zeilen passen zu dieser Ansicht',
'evidence_gap_table_empty_description' => 'Passen Sie Suche oder Filter an, um andere betroffene Subjekte zu pruefen.',
'comparing_indicator' => 'Vergleich laeuft...',
'no_findings_all_clear' => 'Kein bestaetigter Drift im letzten Vergleich',
'no_findings_coverage_warnings' => 'Kein Drift angezeigt, aber Coverage limitiert diesen Vergleich',
'no_findings_evidence_gaps' => 'Kein Drift angezeigt, aber Evidence Gaps muessen geprueft werden',
'no_findings_default' => 'Aktuell sind keine Drift Findings sichtbar',
'coverage_warning_title' => 'Vergleich mit Warnungen abgeschlossen',
'coverage_unproven_body' => 'Coverage Proof fehlte oder war nicht lesbar. Findings wurden aus Sicherheitsgruenden unterdrueckt.',
'coverage_incomplete_body' => 'Findings wurden fuer :count Policy :types wegen unvollstaendiger Coverage uebersprungen.',
'coverage_uncovered_label' => 'Nicht abgedeckt: :list',
'failed_title' => 'Vergleich fehlgeschlagen',
'failed_body_default' => 'Der letzte Baseline-Vergleich ist fehlgeschlagen. Pruefen Sie die Run-Details oder wiederholen Sie ihn.',
'critical_drift_title' => 'Kritischer Drift erkannt',
'critical_drift_body' => 'Der aktuelle Tenant-Zustand weicht von Baseline :profile ab. :count High-Severity :findings erfordern sofortige Aufmerksamkeit.',
'empty_no_tenant' => 'Kein Tenant ausgewaehlt',
'empty_no_assignment' => 'Keine Baseline zugewiesen',
'empty_no_snapshot' => 'Kein Snapshot verfuegbar',
'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.',
'rbac_summary_title' => 'Intune-RBAC-Rollendefinitionen',
'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
'rbac_summary_compared' => 'Verglichen',
'rbac_summary_unchanged' => 'Unveraendert',
'rbac_summary_modified' => 'Geaendert',
'rbac_summary_missing' => 'Fehlend',
'rbac_summary_unexpected' => 'Unerwartet',
'no_drift_title' => 'Kein Drift erkannt',
'no_drift_body' => 'Der letzte Vergleich hat keinen bestaetigten Drift fuer das zugewiesene Baseline-Profil aufgezeichnet.',
'coverage_warnings_title' => 'Coverage-Warnungen',
'coverage_warnings_body' => 'Der letzte Vergleich wurde mit Warnungen abgeschlossen und erzeugte keine bestaetigten Drift Findings. Aktualisieren Sie Evidence, bevor Sie dies als Entwarnung werten.',
'idle_title' => 'Bereit zum Vergleich',
'button_view_run' => 'Run anzeigen',
'button_view_failed_run' => 'Fehlgeschlagenen Run anzeigen',
'button_view_findings' => 'Alle Findings anzeigen',
'button_review_last_run' => 'Letzten Run pruefen',
];

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
return [
'drift' => [
'rbac_role_definition' => 'Intune-RBAC-Rollendefinitions-Drift',
],
'subject_types' => [
'policy' => 'Policy',
'intuneRoleDefinition' => 'Intune-RBAC-Rollendefinition',
],
'rbac' => [
'detail_heading' => 'Intune-RBAC-Rollendefinitions-Drift',
'detail_subheading' => 'Rollenzuweisungen sind nicht enthalten. RBAC-Restore wird nicht unterstuetzt.',
'metadata_only' => 'Nur Metadaten geaendert',
'permission_change' => 'Berechtigung geaendert',
'missing' => 'Im aktuellen Tenant fehlend',
'unexpected' => 'Unerwartet im aktuellen Tenant',
'changed_fields' => 'Geaenderte Felder',
'baseline' => 'Baseline',
'current' => 'Aktuell',
'absent' => 'Nicht vorhanden',
'role_source' => 'Rollenquelle',
'permission_blocks' => 'Berechtigungsbloecke',
'built_in' => 'Integriert',
'custom' => 'Benutzerdefiniert',
'assignments_excluded' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
'restore_unsupported' => 'RBAC-Restore wird in diesem Release nicht unterstuetzt.',
],
];

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
return [
'locales' => [
'en' => 'Englisch',
'de' => 'Deutsch',
],
'source' => [
'explicit_override' => 'Sitzungsueberschreibung',
'user_preference' => 'persoenliche Einstellung',
'workspace_default' => 'Workspace-Standard',
'workspace_override' => 'Workspace-Ueberschreibung',
'system_default' => 'Systemstandard',
],
'shell' => [
'language' => 'Sprache',
'current_language' => 'Aktuelle Sprache',
'language_source' => 'Quelle: :source',
'temporary_override' => 'Temporaere Ueberschreibung',
'switch_language' => 'Sprache wechseln',
'clear_override' => 'Geerbte Sprache verwenden',
'personal_preference' => 'Persoenliche Einstellung',
'save_preference' => 'Einstellung speichern',
'inherit_workspace' => 'Workspace-Standard verwenden',
'workspace' => 'Workspace',
'choose_workspace' => 'Workspace auswaehlen',
'switch_workspace' => 'Workspace wechseln',
'workspace_home' => 'Workspace-Start',
'tenant_scope' => 'Tenant-Kontext',
'select_tenant' => 'Tenant auswaehlen',
'selected_tenant' => 'Ausgewaehlter Tenant',
'no_tenant_selected' => 'Kein Tenant ausgewaehlt',
'switch_tenant' => 'Tenant wechseln',
'clear_tenant_scope' => 'Tenant-Kontext loeschen',
'context_unavailable' => 'Kontext nicht verfuegbar',
'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gueltigen Workspace-Kontext.',
'context_unavailable_no_workspace' => 'Waehlen Sie einen Workspace aus, um mit einem gueltigen Admin-Kontext fortzufahren.',
'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants fuer den Standardbetrieb verfuegbar.',
'view_managed_tenants' => 'Managed Tenants anzeigen',
'workspace_wide_available' => 'Kein Tenant ausgewaehlt. Workspace-weite Seiten bleiben verfuegbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.',
'search_tenants' => 'Tenants suchen...',
'choose_workspace_first' => 'Waehlen Sie zuerst einen Workspace aus.',
],
'workspace' => [
'title' => 'Workspace-Einstellungen',
'save' => 'Speichern',
'reset' => 'Zuruecksetzen',
'no_manage_permission' => 'Sie haben keine Berechtigung zum Verwalten der Workspace-Einstellungen.',
'no_workspace_override' => 'Keine Workspace-Ueberschreibung zum Zuruecksetzen vorhanden.',
'last_modified_by' => ':description - Zuletzt geaendert von :user, :time.',
'section' => 'Lokalisierung',
'section_description' => 'Workspace-Standard fuer Benutzer ohne persoenliche Spracheinstellung.',
'default_locale_label' => 'Standardsprache',
'default_locale_placeholder' => 'Nicht gesetzt (verwendet Systemstandard)',
'default_locale_helper_unset' => 'Nicht gesetzt. Effektive Sprache: :locale (:source).',
'default_locale_helper_set' => 'Effektive Sprache: :locale.',
],
'auth' => [
'microsoft_not_configured' => 'Microsoft-Anmeldung ist nicht konfiguriert.',
'sign_in_microsoft' => 'Mit Microsoft anmelden',
'tenant_admin_membership_required' => 'Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft.',
],
'navigation' => [
'findings' => 'Findings',
'settings' => 'Einstellungen',
'integrations' => 'Integrationen',
'manage_workspaces' => 'Workspaces verwalten',
'operations' => 'Operationen',
'audit_log' => 'Audit-Log',
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
'dashboard' => 'Dashboard',
],
'dashboard' => [
'tenant_title' => 'Tenant-Dashboard',
'system_title' => 'System-Dashboard',
'request_support' => 'Support anfragen',
'support_request_heading' => 'Support anfragen',
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fuegt redaktionell bereinigten Kontext aus bestehenden Datensaetzen hinzu.',
'submit_request' => 'Anfrage senden',
'included_context' => 'Enthaltener Kontext',
'severity' => 'Schweregrad',
'summary' => 'Zusammenfassung',
'reproduction_notes' => 'Reproduktionshinweise',
'contact_name' => 'Kontaktname',
'contact_email' => 'Kontakt-E-Mail',
'support_request_submitted' => 'Supportanfrage gesendet',
'open_support_diagnostics' => 'Supportdiagnosen oeffnen',
'support_diagnostics' => 'Supportdiagnosen',
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensaetzen.',
'close' => 'Schliessen',
'time_window' => 'Zeitfenster',
'window' => 'Fenster',
'enter_break_glass' => 'Break-Glass-Modus aktivieren',
'exit_break_glass' => 'Break-Glass beenden',
'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert',
'recovery_mode_ended' => 'Wiederherstellungsmodus beendet',
],
'review' => [
'reporting' => 'Berichte',
'customer_reviews' => 'Kundenreviews',
'customer_review_workspace' => 'Kundenreview-Workspace',
'customer_safe_review_workspace' => 'Kundensicherer Review-Workspace',
'customer_workspace_intro' => 'Pruefen Sie den zuletzt veroeffentlichten kundensicheren Status fuer jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.',
'customer_workspace_canonical_note' => 'Eine Zeile oeffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfaehige Nachweise auf ihren kanonischen tenantbezogenen Oberflaechen bleiben.',
'reviews' => 'Reviews',
'clear_filters' => 'Filter loeschen',
'tenant' => 'Tenant',
'latest_review' => 'Letztes Review',
'key_findings' => 'Wichtige Findings',
'accepted_risks' => 'Akzeptierte Risiken',
'published' => 'Veroeffentlicht',
'review_pack' => 'Review-Pack',
'open_latest_review' => 'Letztes Review oeffnen',
'download_review_pack' => 'Review-Pack herunterladen',
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
'clear_filters_description' => 'Loeschen Sie die aktuellen Filter, um zum vollstaendigen Kundenreview-Workspace fuer Ihre berechtigten Tenants zurueckzukehren.',
'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollstaendigen Kundenreview-Workspace fuer Ihre berechtigten Tenants zurueckzukehren.',
'no_published_review' => 'Kein veroeffentlichtes Review',
'no_published_review_available' => 'Noch kein veroeffentlichtes Review verfuegbar',
'no_findings_recorded' => 'Im veroeffentlichten Review sind keine Findings erfasst.',
'findings_count_summary' => ':count Findings im veroeffentlichten Review zusammengefasst.',
'findings_count_with_outcomes' => ':count Findings. Terminale Ergebnisse: :outcomes.',
'no_accepted_risks_recorded' => 'Keine akzeptierten Risiken erfasst.',
'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benoetigen Governance-Nacharbeit (:total gesamt).',
'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.',
'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.',
'unavailable' => 'Nicht verfuegbar',
'available' => 'Verfuegbar',
'outcome_summary' => 'Ergebniszusammenfassung',
'review' => 'Review',
'review_date' => 'Review-Datum',
'completeness' => 'Vollstaendigkeit',
'evidence_snapshot' => 'Evidence-Snapshot',
'current_export' => 'Aktueller Export',
'executive_posture' => 'Executive-Status',
'sections' => 'Abschnitte',
'details' => 'Details',
'export_executive_pack' => 'Executive-Pack exportieren',
'outcome' => 'Ergebnis',
'export' => 'Export',
'next_step' => 'Naechster Schritt',
'no_tenant_reviews_yet' => 'Noch keine Tenant-Reviews',
'create_first_review_description' => 'Erstellen Sie das erste Review aus einem verankerten Evidence-Snapshot, um die wiederkehrende Review-Historie fuer diesen Tenant zu starten.',
'create_first_review' => 'Erstes Review erstellen',
'create_review' => 'Review erstellen',
'evidence_basis' => 'Evidence-Basis',
'evidence_basis_helper' => 'Waehlen Sie den verankerten Evidence-Snapshot fuer dieses Review.',
'unable_create_missing_context' => 'Review kann nicht erstellt werden - Kontext fehlt.',
'select_valid_evidence_snapshot' => 'Waehlen Sie einen gueltigen Evidence-Snapshot aus.',
'unable_create_review' => 'Review kann nicht erstellt werden',
'review_already_available' => 'Review bereits verfuegbar',
'review_already_available_body' => 'Ein passendes veraenderbares Review ist fuer diese Evidence-Basis bereits vorhanden.',
'view_review' => 'Review anzeigen',
'open_operation' => 'Operation oeffnen',
'review_composing_background' => 'Das Review wird im Hintergrund zusammengestellt.',
'unable_export_missing_context' => 'Review kann nicht exportiert werden - Kontext fehlt.',
'export_already_queued_body' => 'Ein Executive-Pack-Export ist fuer dieses Review bereits eingereiht oder laeuft.',
'executive_pack_export_unavailable' => 'Executive-Pack-Export nicht verfuegbar',
'unable_export_executive_pack' => 'Executive-Pack kann nicht exportiert werden',
'executive_pack_already_available' => 'Executive-Pack bereits verfuegbar',
'executive_pack_already_available_body' => 'Ein passendes Executive-Pack ist fuer dieses Review bereits vorhanden.',
'view_pack' => 'Pack anzeigen',
'executive_pack_generating_background' => 'Das Executive-Pack wird im Hintergrund erstellt.',
'review_explanation' => 'Review-Erklaerung',
'reason_owner' => 'Reason Owner',
'platform_core' => 'Platform Core',
'platform_reason_family' => 'Platform-Reason-Familie',
'compatibility' => 'Kompatibilitaet',
'highlights' => 'Highlights',
'next_actions' => 'Naechste Aktionen',
'related_context' => 'Verwandter Kontext',
'publication_readiness' => 'Veroeffentlichungsreife',
'ready_for_publication' => 'Dieses Review ist bereit fuer Veroeffentlichung und Executive-Pack-Export.',
'internal_only' => 'Dieses Review ist aktuell nur fuer interne Nutzung geeignet.',
'needs_follow_up' => 'Dieses Review benoetigt vor der Veroeffentlichung noch Nacharbeit.',
'key_entries' => 'Wichtige Eintraege',
'entry' => 'Eintrag',
'follow_up' => 'Follow-up',
'diagnostics' => 'Diagnosen',
'result_meaning' => 'Ergebnisbedeutung',
'result_trust' => 'Ergebnisvertrauen',
'artifact_truth' => 'Artifact Truth',
'no_action_needed' => 'Keine Aktion erforderlich',
'count' => 'Anzahl',
'guidance' => 'Orientierung',
'findings' => 'Findings',
'reports' => 'Berichte',
'operations' => 'Operationen',
'pending_verification' => 'Verifizierung ausstehend',
'verified_cleared' => 'Verifiziert bereinigt',
'terminal_outcomes' => 'Terminale Ergebnisse',
'pending' => 'Ausstehend',
'operation' => 'Operation',
'operation_description' => 'Pruefen Sie die letzte Review-Zusammenstellung oder den Aktualisierungslauf.',
'executive_pack' => 'Executive-Pack',
'view_executive_pack' => 'Executive-Pack anzeigen',
'executive_pack_description' => 'Oeffnet den aktuellen Export, der zu diesem Review gehoert.',
'customer_workspace' => 'Kunden-Workspace',
'open_customer_workspace' => 'Kunden-Workspace oeffnen',
'customer_workspace_description' => 'Oeffnet den kundensicheren Review-Workspace mit Filter auf diesen Tenant.',
'view_evidence_snapshot' => 'Evidence-Snapshot anzeigen',
'evidence_snapshot_description' => 'Zur Evidence-Basis hinter diesem Review zurueckkehren.',
],
'findings' => [
'all' => 'Alle',
'needs_action' => 'Handlungsbedarf',
'overdue' => 'Ueberfaellig',
'risk_accepted' => 'Risiko akzeptiert',
'resolved' => 'Geloest',
'actions' => 'Aktionen',
'open_approval_queue' => 'Freigabewarteschlange oeffnen',
],
'notifications' => [
'locale_override_saved' => 'Sprachueberschreibung angewendet.',
'locale_override_cleared' => 'Sprachueberschreibung geloescht.',
'user_preference_saved' => 'Spracheinstellung gespeichert.',
'user_preference_cleared' => 'Spracheinstellung geloescht.',
'workspace_settings_saved' => 'Workspace-Einstellungen gespeichert',
'workspace_settings_unchanged' => 'Keine Einstellungsaenderungen zu speichern',
'workspace_setting_reset' => 'Workspace-Einstellung auf Standard zurueckgesetzt',
'setting_already_default' => 'Einstellung verwendet bereits den Standard',
],
'validation' => [
'unsupported_locale' => 'Waehlen Sie eine unterstuetzte Sprache.',
],
];

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
return [
'locales' => [
'en' => 'English',
'de' => 'German',
],
'source' => [
'explicit_override' => 'session override',
'user_preference' => 'personal preference',
'workspace_default' => 'workspace default',
'workspace_override' => 'workspace override',
'system_default' => 'system default',
],
'shell' => [
'language' => 'Language',
'current_language' => 'Current language',
'language_source' => 'Source: :source',
'temporary_override' => 'Temporary override',
'switch_language' => 'Switch language',
'clear_override' => 'Use inherited language',
'personal_preference' => 'Personal preference',
'save_preference' => 'Save preference',
'inherit_workspace' => 'Use workspace default',
'workspace' => 'Workspace',
'choose_workspace' => 'Choose workspace',
'switch_workspace' => 'Switch workspace',
'workspace_home' => 'Workspace Home',
'tenant_scope' => 'Tenant scope',
'select_tenant' => 'Select tenant',
'selected_tenant' => 'Selected tenant',
'no_tenant_selected' => 'No tenant selected',
'switch_tenant' => 'Switch tenant',
'clear_tenant_scope' => 'Clear tenant scope',
'context_unavailable' => 'Context unavailable',
'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.',
'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.',
'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.',
'view_managed_tenants' => 'View managed tenants',
'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.',
'search_tenants' => 'Search tenants...',
'choose_workspace_first' => 'Choose a workspace first.',
],
'workspace' => [
'title' => 'Workspace settings',
'save' => 'Save',
'reset' => 'Reset',
'no_manage_permission' => 'You do not have permission to manage workspace settings.',
'no_workspace_override' => 'No workspace override to reset.',
'last_modified_by' => ':description - Last modified by :user, :time.',
'section' => 'Localization settings',
'section_description' => 'Workspace default used by users without a personal language preference.',
'default_locale_label' => 'Default language',
'default_locale_placeholder' => 'Unset (uses system default)',
'default_locale_helper_unset' => 'Unset. Effective language: :locale (:source).',
'default_locale_helper_set' => 'Effective language: :locale.',
],
'auth' => [
'microsoft_not_configured' => 'Microsoft sign-in is not configured.',
'sign_in_microsoft' => 'Sign in with Microsoft',
'tenant_admin_membership_required' => 'Tenant Admin access requires a tenant membership.',
],
'navigation' => [
'findings' => 'Findings',
'settings' => 'Settings',
'integrations' => 'Integrations',
'manage_workspaces' => 'Manage workspaces',
'operations' => 'Operations',
'audit_log' => 'Audit Log',
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
'dashboard' => 'Dashboard',
],
'dashboard' => [
'tenant_title' => 'Tenant dashboard',
'system_title' => 'System dashboard',
'request_support' => 'Request support',
'support_request_heading' => 'Request support',
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
'submit_request' => 'Submit request',
'included_context' => 'Included context',
'severity' => 'Severity',
'summary' => 'Summary',
'reproduction_notes' => 'Reproduction notes',
'contact_name' => 'Contact name',
'contact_email' => 'Contact email',
'support_request_submitted' => 'Support request submitted',
'open_support_diagnostics' => 'Open support diagnostics',
'support_diagnostics' => 'Support diagnostics',
'support_diagnostics_description' => 'Redacted tenant context from existing records.',
'close' => 'Close',
'time_window' => 'Time window',
'window' => 'Window',
'enter_break_glass' => 'Enter break-glass mode',
'exit_break_glass' => 'Exit break-glass',
'recovery_mode_enabled' => 'Recovery mode enabled',
'recovery_mode_ended' => 'Recovery mode ended',
],
'review' => [
'reporting' => 'Reporting',
'customer_reviews' => 'Customer reviews',
'customer_review_workspace' => 'Customer Review Workspace',
'customer_safe_review_workspace' => 'Customer-safe review workspace',
'customer_workspace_intro' => 'Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.',
'customer_workspace_canonical_note' => 'Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.',
'reviews' => 'Reviews',
'clear_filters' => 'Clear filters',
'tenant' => 'Tenant',
'latest_review' => 'Latest review',
'key_findings' => 'Key findings',
'accepted_risks' => 'Accepted risks',
'published' => 'Published',
'review_pack' => 'Review pack',
'open_latest_review' => 'Open latest review',
'download_review_pack' => 'Download review pack',
'no_entitled_tenants' => 'No entitled tenants match this view',
'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.',
'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.',
'no_published_review' => 'No published review',
'no_published_review_available' => 'No published review available yet',
'no_findings_recorded' => 'No findings recorded in the published review.',
'findings_count_summary' => ':count findings summarized in the published review.',
'findings_count_with_outcomes' => ':count findings. Terminal outcomes: :outcomes.',
'no_accepted_risks_recorded' => 'No accepted risks recorded.',
'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).',
'accepted_risks_governed' => ':count accepted risks are governed.',
'accepted_risks_on_record' => ':count accepted risks are on record.',
'unavailable' => 'Unavailable',
'available' => 'Available',
'outcome_summary' => 'Outcome summary',
'review' => 'Review',
'review_date' => 'Review date',
'completeness' => 'Completeness',
'evidence_snapshot' => 'Evidence snapshot',
'current_export' => 'Current export',
'executive_posture' => 'Executive posture',
'sections' => 'Sections',
'details' => 'Details',
'export_executive_pack' => 'Export executive pack',
'outcome' => 'Outcome',
'export' => 'Export',
'next_step' => 'Next step',
'no_tenant_reviews_yet' => 'No tenant reviews yet',
'create_first_review_description' => 'Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.',
'create_first_review' => 'Create first review',
'create_review' => 'Create review',
'evidence_basis' => 'Evidence basis',
'evidence_basis_helper' => 'Choose the anchored evidence snapshot for this review.',
'unable_create_missing_context' => 'Unable to create review - missing context.',
'select_valid_evidence_snapshot' => 'Select a valid evidence snapshot.',
'unable_create_review' => 'Unable to create review',
'review_already_available' => 'Review already available',
'review_already_available_body' => 'A matching mutable review already exists for this evidence basis.',
'view_review' => 'View review',
'open_operation' => 'Open operation',
'review_composing_background' => 'The review is being composed in the background.',
'unable_export_missing_context' => 'Unable to export review - missing context.',
'export_already_queued_body' => 'An executive pack export is already queued or running for this review.',
'executive_pack_export_unavailable' => 'Executive pack export unavailable',
'unable_export_executive_pack' => 'Unable to export executive pack',
'executive_pack_already_available' => 'Executive pack already available',
'executive_pack_already_available_body' => 'A matching executive pack already exists for this review.',
'view_pack' => 'View pack',
'executive_pack_generating_background' => 'The executive pack is being generated in the background.',
'review_explanation' => 'Review explanation',
'reason_owner' => 'Reason owner',
'platform_core' => 'Platform core',
'platform_reason_family' => 'Platform reason family',
'compatibility' => 'Compatibility',
'highlights' => 'Highlights',
'next_actions' => 'Next actions',
'related_context' => 'Related context',
'publication_readiness' => 'Publication readiness',
'ready_for_publication' => 'This review is ready for publication and executive-pack export.',
'internal_only' => 'This review is currently safe for internal use only.',
'needs_follow_up' => 'This review still needs follow-up before publication.',
'key_entries' => 'Key entries',
'entry' => 'Entry',
'follow_up' => 'Follow-up',
'diagnostics' => 'Diagnostics',
'result_meaning' => 'Result meaning',
'result_trust' => 'Result trust',
'artifact_truth' => 'Artifact truth',
'no_action_needed' => 'No action needed',
'count' => 'Count',
'guidance' => 'Guidance',
'findings' => 'Findings',
'reports' => 'Reports',
'operations' => 'Operations',
'pending_verification' => 'Pending verification',
'verified_cleared' => 'Verified cleared',
'terminal_outcomes' => 'Terminal outcomes',
'pending' => 'Pending',
'operation' => 'Operation',
'operation_description' => 'Inspect the latest review composition or refresh run.',
'executive_pack' => 'Executive pack',
'view_executive_pack' => 'View executive pack',
'executive_pack_description' => 'Open the current export that belongs to this review.',
'customer_workspace' => 'Customer workspace',
'open_customer_workspace' => 'Open customer workspace',
'customer_workspace_description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
'view_evidence_snapshot' => 'View evidence snapshot',
'evidence_snapshot_description' => 'Return to the evidence basis behind this review.',
],
'findings' => [
'all' => 'All',
'needs_action' => 'Needs action',
'overdue' => 'Overdue',
'risk_accepted' => 'Risk accepted',
'resolved' => 'Resolved',
'actions' => 'Actions',
'open_approval_queue' => 'Open approval queue',
],
'notifications' => [
'locale_override_saved' => 'Language override applied.',
'locale_override_cleared' => 'Language override cleared.',
'user_preference_saved' => 'Language preference saved.',
'user_preference_cleared' => 'Language preference cleared.',
'workspace_settings_saved' => 'Workspace settings saved',
'workspace_settings_unchanged' => 'No settings changes to save',
'workspace_setting_reset' => 'Workspace setting reset to default',
'setting_already_default' => 'Setting already uses default',
],
'validation' => [
'unsupported_locale' => 'Choose a supported language.',
],
];

View File

@ -37,7 +37,7 @@
$compressedOutcome['primaryLabel'] ?? null, $compressedOutcome['primaryLabel'] ?? null,
$state['primaryLabel'] ?? null, $state['primaryLabel'] ?? null,
$operatorExplanation['headline'] ?? null, $operatorExplanation['headline'] ?? null,
'Artifact truth', __('localization.review.artifact_truth'),
]); ]);
$primaryReason = $firstArtifactTruthText([ $primaryReason = $firstArtifactTruthText([
$compressedOutcome['primaryReason'] ?? null, $compressedOutcome['primaryReason'] ?? null,
@ -49,7 +49,7 @@
$compressedOutcome['nextActionText'] ?? null, $compressedOutcome['nextActionText'] ?? null,
data_get($operatorExplanation, 'nextAction.text'), data_get($operatorExplanation, 'nextAction.text'),
$state['nextActionLabel'] ?? null, $state['nextActionLabel'] ?? null,
'No action needed', __('localization.review.no_action_needed'),
]); ]);
$diagnosticsSummary = $firstArtifactTruthText([ $diagnosticsSummary = $firstArtifactTruthText([
$compressedOutcome['diagnosticsSummary'] ?? null, $compressedOutcome['diagnosticsSummary'] ?? null,
@ -81,7 +81,7 @@
if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') { if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') {
$summaryFacts->push([ $summaryFacts->push([
'label' => 'Result meaning', 'label' => __('localization.review.result_meaning'),
'value' => $evaluationSpec->label, 'value' => $evaluationSpec->label,
'badge' => BadgeCatalog::summaryData($evaluationSpec), 'badge' => BadgeCatalog::summaryData($evaluationSpec),
]); ]);
@ -89,7 +89,7 @@
if ($trustSpec && $trustSpec->label !== 'Unknown') { if ($trustSpec && $trustSpec->label !== 'Unknown') {
$summaryFacts->push([ $summaryFacts->push([
'label' => 'Result trust', 'label' => __('localization.review.result_trust'),
'value' => $trustSpec->label, 'value' => $trustSpec->label,
'badge' => BadgeCatalog::summaryData($trustSpec), 'badge' => BadgeCatalog::summaryData($trustSpec),
]); ]);
@ -133,7 +133,7 @@
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400"> <div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
Diagnostics {{ __('localization.review.diagnostics') }}
</div> </div>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
@ -164,7 +164,7 @@
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $count['label'] ?? 'Count' }} {{ $count['label'] ?? __('localization.review.count') }}
</div> </div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ (int) ($count['value'] ?? 0) }} {{ (int) ($count['value'] ?? 0) }}
@ -211,7 +211,7 @@
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_step') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100"> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $nextActionText }} {{ $nextActionText }}
</dd> </dd>
@ -237,7 +237,7 @@
@if ($nextSteps !== []) @if ($nextSteps !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.guidance') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextSteps as $step) @foreach ($nextSteps as $step)
@continue(! is_string($step) || trim($step) === '') @continue(! is_string($step) || trim($step) === '')

View File

@ -42,14 +42,14 @@
@if ($entries !== []) @if ($entries !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Key entries</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.key_entries') }}</div>
<div class="space-y-2"> <div class="space-y-2">
@foreach ($entries as $entry) @foreach ($entries as $entry)
@continue(! is_array($entry)) @continue(! is_array($entry))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60">
<div class="font-medium text-gray-900 dark:text-gray-100"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }} {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
</div> </div>
@php @php
@ -82,7 +82,7 @@
@if ($nextActions !== []) @if ($nextActions !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Follow-up</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.follow_up') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextActions as $action) @foreach ($nextActions as $action)
@continue(! is_string($action) || trim($action) === '') @continue(! is_string($action) || trim($action) === '')

View File

@ -25,7 +25,7 @@
@if ($operatorExplanation !== []) @if ($operatorExplanation !== [])
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70"> <div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white"> <div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $operatorExplanation['headline'] ?? 'Review explanation' }} {{ $operatorExplanation['headline'] ?? __('localization.review.review_explanation') }}
</div> </div>
@if (filled($operatorExplanation['reliabilityStatement'] ?? null)) @if (filled($operatorExplanation['reliabilityStatement'] ?? null))
@ -45,13 +45,13 @@
@if ($reasonSemantics !== []) @if ($reasonSemantics !== [])
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2"> <dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.reason_owner') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? __('localization.review.platform_core') }}</dd>
</div> </div>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.platform_reason_family') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? __('localization.review.compatibility') }}</dd>
</div> </div>
</dl> </dl>
@endif @endif
@ -74,7 +74,7 @@
@if ($highlights !== []) @if ($highlights !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Highlights</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.highlights') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($highlights as $highlight) @foreach ($highlights as $highlight)
@continue(! is_string($highlight) || trim($highlight) === '') @continue(! is_string($highlight) || trim($highlight) === '')
@ -87,7 +87,7 @@
@if ($nextActions !== []) @if ($nextActions !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next actions</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_actions') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextActions as $action) @foreach ($nextActions as $action)
@continue(! is_string($action) || trim($action) === '') @continue(! is_string($action) || trim($action) === '')
@ -100,7 +100,7 @@
@if ($contextLinks !== []) @if ($contextLinks !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.related_context') }}</div>
<div class="grid gap-3 md:grid-cols-3"> <div class="grid gap-3 md:grid-cols-3">
@foreach ($contextLinks as $link) @foreach ($contextLinks as $link)
@php @php
@ -130,11 +130,11 @@
@endif @endif
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.publication_readiness') }}</div>
@if ($publishBlockers === [] && $decisionDirection === 'publishable') @if ($publishBlockers === [] && $decisionDirection === 'publishable')
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200"> <div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
This review is ready for publication and executive-pack export. {{ __('localization.review.ready_for_publication') }}
</div> </div>
@elseif ($publishBlockers !== []) @elseif ($publishBlockers !== [])
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200"> <ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
@ -146,7 +146,7 @@
</ul> </ul>
@elseif ($decisionDirection === 'internal_only') @elseif ($decisionDirection === 'internal_only')
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
<div>This review is currently safe for internal use only.</div> <div>{{ __('localization.review.internal_only') }}</div>
@if ($publicationNextAction !== null) @if ($publicationNextAction !== null)
<div class="mt-1">{{ $publicationNextAction }}</div> <div class="mt-1">{{ $publicationNextAction }}</div>
@ -154,7 +154,7 @@
</div> </div>
@else @else
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
{{ $publicationNextAction ?? $publicationReason ?? 'This review still needs follow-up before publication.' }} {{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }}
</div> </div>
@endif @endif
</div> </div>

View File

@ -14,7 +14,7 @@
@if (! $isConfigured) @if (! $isConfigured)
<div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
Microsoft sign-in is not configured. {{ __('localization.auth.microsoft_not_configured') }}
</div> </div>
@endif @endif
@ -25,11 +25,11 @@
:disabled="! $isConfigured" :disabled="! $isConfigured"
color="primary" color="primary"
> >
Sign in with Microsoft {{ __('localization.auth.sign_in_microsoft') }}
</x-filament::button> </x-filament::button>
<div class="text-center text-sm text-gray-500 dark:text-gray-400"> <div class="text-center text-sm text-gray-500 dark:text-gray-400">
Tenant Admin access requires a tenant membership. {{ __('localization.auth.tenant_admin_membership_required') }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,15 +2,15 @@
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Customer-safe review workspace {{ __('localization.review.customer_safe_review_workspace') }}
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context. {{ __('localization.review.customer_workspace_intro') }}
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces. {{ __('localization.review.customer_workspace_canonical_note') }}
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>

View File

@ -31,8 +31,8 @@
@endphp @endphp
@php @php
$tenantLabel = $currentTenantName ?? 'No tenant selected'; $tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected');
$workspaceLabel = $workspace?->name ?? 'Choose workspace'; $workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
$hasActiveTenant = $currentTenantName !== null; $hasActiveTenant = $currentTenantName !== null;
$managedTenantsUrl = $workspace $managedTenantsUrl = $workspace
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]) ? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
@ -40,7 +40,8 @@
$workspaceUrl = $workspace $workspaceUrl = $workspace
? route('admin.home') ? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin'); : ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace'; $tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace');
$localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin';
@endphp @endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5"> <div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
@ -63,7 +64,7 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t
<x-slot name="trigger"> <x-slot name="trigger">
<button <button
type="button" type="button"
aria-label="{{ $workspace ? 'Tenant scope' : 'Select tenant' }}" aria-label="{{ $workspace ? __('localization.shell.tenant_scope') : __('localization.shell.select_tenant') }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10" class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
> >
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}"> <span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
@ -78,12 +79,12 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }"> <div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
@if ($resolvedContext->showsRecoveryNotice()) @if ($resolvedContext->showsRecoveryNotice())
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"> <div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
<div class="font-semibold">Context unavailable</div> <div class="font-semibold">{{ __('localization.shell.context_unavailable') }}</div>
@if ($workspace) @if ($workspace)
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div> <div>{{ __('localization.shell.context_unavailable_workspace') }}</div>
@else @else
<div>Choose a workspace to continue with a valid admin context.</div> <div>{{ __('localization.shell.context_unavailable_no_workspace') }}</div>
@endif @endif
</div> </div>
@endif @endif
@ -91,7 +92,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
{{-- Workspace section --}} {{-- Workspace section --}}
<div class="space-y-1"> <div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Workspace {{ __('localization.shell.workspace') }}
</div> </div>
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5"> <div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
@ -104,7 +105,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}" href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
> >
Switch workspace {{ __('localization.shell.switch_workspace') }}
</a> </a>
</div> </div>
@ -113,7 +114,7 @@ class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-pri
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5" class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
> >
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" /> <x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
Workspace Home {{ __('localization.shell.workspace_home') }}
</a> </a>
</div> </div>
@ -124,7 +125,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Selected tenant {{ __('localization.shell.selected_tenant') }}
</div> </div>
</div> </div>
@ -137,7 +138,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
href="{{ ChooseTenant::getUrl(panel: 'admin') }}" href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
> >
Switch tenant {{ __('localization.shell.switch_tenant') }}
</a> </a>
</div> </div>
@ -146,7 +147,7 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
Clear tenant scope {{ __('localization.shell.clear_tenant_scope') }}
</button> </button>
</form> </form>
@endif @endif
@ -154,23 +155,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@else @else
@if ($tenants->isEmpty()) @if ($tenants->isEmpty())
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
<div>No active tenants are available for the standard operating context in this workspace.</div> <div>{{ __('localization.shell.no_active_tenants') }}</div>
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"> <a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" /> <x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
View managed tenants {{ __('localization.shell.view_managed_tenants') }}
</a> </a>
</div> </div>
@else @else
@if (! $hasActiveTenant) @if (! $hasActiveTenant)
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400"> <div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context. {{ __('localization.shell.workspace_wide_available') }}
</div> </div>
@endif @endif
<input <input
type="text" type="text"
class="fi-input fi-text-input w-full" class="fi-input fi-text-input w-full"
placeholder="Search tenants…" placeholder="{{ __('localization.shell.search_tenants') }}"
x-model="query" x-model="query"
/> />
@ -207,7 +208,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
Clear tenant scope {{ __('localization.shell.clear_tenant_scope') }}
</button> </button>
</form> </form>
@endif @endif
@ -216,10 +217,12 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
</div> </div>
@else @else
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
Choose a workspace first. {{ __('localization.shell.choose_workspace_first') }}
</div> </div>
@endif @endif
</div> </div>
</x-filament::dropdown.list> </x-filament::dropdown.list>
</x-filament::dropdown> </x-filament::dropdown>
@include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
</div> </div>

View File

@ -0,0 +1,108 @@
@php
use App\Models\User;
use App\Services\Localization\LocaleResolver;
$plane = $plane ?? 'admin';
$showPreference = (bool) ($showPreference ?? true);
$embedded = (bool) ($embedded ?? false);
/** @var LocaleResolver $localeResolver */
$localeResolver = app(LocaleResolver::class);
$localeContext = request()->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
$localeContext = is_array($localeContext) ? $localeContext : $localeResolver->resolve(request(), $plane);
$localeOptions = LocaleResolver::localeOptions();
$currentLocale = (string) ($localeContext['locale'] ?? 'en');
$source = (string) ($localeContext['source'] ?? LocaleResolver::SOURCE_SYSTEM_DEFAULT);
$sourceLabel = __('localization.source.'.$source);
$user = auth()->user();
$preferredLocale = $user instanceof User ? $user->preferred_locale : null;
@endphp
<div class="{{ $embedded ? 'border-l border-gray-200 dark:border-white/10' : 'inline-flex rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5' }}">
<x-filament::dropdown placement="bottom-end" teleport width="sm">
<x-slot name="trigger">
<button
type="button"
aria-label="{{ __('localization.shell.language') }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 text-sm transition hover:bg-gray-50 dark:hover:bg-white/10"
>
<x-filament::icon icon="heroicon-o-language" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-200">{{ strtoupper($currentLocale) }}</span>
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
</button>
</x-slot>
<x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2">
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.current_language') }}
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $localeOptions[$currentLocale] ?? strtoupper($currentLocale) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ __('localization.shell.language_source', ['source' => $sourceLabel]) }}
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-white/10"></div>
<form method="POST" action="{{ route('localization.override.update') }}" class="space-y-2">
@csrf
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-override-{{ $plane }}">
{{ __('localization.shell.temporary_override') }}
</label>
<select
id="tenantpilot-locale-override-{{ $plane }}"
name="locale"
class="fi-input fi-select-input w-full"
>
@foreach ($localeOptions as $locale => $label)
<option value="{{ $locale }}" @selected($currentLocale === $locale)>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="w-full rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-primary-500">
{{ __('localization.shell.switch_language') }}
</button>
</form>
@if ($source === LocaleResolver::SOURCE_EXPLICIT_OVERRIDE)
<form method="POST" action="{{ route('localization.override.clear') }}">
@csrf
@method('DELETE')
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_override') }}
</button>
</form>
@endif
@if ($showPreference && $user instanceof User)
<div class="border-t border-gray-200 dark:border-white/10"></div>
<form method="POST" action="{{ route('localization.preference.update') }}" class="space-y-2">
@csrf
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-preference-{{ $plane }}">
{{ __('localization.shell.personal_preference') }}
</label>
<select
id="tenantpilot-locale-preference-{{ $plane }}"
name="preferred_locale"
class="fi-input fi-select-input w-full"
>
<option value="" @selected($preferredLocale === null)>{{ __('localization.shell.inherit_workspace') }}</option>
@foreach ($localeOptions as $locale => $label)
<option value="{{ $locale }}" @selected($preferredLocale === $locale)>{{ $label }}</option>
@endforeach
</select>
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-sm font-medium text-primary-600 transition hover:bg-primary-50 hover:text-primary-500 dark:text-primary-400 dark:hover:bg-primary-500/10 dark:hover:text-primary-300">
{{ __('localization.shell.save_preference') }}
</button>
</form>
@endif
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
</div>

View File

@ -1,9 +1,18 @@
@php @php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
/** @var \App\Models\Workspace $workspace */ /** @var \App\Models\Workspace $workspace */
$workspace = $this->workspace; $workspace = $this->workspace;
$customerHealthDecision = $this->customerHealthDecision(); $customerHealthDecision = $this->customerHealthDecision();
$tenants = $this->workspaceTenants(); $tenants = $this->workspaceTenants();
$runs = $this->recentRuns(); $runs = $this->recentRuns();
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
$reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null;
$readOnlyLifecycleDecision = $commercialActionDecisions['generated_pack_read'] ?? null;
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary(); $workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null; $planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? []; $entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
@ -40,6 +49,63 @@
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision]) @include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif @endif
<x-filament::section>
<x-slot name="heading">
Commercial lifecycle
</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">Current state</p>
<div class="mt-2 flex items-center gap-2">
<x-filament::badge :color="$commercialBadge->color" :icon="$commercialBadge->icon">
{{ $commercialBadge->label }}
</x-filament::badge>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['source_label'] ?? 'default active paid' }}</span>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}</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">Lifecycle rationale</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $commercialLifecycle['last_changed_by'] ?? 'System default' }}
@if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface)
· {{ $commercialLifecycle['last_changed_at']->diffForHumans() }}
@endif
</p>
</div>
</div>
<div class="mt-4 space-y-3">
@foreach ([
'Managed tenant activation' => $activationLifecycleDecision,
'Review-pack starts' => $reviewPackLifecycleDecision,
'Read-only history and downloads' => $readOnlyLifecycleDecision,
] as $label => $decision)
@if (is_array($decision))
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">{{ $label }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $decision['message'] ?? 'No lifecycle decision message available.' }}</p>
</div>
<x-filament::badge :color="match ($decision['outcome'] ?? null) {
'block' => 'danger',
'warn' => 'warning',
'allow_read_only' => 'info',
default => 'success',
}">
{{ str_replace('_', ' ', (string) ($decision['outcome'] ?? 'allow')) }}
</x-filament::badge>
</div>
</div>
@endif
@endforeach
</div>
</x-filament::section>
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision)) @if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
<x-filament::section> <x-filament::section>
<x-slot name="heading"> <x-slot name="heading">

View File

@ -11,6 +11,7 @@
/** @var bool $canManage */ /** @var bool $canManage */
/** @var bool $generationBlocked */ /** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */ /** @var ?string $generationBlockReason */
/** @var ?string $generationWarningReason */
/** @var ?string $customerWorkspaceUrl */ /** @var ?string $customerWorkspaceUrl */
/** @var ?string $downloadUrl */ /** @var ?string $downloadUrl */
/** @var ?string $failedReason */ /** @var ?string $failedReason */
@ -33,6 +34,12 @@
</div> </div>
@endif @endif
@if ($canManage && ! $generationBlocked && $generationWarningReason)
<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">
{{ $generationWarningReason }}
</div>
@endif
@if (! $pack) @if (! $pack)
{{-- State 1: No pack --}} {{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center"> <div class="flex flex-col items-center gap-3 py-4 text-center">

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\ClearTenantContextController; use App\Http\Controllers\ClearTenantContextController;
use App\Http\Controllers\LocalizationController;
use App\Http\Controllers\OpenFindingExceptionsQueueController; use App\Http\Controllers\OpenFindingExceptionsQueueController;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\ReviewPackDownloadController; use App\Http\Controllers\ReviewPackDownloadController;
@ -67,6 +68,21 @@
->middleware('throttle:entra-callback') ->middleware('throttle:entra-callback')
->name('auth.entra.callback'); ->name('auth.entra.callback');
Route::middleware(['web'])->group(function (): void {
Route::get('/localization/context', [LocalizationController::class, 'context'])
->name('localization.context');
Route::post('/localization/override', [LocalizationController::class, 'updateOverride'])
->name('localization.override.update');
Route::delete('/localization/override', [LocalizationController::class, 'clearOverride'])
->name('localization.override.clear');
});
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
->post('/users/me/locale-preference', [LocalizationController::class, 'updateUserPreference'])
->name('localization.preference.update');
$makeSmokeCookie = static fn () => cookie()->make( $makeSmokeCookie = static fn () => cookie()->make(
SuppressDebugbarForSmokeRequests::COOKIE_NAME, SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE, SuppressDebugbarForSmokeRequests::COOKIE_VALUE,

View File

@ -10,10 +10,14 @@
use App\Models\EvidenceSnapshotItem; use App\Models\EvidenceSnapshotItem;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
@ -68,6 +72,23 @@ function evidenceSnapshotHeaderActions(Testable $component): array
return $instance->getCachedHeaderActions(); return $instance->getCachedHeaderActions();
} }
function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Evidence read-only preservation test',
);
}
it('renders the evidence list page for an authorized user', function (): void { it('renders the evidence list page for an authorized user', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
@ -207,6 +228,36 @@ function evidenceSnapshotHeaderActions(Testable $component): array
->toContain('operation_run', 'review_pack'); ->toContain('operation_run', 'review_pack');
}); });
it('keeps evidence snapshot detail accessible for readonly members while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 2],
'generated_at' => now(),
]);
suspendEvidenceSnapshotWorkspace($tenant);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant'))
->assertOk();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_evidence')
->assertActionDisabled('refresh_evidence')
->assertActionVisible('expire_snapshot')
->assertActionDisabled('expire_snapshot');
});
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void { it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use Illuminate\Support\Facades\App;
it('resolves first-wave governance labels from the active locale', function (): void {
App::setLocale('de');
expect(__('localization.dashboard.tenant_title'))->toBe('Tenant-Dashboard')
->and(FindingResource::getNavigationGroup())->toBe('Governance')
->and(__('localization.findings.needs_action'))->toBe('Handlungsbedarf')
->and(__('baseline-compare.stat_total_findings'))->toBe('Findings gesamt')
->and(__('findings.rbac.detail_heading'))->toBe('Intune-RBAC-Rollendefinitions-Drift');
});

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Support\Workspaces\WorkspaceContext;
it('renders the admin auth surface in the explicit locale override', function (): void {
$this->withSession([LocaleResolver::SESSION_OVERRIDE_KEY => 'de'])
->get('/admin/login')
->assertSuccessful()
->assertSee('Mit Microsoft anmelden')
->assertSee('Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft');
});
it('keeps system plane resolution independent from user and workspace preferences', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$user->forceFill(['preferred_locale' => 'de'])->save();
session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => null,
])
->getJson('/localization/context?plane=system')
->assertSuccessful()
->assertJsonPath('locale', 'en')
->assertJsonPath('source', LocaleResolver::SOURCE_SYSTEM_DEFAULT)
->assertJsonPath('user_preference_locale', null)
->assertJsonPath('workspace_default_locale', null);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->getJson('/localization/context?plane=system')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
});

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Workspaces\WorkspaceContext;
it('allows users to save and clear a personal locale preference over workspace default', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.preference.update'), ['preferred_locale' => 'en'])
->assertRedirect();
expect($user->refresh()->preferred_locale)->toBe('en');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'en')
->assertJsonPath('source', LocaleResolver::SOURCE_USER_PREFERENCE);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.preference.update'), ['preferred_locale' => ''])
->assertRedirect();
expect($user->refresh()->preferred_locale)->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
});
it('allows temporary overrides to win until cleared', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$user->forceFill(['preferred_locale' => 'en'])->save();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.override.update'), ['locale' => 'de'])
->assertRedirect();
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBe('de');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->delete(route('localization.override.clear'))
->assertRedirect();
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBeNull();
});

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Workspaces\WorkspaceContext;
it('formats locale preference feedback in the resolved locale', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->post(route('localization.preference.update'), ['preferred_locale' => 'de'])
->assertRedirect()
->assertSessionHas('status', 'Spracheinstellung gespeichert.');
});
it('formats override feedback in the newly effective locale', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.override.update'), ['locale' => 'de'])
->assertRedirect()
->assertSessionHas('status', 'Sprachueberschreibung angewendet.');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'en',
])
->delete(route('localization.override.clear'))
->assertRedirect()
->assertSessionHas('status', 'Sprachueberschreibung geloescht.');
});

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use Illuminate\Support\Facades\App;
it('keeps audit action identifiers and machine values invariant while UI locale is German', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
App::setLocale('de');
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$audit = AuditLog::query()->latest('id')->first();
expect($audit)->not->toBeNull()
->and($audit->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
->and(data_get($audit->metadata, 'domain'))->toBe(LocaleResolver::SETTING_DOMAIN)
->and(data_get($audit->metadata, 'key'))->toBe(LocaleResolver::SETTING_DEFAULT_LOCALE)
->and(data_get($audit->metadata, 'after_value'))->toBe('de');
});

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Lang;
it('falls back to English for missing German translation lines', function (): void {
Lang::addLines(['localization.fallback_probe' => 'English fallback probe'], 'en');
App::setFallbackLocale('en');
App::setLocale('de');
expect(__('localization.fallback_probe'))->toBe('English fallback probe');
});
it('does not expose raw translation keys for supported first-wave catalogs', function (): void {
App::setLocale('de');
expect(__('localization.auth.sign_in_microsoft'))->not->toBe('localization.auth.sign_in_microsoft')
->and(__('baseline-compare.button_view_findings'))->not->toBe('baseline-compare.button_view_findings')
->and(__('findings.rbac.restore_unsupported'))->not->toBe('findings.rbac.restore_unsupported');
});

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\WorkspaceSetting;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsResolver;
use Livewire\Livewire;
it('persists workspace default locale through the existing workspace settings page', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.localization_default_locale', null)
->set('data.localization_default_locale', 'de')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.localization_default_locale', 'de');
expect(app(SettingsResolver::class)->resolveValue($workspace, LocaleResolver::SETTING_DOMAIN, LocaleResolver::SETTING_DEFAULT_LOCALE))
->toBe('de');
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', LocaleResolver::SETTING_DOMAIN)
->where('key', LocaleResolver::SETTING_DEFAULT_LOCALE)
->exists())->toBeTrue();
});
it('keeps workspace default locale authorization aligned to settings capabilities', function (): void {
[$workspace, $user] = localizationWorkspaceMember('readonly');
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.localization_default_locale', null)
->assertActionVisible('save')
->assertActionDisabled('save')
->call('save')
->assertStatus(403);
});

View File

@ -5,13 +5,16 @@
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
@ -21,7 +24,12 @@
/** /**
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable} * @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 function readyOnboardingEntitlementContext(
int $activeTenantCount = 0,
?int $limitOverride = null,
?string $overrideReason = null,
?string $commercialState = null,
): array
{ {
Queue::fake(); Queue::fake();
@ -110,6 +118,22 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim
} }
} }
if ($commercialState !== null) {
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $workspace,
state: $commercialState,
reason: 'Onboarding entitlement test commercial state',
);
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [ $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
@ -188,3 +212,65 @@ function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $lim
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE); expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
}); });
it('allows onboarding activation while a workspace is in trial', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Trial')
->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 grace commercial-state reason before tenant mutation', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_GRACE,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Grace')
->assertSee('New managed-tenant activation is frozen while this workspace is in grace.')
->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('blocks onboarding activation with a suspended read-only commercial-state reason before tenant mutation', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
commercialState: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Suspended / read-only')
->assertSee('This workspace is suspended / read-only. New managed-tenant activation is 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();
});

View File

@ -4,8 +4,12 @@
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -38,6 +42,23 @@ function createReadyPackWithFile(?array $packOverrides = []): array
return [$user, $tenant, $pack]; return [$user, $tenant, $pack];
} }
function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $pack->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Download preservation test',
);
}
// ─── Happy Path: Signed URL → 200 ─────────────────────────── // ─── Happy Path: Signed URL → 200 ───────────────────────────
it('downloads a ready pack via signed URL with correct headers', function (): void { it('downloads a ready pack via signed URL with correct headers', function (): void {
@ -64,6 +85,21 @@ function createReadyPackWithFile(?array $packOverrides = []): array
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace'); ->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
}); });
it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile();
suspendReadyPackWorkspaceForDownloadTest($pack);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => 'suspended_read_only_check',
]);
$response = $this->actingAs($user)->get($signedUrl);
$response->assertOk();
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
$response->assertDownload();
});
// ─── Expired Signature → 403 ──────────────────────────────── // ─── Expired Signature → 403 ────────────────────────────────
it('rejects requests with an expired signature', function (): void { it('rejects requests with an expired signature', function (): void {

View File

@ -8,16 +8,20 @@
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
@ -108,6 +112,23 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str
); );
} }
function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, string $reason = 'Review pack commercial lifecycle test'): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: $state,
reason: $reason,
);
}
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void { 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'); [$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant); seedEntitlementReviewPackSnapshot($tenant);
@ -188,3 +209,86 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str
->assertOk() ->assertOk()
->assertSee('Download'); ->assertSee('Download');
}); });
it('allows review pack generation in trial and active paid states', function (string $state): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, $state);
$pack = app(ReviewPackService::class)->generate($tenant, $user);
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and($pack->operation_run_id)->not->toBeNull()
->and($pack->status)->toBe(\App\Support\ReviewPackStatus::Queued->value);
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID],
]);
it('warns but allows review pack generation in grace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace period');
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
expect($decision)
->toMatchArray([
'is_blocked' => false,
'is_warning' => true,
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
])
->and($decision['warning_reason'])->toContain('grace');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Workspace is in grace. Review-pack starts remain available');
$pack = app(ReviewPackService::class)->generate($tenant, $user);
expect($pack)->toBeInstanceOf(ReviewPack::class)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->exists())->toBeTrue();
});
it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void {
Notification::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspension');
$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, 'suspended / read-only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
Notification::assertNothingSent();
});
it('does not alter already queued review-pack work when a workspace is suspended later', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
$pack = app(ReviewPackService::class)->generate($tenant, $user);
$initialStatus = (string) $pack->fresh()?->status;
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Later suspension');
expect($pack->fresh()?->status)->toBe($initialStatus)
->and(OperationRun::query()
->whereKey((int) $pack->operation_run_id)
->exists())->toBeTrue();
});

View File

@ -3,18 +3,23 @@
declare(strict_types=1); declare(strict_types=1);
use App\Exceptions\ReviewPackEvidenceResolutionException; use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Widgets\Tenant\TenantReviewPackCard; use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Jobs\GenerateReviewPackJob; use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Notifications\OperationRunCompleted; use App\Notifications\OperationRunCompleted;
use App\Notifications\OperationRunQueued; use App\Notifications\OperationRunQueued;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -157,6 +162,23 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
return $snapshot->load('items'); return $snapshot->load('items');
} }
function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Generation notification boundary test',
);
}
// ─── Happy Path ────────────────────────────────────────────── // ─── Happy Path ──────────────────────────────────────────────
it('generates a review pack end-to-end (happy path)', function (): void { it('generates a review pack end-to-end (happy path)', function (): void {
@ -210,6 +232,22 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
Notification::assertSentTo($user, OperationRunCompleted::class); Notification::assertSentTo($user, OperationRunCompleted::class);
}); });
it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void {
[$user, $tenant] = createUserWithTenant();
seedTenantWithData($tenant);
createEvidenceSnapshotForReviewPack($tenant);
suspendReviewPackGenerationWorkspaceForGenerationTest($tenant);
Notification::fake();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
Notification::assertNotSentTo($user, OperationRunQueued::class);
Notification::assertNotSentTo($user, OperationRunCompleted::class);
});
// ─── Failure Path ────────────────────────────────────────────── // ─── Failure Path ──────────────────────────────────────────────
it('marks pack as failed when generation throws an exception', function (): void { it('marks pack as failed when generation throws an exception', function (): void {

View File

@ -3,8 +3,12 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -12,6 +16,23 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Customer review workspace suspended read-only test',
);
}
it('shows the ready review-pack action for the latest published review', function (): void { it('shows the ready review-pack action for the latest published review', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
@ -48,6 +69,44 @@
->assertSee('Available'); ->assertSee('Available');
}); });
it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
suspendCustomerReviewWorkspacePackAccessWorkspace($tenant);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Available');
});
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void { it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');

View File

@ -5,7 +5,9 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -119,3 +121,38 @@
->get('/system/ops/runbooks') ->get('/system/ops/runbooks')
->assertSuccessful(); ->assertSuccessful();
}); });
it('keeps system workspace detail route semantics separate from commercial business-state blocks', function (): void {
$workspace = Workspace::factory()->create();
$this->actingAs(User::factory()->create())
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertNotFound();
auth()->guard('web')->logout();
$platformWithoutDirectoryView = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($platformWithoutDirectoryView, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertForbidden();
$directoryViewer = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($directoryViewer, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertSuccessful()
->assertSee('Commercial lifecycle')
->assertDontSee('Change commercial state');
});

View File

@ -3,13 +3,25 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\System\Pages\Directory\ViewWorkspace; use App\Filament\System\Pages\Directory\ViewWorkspace;
use App\Models\AuditLog;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSetting;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void { it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']); $workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
@ -79,5 +91,102 @@
->assertSee('Pilot workspace') ->assertSee('Pilot workspace')
->assertSee('Escalation only') ->assertSee('Escalation only')
->assertSee('workspace override') ->assertSee('workspace override')
->assertSee('Commercial lifecycle')
->assertSee('Active paid')
->assertSee('default active paid')
->assertDontSee('Save'); ->assertDontSee('Save');
}); });
it('gates the commercial lifecycle mutation action behind a dedicated platform capability', function (): void {
$workspace = Workspace::factory()->create();
$viewer = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
Livewire::actingAs($viewer, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->assertActionHidden('change_commercial_state');
});
it('changes commercial lifecycle state through the confirmed system action and records audit truth', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Lifecycle Workspace']);
$operator = PlatformUser::factory()->create([
'name' => 'Platform Operator',
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->assertActionVisible('change_commercial_state')
->assertActionExists('change_commercial_state', fn (Action $action): bool => $action->getLabel() === 'Change commercial state'
&& $action->isConfirmationRequired())
->callAction('change_commercial_state', data: [
'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
'reason' => 'Commercial suspension approved by support',
])
->assertNotified('Commercial state updated');
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN)
->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->value('value'))->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY)
->and(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN)
->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON)
->value('value'))->toBe('Commercial suspension approved by support');
$audit = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->where('resource_id', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->actor_name)->toBe('Platform Operator')
->and($audit?->metadata['before_state'] ?? null)->toBeNull()
->and($audit?->metadata['after_state'] ?? null)->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY)
->and($audit?->metadata['after_reason'] ?? null)->toBe('Commercial suspension approved by support');
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
'rationale' => 'Commercial suspension approved by support',
'last_changed_by' => 'Platform Operator',
]);
});
it('requires a rationale before changing commercial lifecycle state', function (): void {
$workspace = Workspace::factory()->create();
$operator = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->callAction('change_commercial_state', data: [
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
'reason' => '',
])
->assertHasActionErrors(['reason']);
});

View File

@ -164,6 +164,25 @@ function something()
// .. // ..
} }
/**
* @return array{0: Workspace, 1: User}
*/
function localizationWorkspaceMember(string $role = 'manager'): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => $role,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
function repo_root(): string function repo_root(): string
{ {
$configuredRoot = env('TENANTATLAS_REPO_ROOT'); $configuredRoot = env('TENANTATLAS_REPO_ROOT');

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps commercial lifecycle states through the shared badge catalog', function (string $state, string $label, string $color): void {
$spec = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $state);
expect($spec->label)->toBe($label)
->and($spec->color)->toBe($color)
->and($spec->icon)->not->toBeNull();
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial', 'info'],
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace', 'warning'],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid', 'success'],
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only', 'danger'],
]);

View File

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function commercialLifecycleWorkspaceManager(): 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];
}
function commercialLifecyclePlatformOperator(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
}
function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: commercialLifecyclePlatformOperator(),
workspace: $workspace,
state: $state,
reason: $reason,
);
}
it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
'state_label' => 'Active paid',
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID,
'source_label' => 'default active paid',
'rationale' => null,
])
->and($summary['last_changed_at'])->toBeNull()
->and($summary['last_changed_by'])->toBeNull()
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW)
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW);
});
it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$operator = commercialLifecyclePlatformOperator();
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: $operator,
workspace: $workspace,
state: $state,
reason: 'Support approved commercial lifecycle transition',
);
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => $state,
'state_label' => $expectedLabel,
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
'source_label' => 'workspace setting',
'rationale' => 'Support approved commercial lifecycle transition',
'last_changed_by' => $operator->name,
])
->and($summary['last_changed_at'])->not->toBeNull();
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'],
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'],
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'],
]);
it('blocks activation but warns review pack starts during grace', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
$activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION);
$reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START);
expect($activation)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($activation['block_reason'])->toContain('grace')
->and($reviewPackStart)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
'is_blocked' => false,
'is_warning' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($reviewPackStart['warning_reason'])->toContain('grace');
});
it('blocks new starts but allows read-only history during suspended read-only', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY,
'is_blocked' => false,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
]);
});
it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void {
[$workspace, $manager] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate');
Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: 1,
);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
value: false,
);
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
'is_warning' => false,
]);
});

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
function unitLocaleResolver(): LocaleResolver
{
return app(LocaleResolver::class);
}
it('resolves admin locale precedence from explicit override through system default', function (): void {
$resolver = unitLocaleResolver();
expect($resolver->resolveFromSources('de', 'en', 'en', 'en'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE,
'machine_artifacts_invariant' => true,
]);
expect($resolver->resolveFromSources(null, 'de', 'en', 'en'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_USER_PREFERENCE,
]);
expect($resolver->resolveFromSources(null, null, 'de', 'en'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT,
]);
expect($resolver->resolveFromSources(null, null, null, 'de'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT,
]);
});
it('falls through unsupported locale sources safely', function (): void {
$resolver = unitLocaleResolver();
$context = $resolver->resolveFromSources('fr', 'es', 'de', 'en');
expect($context)
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT,
'fallback_locale' => 'en',
])
->and($context['user_preference_locale'])->toBeNull();
});
it('keeps system panel resolution to explicit override or system default only', function (): void {
$resolver = unitLocaleResolver();
expect($resolver->resolveFromSources(
explicitOverride: null,
userPreference: 'de',
workspaceDefault: 'de',
systemDefault: 'en',
includeUserPreference: false,
includeWorkspaceDefault: false,
))->toMatchArray([
'locale' => 'en',
'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT,
'user_preference_locale' => null,
'workspace_default_locale' => null,
]);
expect($resolver->resolveFromSources(
explicitOverride: 'de',
userPreference: 'en',
workspaceDefault: 'en',
systemDefault: 'en',
includeUserPreference: false,
includeWorkspaceDefault: false,
))->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE,
]);
});

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves a workspace by slug or id', function (): void {
$workspace = Workspace::factory()->create([
'slug' => 'resolver-smoke-workspace',
]);
$resolver = app(WorkspaceResolver::class);
expect($resolver->resolve('resolver-smoke-workspace')?->is($workspace))->toBeTrue()
->and($resolver->resolve((string) $workspace->getKey())?->is($workspace))->toBeTrue();
});
it('resolves a Livewire serialized workspace route parameter', function (): void {
$workspace = Workspace::factory()->create([
'slug' => 'serialized-route-workspace',
]);
$payload = json_encode([
'id' => $workspace->getKey(),
'name' => $workspace->name,
'slug' => $workspace->slug,
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
});
it('falls back to serialized id when a Livewire route payload has no slug', function (): void {
$workspace = Workspace::factory()->create();
$payload = json_encode([
'id' => (string) $workspace->getKey(),
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
});
it('returns null for an unsupported serialized route payload', function (): void {
$payload = json_encode([
'name' => 'Missing key',
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload))->toBeNull();
});

View File

@ -15,7 +15,7 @@ ## Purpose
## Current Product Position ## Current Product Position
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden.
## Status Model ## Status Model
@ -51,7 +51,7 @@ ## Roadmap Coverage Summary
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. | | R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt. | | Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. |
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
@ -106,7 +106,7 @@ ## Foundation-Only Capabilities
## Partial Capabilities ## Partial Capabilities
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt. - Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer. - Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten.
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch. - Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
@ -179,6 +179,9 @@ ## Open Gaps & Blockers
|---|---|---|---|---| |---|---|---|---|---|
| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 | | Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 | | No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces |
| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility |
| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants |
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 | | Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
@ -191,6 +194,9 @@ ## Recommended Next Specs
- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface. - `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface. - `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks.
- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases.
- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later.
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands. - `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.

View File

@ -3,7 +3,7 @@ # Spec Candidates
> Repo-based next-spec queue for TenantPilot. > Repo-based next-spec queue for TenantPilot.
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
> **Last reviewed**: 2026-04-27 > **Last reviewed**: 2026-04-28
> **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth > **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
--- ---
@ -138,6 +138,94 @@ ### Localization v1
- locale-aware formatting does not affect audit or export truth - locale-aware formatting does not affect audit or export truth
- targeted regression coverage exists for fallback and key critical flows - targeted regression coverage exists for fallback and key critical flows
### Remove Findings Lifecycle Backfill Runtime Surfaces
- **Priority**: P1
- **Why this stays active**: Repo audit shows visible runtime surfaces for a pre-production findings lifecycle repair path even though active finding generators already write the relevant lifecycle fields directly. The remaining path is not just ballast; it appears partially detached from current operational-control truth and keeps internal repair tooling productized.
- **Roadmap relationship**: Findings workflow cleanup / legacy removal.
- **Dependencies**:
- current finding generators that already set lifecycle fields directly
- system runbook registry and execution surfaces
- tenant findings actions
- operation catalog, capability, and seeder bindings
- backfill jobs, runbook service, and deploy hooks
- **Scope**:
- remove the system runbook `Rebuild Findings Lifecycle`
- remove the tenant action `Backfill findings lifecycle`
- remove the command `tenantpilot:findings:backfill-lifecycle`
- remove findings lifecycle backfill jobs, runbook services, and deploy/runtime hooks
- remove operation-catalog, capability, seeder, and test traces that exist only for this backfill path
- **Non-scope**:
- removing the legacy `acknowledged` status or related compatibility helpers
- changing normal finding workflow actions such as triage, assignment, progress, resolve, or risk acceptance
- changing ownership, assignee, SLA, due-date, or risk-governance semantics
- changing historical migrations or adding replacement backfills
- **Acceptance criteria**:
- no `/admin` surface exposes `Backfill findings lifecycle`
- no system runbook exposes `Rebuild Findings Lifecycle`
- `tenantpilot:findings:backfill-lifecycle` is no longer a supported command
- deploy or operational hooks do not start a findings lifecycle backfill
- `findings.lifecycle.backfill` is no longer used as an operational-control key, operation type, or capability
- tests no longer expect backfill preflight, start, or completion behavior
- normal finding workflows keep working unchanged for triage, assignment, start progress, resolve, and risk acceptance
- **Notes**: This is the first and most important cleanup candidate because it removes visible product ballast without changing the canonical findings workflow semantics.
### Remove Legacy Acknowledged Finding Status Compatibility
- **Priority**: P1
- **Why this stays active**: Repo audit indicates that `acknowledged` compatibility still survives in status helpers, filters, badges, capabilities, and tests even though the current operator workflow is centered on `triaged`. Keeping both semantics alive weakens workflow clarity and RBAC consistency.
- **Roadmap relationship**: Findings workflow semantics / RBAC cleanup.
- **Dependencies**:
- finding status constants and model helpers
- badge and filter catalogs
- role capability mappings and capability aliases
- workflow and bulk-action tests that still speak in acknowledge semantics
- **Scope**:
- remove `Finding::STATUS_ACKNOWLEDGED`
- remove or simplify compatibility helpers that only map `acknowledged` to `triaged`
- remove `openStatusesForQuery()` compatibility for `acknowledged`
- remove legacy capability aliases such as `tenant_findings.acknowledge`
- rename, adapt, or remove tests that only protect the old acknowledge vocabulary
- ensure active workflow actions consistently use `triage` / `triaged`
- **Non-scope**:
- removing findings lifecycle backfill runtime surfaces in the same slice
- changing SLA, ownership, assignee, or risk-acceptance behavior
- introducing new workflow states or new customer-facing workflow surfaces
- changing finding generators unless they still emit `acknowledged`
- **Acceptance criteria**:
- no productive code path writes `acknowledged`
- no productive code path expects `acknowledged` as a valid workflow status
- `tenant_findings.acknowledge` no longer exists as a capability or alias
- workflow actions, filters, badges, and tests consistently use `triage` / `triaged`
- existing finding flows remain functional from `new` to `triaged`, `in_progress`, `resolved`, and risk-accepted outcomes
- **Notes**: Keep this separate from backfill removal because it reaches deeper into workflow semantics, queries, badges, and RBAC mappings.
### Enforce Creation-Time Finding Invariants
- **Priority**: P1
- **Why this stays active**: Removing lifecycle backfills only stays safe if new findings are always created in a lifecycle-ready state. The repo already hints at good direct-write behavior, but those invariants still need explicit protection so future generators do not recreate the need for repair jobs.
- **Roadmap relationship**: Findings data integrity / workflow hardening.
- **Dependencies**:
- drift and baseline compare finding generation
- permission posture finding generation
- Entra admin roles finding generation
- rediscovery, reopen, and deduplication behavior around recurrence keys and lifecycle timestamps
- **Scope**:
- review active finding generators and verify lifecycle-ready creation
- add or tighten invariant tests around canonical status, first/last seen timestamps, `times_seen`, `sla_days`, and `due_at` where applicable
- verify reopen and rediscovery behavior
- verify drift idempotency and recurrence-key semantics
- consider a tightly bounded DB constraint only if the repo proves a safe, narrow case
- **Non-scope**:
- reintroducing any backfill or repair runtime surface
- historical data migration work
- forcing owner or assignee fields to become mandatory
- introducing new finding types or broader customer review workflow changes
- **Acceptance criteria**:
- repo-verified finding generators have tests that prove lifecycle-ready creation
- no new finding generation path relies on a later backfill or repair run
- repeated drift detection does not create uncontrolled canonical duplicates
- reopen or rediscovery behavior updates lifecycle fields correctly
- accountability remains a governance state rather than a forced owner/assignee requirement
- **Notes**: This should follow the visible cleanup work and protects the target state so findings do not regress back into repair-job dependency.
### P2 — Commercial / Scale ### P2 — Commercial / Scale
### Commercial Entitlements and Billing-State Maturity ### Commercial Entitlements and Billing-State Maturity

View File

@ -0,0 +1,42 @@
# Specification Quality Checklist: Commercial Entitlements and Billing-State Maturity
**Purpose**: Validate specification completeness and readiness before planning or implementation.
**Created**: 2026-04-28
**Feature**: [../spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Review Outcome
- [x] Review outcome class: acceptable-special-case
- [x] Workflow outcome: keep
- [x] Test-governance impact is explicitly recorded in the spec
## Notes
- Repo-specific surface names and existing product terms are used to anchor the spec to current truth, but the spec does not prescribe languages, frameworks, APIs, or low-level implementation design.
- No open clarification markers remain. The bounded assumptions are the default `active_paid` resolution for unset workspaces and the distinct `grace` behavior that freezes onboarding expansion without blocking in-scope review-pack starts.
- Implementation close-out keeps the workflow outcome as `keep`. The Livewire browser-smoke finding was fixed inside scope by making workspace route resolution accept Livewire serialized workspace parameters; no follow-up spec is required.

View File

@ -0,0 +1,465 @@
openapi: 3.0.3
info:
title: TenantPilot Admin/System - Workspace Commercial Lifecycle Overlay (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the commercial lifecycle overlay that follows the
existing workspace entitlement substrate from Spec 247.
NOTE: These routes are implemented as existing Filament pages, resources,
widgets, and Livewire-backed actions. Exact Livewire payload shapes are not
part of this contract. This file captures logical route boundaries, the
system/admin split, and the required 404 / 403 / business-state semantics.
servers:
- url: /admin
- url: /system
paths:
/directory/workspaces/{workspace}:
get:
summary: View read-only workspace commercial lifecycle summary in the system plane
description: |
Renders the existing system directory workspace detail page with the
effective lifecycle state, rationale, affected behavior summary, and the
reused entitlement substrate summary.
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/SystemWorkspaceCommercialLifecycleView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/directory/workspaces/{workspace}/actions/change-commercial-state:
post:
summary: Change the workspace commercial lifecycle state from the system plane
description: |
Conceptual contract for the confirmation-protected state-change action
on the existing system workspace detail page.
Behavior:
- Platform user with directory visibility but without the dedicated
lifecycle-manage capability: 403
- Wrong plane or non-platform actor: 404 semantics at the panel boundary
- Authorized platform user: state and rationale are written through the
existing workspace settings audit path
parameters:
- $ref: '#/components/parameters/WorkspaceId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChangeCommercialLifecycleCommand'
responses:
'204':
description: Commercial lifecycle state changed successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/onboarding/{onboardingDraft}:
get:
summary: View onboarding workflow with lifecycle-aware completion state
description: |
Renders the existing managed-tenant onboarding wizard. The completion
step must include the commercial lifecycle outcome after the underlying
entitlement substrate has been evaluated.
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'200':
description: Onboarding wizard rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/OnboardingCommercialLifecycleView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}/actions/complete:
post:
summary: Complete onboarding when entitlement, lifecycle state, and existing readiness all allow
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 and review-pack list start
action family.
Behavior ordering:
1. authorization
2. underlying entitlement substrate decision
3. lifecycle overlay decision
4. existing dedupe / queued-start flow when allowed
A lifecycle-blocked attempt is future-start-only in this slice: it
creates no new `ReviewPack`, creates no new `OperationRun`, emits no
queued or terminal review-pack notification, and does not affect any
review-pack work that was already queued or running.
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 lifecycle overlay must block before any new
`ReviewPack` or `OperationRun` is created, emit no queued or terminal
review-pack notification for the blocked attempt, and leave any
already-created queued or running review-pack work unchanged.
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 dedupe behavior remain in place when
the lifecycle overlay allows the start. A lifecycle-blocked attempt is
future-start-only: it creates no new `ReviewPack`, creates no new
`OperationRun`, emits no queued or terminal review-pack notification,
and leaves any already-created queued or running review-pack work
unchanged.
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'
/tenant-reviews/{tenantReview}:
get:
summary: View existing tenant review while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/TenantReviewId'
responses:
'200':
description: Existing tenant review rendered when current RBAC allows it
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/PreservedReadOnlyView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/review-packs/{reviewPack}:
get:
summary: View existing review pack while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/ReviewPackId'
responses:
'200':
description: Existing review pack rendered when current RBAC allows it
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/PreservedReadOnlyView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/review-packs/{reviewPack}/download:
get:
summary: Download an already-generated review pack while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/ReviewPackId'
responses:
'200':
description: Existing generated pack download is still available when current RBAC allows it
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/evidence-snapshots/{evidenceSnapshot}:
get:
summary: View existing evidence snapshot while the workspace may be suspended read-only
parameters:
- $ref: '#/components/parameters/EvidenceSnapshotId'
responses:
'200':
description: Existing evidence snapshot rendered when current RBAC allows it
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/PreservedReadOnlyView'
'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
EvidenceSnapshotId:
name: evidenceSnapshot
in: path
required: true
schema:
type: integer
responses:
Forbidden:
description: Established-scope actor lacks the required capability
NotFound:
description: Wrong plane, non-member scope, or inaccessible record
BusinessStateBlocked:
description: Actor is otherwise authorized, but the workspace commercial state or underlying entitlement substrate blocks the requested action
content:
application/json:
schema:
$ref: '#/components/schemas/CommercialLifecycleBlockResponse'
ValidationError:
description: Submitted commercial lifecycle state change failed validation
schemas:
ChangeCommercialLifecycleCommand:
type: object
required:
- state
- reason
properties:
state:
$ref: '#/components/schemas/CommercialLifecycleState'
reason:
type: string
description: Required for every explicit lifecycle state change, including an explicit return to active_paid.
minLength: 1
maxLength: 500
CommercialLifecycleState:
type: string
enum:
- trial
- grace
- active_paid
- suspended_read_only
ReviewPackGenerationCommand:
type: object
properties:
include_pii:
type: boolean
include_operations:
type: boolean
SystemWorkspaceCommercialLifecycleView:
type: object
required:
- workspace_id
- lifecycle
- affected_behaviors
properties:
workspace_id:
type: integer
lifecycle:
$ref: '#/components/schemas/CommercialLifecycleDecision'
affected_behaviors:
type: array
items:
$ref: '#/components/schemas/CommercialLifecycleActionDecision'
entitlement_substrate:
type: object
description: Existing Spec 247 workspace entitlement summary reused for context
primary_action:
$ref: '#/components/schemas/NextAction'
nullable: true
OnboardingCommercialLifecycleView:
type: object
required:
- onboarding_draft_id
- action_decision
properties:
onboarding_draft_id:
type: integer
action_decision:
$ref: '#/components/schemas/CommercialLifecycleActionDecision'
entitlement_substrate:
type: object
nullable: true
CommercialLifecycleDecision:
type: object
required:
- state
- label
- source
- source_label
properties:
state:
$ref: '#/components/schemas/CommercialLifecycleState'
label:
type: string
source:
type: string
enum:
- default_active_paid
- workspace_setting
source_label:
type: string
description: Rendered source label from the shared lifecycle source mapping used by system detail surfaces.
rationale:
type: string
nullable: true
last_changed_at:
type: string
format: date-time
nullable: true
last_changed_by:
type: string
nullable: true
CommercialLifecycleActionDecision:
type: object
required:
- action_key
- outcome
- lifecycle_state
properties:
action_key:
type: string
enum:
- managed_tenant_activation
- review_pack_start
- review_history_read
- evidence_read
- generated_pack_read
outcome:
type: string
enum:
- allow
- warn
- block
- allow_read_only
reason_family:
type: string
nullable: true
enum:
- commercial_lifecycle
- entitlement_substrate
message:
type: string
nullable: true
lifecycle_state:
$ref: '#/components/schemas/CommercialLifecycleState'
underlying_entitlement_key:
type: string
nullable: true
CommercialLifecycleBlockResponse:
type: object
required:
- reason_family
- message
properties:
reason_family:
type: string
enum:
- commercial_lifecycle
- entitlement_substrate
lifecycle_state:
$ref: '#/components/schemas/CommercialLifecycleState'
nullable: true
message:
type: string
PreservedReadOnlyView:
type: object
required:
- read_only_access_preserved
properties:
read_only_access_preserved:
type: boolean
enum: [true]
lifecycle_state:
$ref: '#/components/schemas/CommercialLifecycleState'
message:
type: string
nullable: true
description: Optional calm explanation that the workspace is suspended read-only while current history access remains available
NextAction:
type: object
required:
- label
properties:
label:
type: string
enabled:
type: boolean
reason:
type: string
nullable: true

View File

@ -0,0 +1,170 @@
# Data Model: Commercial Entitlements and Billing-State Maturity
**Date**: 2026-04-28
**Branch**: `251-commercial-entitlements-billing-state`
## Overview
This slice adds no new table. Persisted truth stays in existing `workspace_settings` rows, while the commercial lifecycle overlay and action-family outcomes remain derived.
## Persisted Truth
### 1. Workspace Commercial Lifecycle Setting Aggregate
**Persistence**: Existing `App\Models\WorkspaceSetting` rows
**Ownership**: Workspace-owned
**Scope**: One workspace, no new tenant-owned or platform-owned persistence
The slice reuses explicit settings keys under the existing `entitlements` domain.
| Setting key | Type | Nullable | Validation | Notes |
|-------------|------|----------|------------|-------|
| `entitlements.commercial_lifecycle_state` | string | yes | when present, must be one of `trial`, `grace`, `active_paid`, `suspended_read_only` | `null` means the workspace has never been explicitly set and resolves to the implicit default `active_paid` |
| `entitlements.commercial_lifecycle_reason` | string | yes | required on every explicit lifecycle state change; trimmed; max 500 chars | Operator-entered rationale shown on system and contextual admin surfaces |
**Write rules**:
- Lifecycle mutation happens from the system plane only and updates state plus rationale together through the existing workspace settings write/audit path.
- The future `Change commercial state` action is confirmation-protected and requires explicit rationale for every explicit lifecycle transition, including an explicit return to `active_paid`.
- Once a platform operator explicitly sets `active_paid`, that remains a stored state like the other three values. `null` is reserved for untouched workspaces only.
**Relationships**:
- `workspace_settings.workspace_id` anchors lifecycle truth to the workspace.
- `workspace_settings.updated_by_user_id` remains the attribution source for state change metadata.
## Existing Substrate Truth Reused
### 2. Workspace Entitlement Substrate Summary
**Persistence**: Existing Spec 247 workspace entitlement settings + code-owned plan-profile catalog
**Owner**: `WorkspaceEntitlementResolver`
This slice does not remodel the substrate. It reuses:
- `plan_profile`
- `managed_tenant_activation_limit`
- `review_pack_generation_enabled`
- substrate rationale/source/current-usage metadata
The lifecycle overlay may warn or restrict after substrate resolution, but it must never expand access beyond what the substrate already allows.
## Code-Owned Truth
### 3. Commercial Lifecycle State Catalog Entry
**Persistence**: none, code-owned
**Ownership**: Product/runtime configuration
**Scope**: current release only
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | string | yes | Stable internal identifier stored in `entitlements.commercial_lifecycle_state` |
| `label` | string | yes | Operator-facing state label |
| `description` | string | yes | Short explanation for system detail and contextual messaging |
| `onboarding_outcome` | string | yes | `allow` or `block` |
| `review_pack_start_outcome` | string | yes | `allow`, `warn`, or `block` |
| `preserves_read_only_history` | bool | yes | Whether existing review/evidence/generated-pack consumption remains explicitly preserved |
| `is_default` | bool | yes | Exactly one default entry: `active_paid` |
**Behavior matrix**:
| State | Onboarding activation | Review-pack starts | Existing review/evidence/download access |
|-------|-----------------------|--------------------|------------------------------------------|
| `trial` | allow | allow | allow |
| `active_paid` | allow | allow | allow |
| `grace` | block | warn (start still allowed) | allow |
| `suspended_read_only` | block | block | allow |
## Derived Truth
### 4. Effective Commercial Lifecycle Decision
**Persistence**: none, derived at runtime
**Owner**: bounded `WorkspaceCommercialLifecycleResolver`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `workspace_id` | int | yes | Workspace being evaluated |
| `state` | string | yes | Effective lifecycle state |
| `label` | string | yes | Operator-facing label |
| `source` | string | yes | `default_active_paid` or `workspace_setting`; any rendered source label must come from one shared mapping |
| `rationale` | string | no | Explicit operator rationale when source is `workspace_setting` |
| `last_changed_at` | datetime | no | Derived from the most recent lifecycle-related `WorkspaceSetting` row |
| `last_changed_by` | string | no | Derived actor attribution |
| `entitlement_summary` | object | yes | Existing Spec 247 substrate summary reused for support/context |
| `action_decisions` | object | yes | Per-action-family outcomes described below |
### 5. Commercial Lifecycle Action Decision
**Persistence**: none, derived at runtime
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `action_key` | string | yes | One of `managed_tenant_activation`, `review_pack_start`, `review_history_read`, `evidence_read`, `generated_pack_read` |
| `outcome` | string | yes | `allow`, `warn`, `block`, or `allow_read_only` |
| `reason_family` | string | no | `commercial_lifecycle`, `entitlement_substrate`, or `null` when fully allowed |
| `message` | string | no | Operator-safe explanation or warning |
| `lifecycle_state` | string | yes | Effective state that produced the action decision |
| `underlying_entitlement_key` | string | no | Present for onboarding/review-pack start decisions to preserve substrate traceability |
**Decision ordering rules**:
- The substrate entitlement decision runs first.
- If the substrate already blocks the action, the lifecycle overlay must not replace that reason.
- If the substrate allows the action, the lifecycle overlay may warn or block according to the state matrix.
- Authorization is not part of this derived decision; 404 and 403 semantics remain outside and happen earlier.
## Supporting Derived View Models
### 6. System Workspace Commercial Lifecycle View Model
**Persistence**: none
**Consumer**: `App\Filament\System\Pages\Directory\ViewWorkspace`
Contains:
- effective lifecycle state, label, rationale, and last-change attribution
- the two in-scope action-family outcomes
- the reused entitlement substrate summary for support context
- the one dominant mutation affordance metadata for `Change commercial state`
### 7. Contextual Admin Lifecycle Gate View Models
**Persistence**: none
**Consumers**: `ManagedTenantOnboardingWizard`, review-pack entry surfaces, and suspended read-only history surfaces
Contains:
- the immediate action-family outcome (`allow`, `warn`, `block`, or `allow_read_only`)
- one operator-safe explanation
- enough substrate context to keep lifecycle blocks distinct from underlying entitlement blocks
## Derived Query Dependencies
| Need | Source | Notes |
|------|--------|-------|
| Underlying plan-profile and entitlement truth | `WorkspaceEntitlementResolver` | Remains the canonical substrate |
| Lifecycle last-change attribution | existing `workspace_settings.updated_by_user_id` and timestamps | Derived from lifecycle-related rows only |
| Active managed-tenant usage | existing tenant/workspace runtime truth | Reused from the substrate summary |
| Existing review/history/evidence/download availability | existing review pack, review, evidence snapshot, and RBAC truth | No new persistence needed |
| Review-pack no-run proof | existing `review_packs` and `operation_runs` tables | Used only in tests to prove blocked starts do not write new run state |
## State Transitions
There is no new table-backed lifecycle entity. State changes are explicit workspace-setting transitions plus audit entries.
| From | To | Trigger | Consequence |
|------|----|---------|-------------|
| `null` (implicit default) | any explicit state | platform operator saves lifecycle state on the system detail page | workspace now has explicit commercial posture, rationale, and attribution |
| `trial` | `grace` | platform operator state change | new managed-tenant activation blocks; review-pack starts remain allowed with warning |
| `grace` | `suspended_read_only` | platform operator state change | onboarding and new review-pack starts block; history/evidence/download remain available |
| `suspended_read_only` | `active_paid` | platform operator state change | future starts again defer to underlying entitlement truth |
| any explicit state | another explicit state | platform operator state change | previous state is replaced; audit history preserves the transition trail |
## Boundaries Explicitly Preserved
- No new billing/customer/subscription entity exists.
- No new automated timers, expiry jobs, renewal reminders, or scheduled transitions are introduced.
- No new broad suspension contract is added for unrelated mutable surfaces.
- Existing read-only review/evidence/generated-pack access remains governed by current RBAC and redaction rules.

View File

@ -0,0 +1,297 @@
# Implementation Plan: Commercial Entitlements and Billing-State Maturity
**Branch**: `251-commercial-entitlements-billing-state` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Layer one bounded workspace commercial lifecycle overlay on top of the already-real Spec 247 entitlement substrate, not beside it. The existing `WorkspaceEntitlementResolver` remains canonical for plan/default/override truth, and the new slice adds one explicit lifecycle state plus action-family outcomes for onboarding activation, review-pack start, and preserved read-only history access.
- Keep mutation narrow and platform-owned: persist lifecycle state through the existing workspace settings infrastructure, expose inspection plus state change from the existing system workspace detail surface, and keep `/admin` limited to contextual allow, warn, or block messaging on onboarding and review-pack surfaces.
- Preserve current review/evidence/download truth while suspended. New lifecycle blocking must stop future onboarding activation and future review-pack starts before any tenant mutation, `ReviewPack`, or `OperationRun` creation, while leaving already-generated history and evidence consumption under current RBAC intact.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
**Storage**: PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model
**Testing**: Pest unit and feature tests via Laravel Sail
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Monorepo Laravel web application in `apps/platform`, using existing Filament admin and system panels
**Project Type**: web
**Performance Goals**: Reuse existing settings reads and current workspace aggregates only, add no new external calls during render, keep review-pack dedupe and shared run UX unchanged when allowed, and short-circuit blocked review-pack starts before any `ReviewPack` or `OperationRun` write
**Constraints**: One commercial lifecycle overlay only, four bounded states, two real gated behavior families, preserved authorized read-only history/evidence/download access while suspended, explicit `/admin` vs `/system` separation, no payment provider/invoice/checkout/website/broad billing-engine scope
**Scale/Scope**: One bounded lifecycle resolver, one system-plane mutation surface, one platform capability addition, one onboarding gate, one review-pack action-family gate, and focused lifecycle/read-only test 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. Current global-search behavior remains unchanged.
- **Destructive and high-impact actions**: The future `Change commercial state` action on the system workspace detail page must use `->requiresConfirmation()`, require platform authorization, and write audit history. The `Suspended / read-only` transition is the only high-risk path in scope. Review-pack and onboarding blocks remain non-destructive business-state responses, not hidden authorization 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 elsewhere in the product.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mixed
- **Shared-family relevance**: system detail controls, status messaging, onboarding helper text, review-pack action gating, review/evidence viewer messaging
- **State layers in scope**: page, detail
- **Audience modes in scope**: operator-MSP, support-platform, customer/read-only
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the system workspace detail surface and on the immediate onboarding/review-pack action context; diagnostics-second via the existing entitlement substrate and review/run/history context; no new raw/support payload surface is planned
- **Raw/support gating plan**: capability-gated system-plane inspection only; customer/read-only surfaces remain calm and evidence-first
- **One-primary-action / duplicate-truth control**: `/system/directory/workspaces/{workspace}` remains the only mutation surface; onboarding and review-pack surfaces show only the local lifecycle consequence required for the immediate action; suspended read-only history pages do not restate the whole commercial profile
- **Handling modes by drift class or surface**: review-mandatory because one lifecycle vocabulary must stay consistent across system, onboarding, review-pack, and read-only history surfaces
- **Repository-signal treatment**: review-mandatory
- **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**: no second admin-plane commercial mutation surface, no page-local lifecycle labels, and no broad suspension sweep across unrelated mutable surfaces
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `SettingsRegistry`, `SettingsResolver`, `SettingsWriter`, existing workspace-setting audit path, `WorkspaceEntitlementResolver`, `WorkspaceSettings` as the current entitlement substrate reference, `App\Filament\System\Pages\Directory\ViewWorkspace`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, `TenantReviewPackCard`, `ReviewRegister`, `TenantReviewResource`, `ReviewPackResource`, `CustomerReviewWorkspace`, `EvidenceSnapshotResource`, and `WorkspaceEntitlementBlockedException`
- **Shared abstractions reused**: `WorkspaceEntitlementResolver`, `WorkspacePlanProfileCatalog`, `SettingsResolver`, `SettingsWriter`, current workspace audit logging, `ReviewPackService`, `OperationUxPresenter`, `OperationRunLinks`, `Capabilities`, `PlatformCapabilities`, and existing Filament action/resource surfaces
- **New abstraction introduced? why?**: one bounded `WorkspaceCommercialLifecycleResolver` is justified because the existing entitlement resolver answers per-key entitlement truth but does not express one workspace-wide lifecycle posture with action-family outcomes, preserved read-only semantics, or system/admin messaging
- **Why the existing abstraction was sufficient or insufficient**: Spec 247 already provides canonical entitlement substrate truth and must remain the foundation. It is insufficient for `trial`, `grace`, `active_paid`, and `suspended_read_only` because those states cut across more than one entitlement key and need one central business-state explanation
- **Bounded deviation / spread control**: no page-local lifecycle conditionals and no second exception taxonomy by default; prefer reusing the existing blocked decision payload/catch path for review-pack actions unless implementation proves that the current class name or payload cannot carry lifecycle metadata cleanly
## 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, and existing terminal notifications remain unchanged when review-pack generation is allowed; lifecycle-blocked starts create no `OperationRun`, no queued DB notification, and no terminal notification
- **Surface-owned behavior kept local**: onboarding completion helper text, review-pack tooltips/disabled state, and suspended read-only explanation on history surfaces remain local projections of the central lifecycle decision
- **Queued DB-notification policy**: unchanged explicit opt-in only
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
- **Exception path**: none planned; lifecycle blocking must happen before `ReviewPackService` creates or reuses a `ReviewPack` or `OperationRun`, and the preferred later implementation is to extend the current blocked-decision payload rather than invent a second parallel business-state exception family
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: workspace commercial lifecycle vocabulary, lifecycle rationale, action-family outcomes, system/admin messaging, and audit semantics
- **Neutral platform terms / contracts preserved**: `workspace`, `trial`, `grace`, `active paid`, `suspended / read-only`, `commercial state`, `review pack`, `managed tenant activation`
- **Retained provider-specific semantics and why**: none; review-pack generation stays provider-backed operationally, but the new lifecycle 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 - the slice adds workspace-owned business state, not new inventory or backup truth.
- Read/write separation: PASS - the only new write is a confirmation-protected, audited system-plane lifecycle mutation using existing workspace settings persistence.
- Graph contract path: PASS - no new Microsoft Graph path is introduced.
- Deterministic capabilities: PASS - admin-plane capabilities remain unchanged, and any new platform capability stays registry-backed.
- RBAC-UX: PASS - `/admin` and `/system` remain separated; wrong-plane and non-member access stay 404; member-without-capability stays 403; otherwise-authorized actors get a business-state block or warning instead of authorization failure.
- Workspace isolation: PASS - admin-plane contextual behavior still requires established workspace context.
- RBAC-UX destructive confirmation: PASS - the future system-plane state-change action must require confirmation and rationale.
- RBAC-UX global search: PASS - no new searchable resource or search scope is introduced.
- Tenant isolation: PASS - onboarding, review-pack, review history, evidence, and download surfaces remain tenant-safe.
- Run observability: PASS - review-pack generation keeps the existing `OperationRun` path when allowed, and blocked starts stop before run creation.
- OperationRun start UX: PASS - the plan preserves shared review-pack start UX and inserts lifecycle blocking before run creation.
- Ops-UX 3-surface feedback: PASS - existing feedback stays toast + progress surfaces + terminal notification only when a run exists.
- Ops-UX lifecycle: PASS - no new `OperationRun` lifecycle contract is introduced.
- Ops-UX summary counts: N/A - no `summary_counts` shape change is planned.
- Ops-UX guards: N/A - no new run guard family is planned in the planning slice.
- Ops-UX system runs: N/A - initiator-null behavior is unchanged.
- Automation: N/A - no new queued or scheduled workflow family is introduced.
- Data minimization: PASS - no payment payloads, account records, or provider secrets are introduced.
- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit + feature lanes with explicit proof commands and limited fixture growth.
- Proportionality (PROP-001): PASS - persistence stays in existing settings rows, and the only new structural element is one bounded lifecycle overlay.
- No premature abstraction (ABSTR-001): PASS - no interface, registry, strategy system, or framework is planned; only one local resolver is added because multiple real surfaces already need the same lifecycle decision.
- Persisted truth (PERSIST-001): PASS - no new table or durable artifact is introduced.
- Behavioral state (STATE-001): PASS - `grace` and `suspended_read_only` create distinct action-family consequences immediately, and `trial` remains justified because it is part of the explicit platform-managed commercial posture and audit workflow even though its two in-scope gated families currently match `active_paid`.
- UI semantics (UI-SEM-001): PASS - the plan prefers direct mapping from lifecycle truth to helper text and badges instead of a new presentation framework.
- Shared pattern first (XCUT-001): PASS - system detail, onboarding, review-pack, and read-only history surfaces all reuse the existing substrate and shared run path first.
- Provider boundary (PROV-001): PASS - the new vocabulary is platform-core, not Microsoft-shaped.
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - one explicit state family plus one thin overlay resolver is the narrowest viable shape.
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the plan keeps the whole lifecycle overlay in one coherent spec and includes proportionality review below.
- Badge semantics (BADGE-001): PASS - any future lifecycle badge must reuse shared badge semantics or stay plain text; no page-local color taxonomy is planned.
- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages, resources, widgets, and the current system Blade page.
- Filament-native UI local Blade/Tailwind: PASS - the existing system Blade view remains the only custom-rendered surface in scope and must preserve current Filament visual language.
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - existing system detail, guided onboarding, action family, and read-only viewer surface types remain intact.
- Decision-first operating model (DECIDE-001): PASS - system workspace detail is primary, onboarding/review-pack surfaces stay contextual, and read-only history/evidence pages remain tertiary evidence/diagnostics.
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - system detail stays platform/support-facing, admin action gates stay operator-first, and suspended read-only pages keep customer-safe history access without raw platform diagnostics.
- 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 - the plan keeps one system mutation action and existing onboarding/review-pack primary actions in place.
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - labels remain narrow and billing-provider-free.
- UI/UX placeholder ban (UI-HARD-001): PASS - no empty action groups are planned.
- UI naming (UI-NAMING-001): PASS - primary labels stay `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`.
- Operator surfaces (OPSURF-001): PASS - mutation scope remains explicit, and `/admin` surfaces only show contextual lifecycle truth.
- Operator surface page contract: PASS - the spec already defines the required surface contracts.
- Filament UI Action Surface Contract: PASS - touched surfaces already have contracts or exemptions; the plan preserves them while adding lifecycle truth.
- Filament UI UX-001 (Layout & IA): PASS - no new page shell or panel is introduced.
- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - one system primary action and existing onboarding/review-pack action families remain the only primary mutations in scope.
- 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 lifecycle overlay and behavior matrix; `Feature` for system-plane mutation, onboarding activation gating, review-pack start blocking, and preserved suspended read-only consumption
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: the business risk is deterministic decision ordering plus existing Filament/Livewire and service entry points. Browser or heavy-governance coverage would add cost without proving additional current-release risk for this bounded overlay.
- **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/WorkspaceCommercialLifecycleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
- **Fixture / helper / factory / seed / context cost risks**: limited to workspace, platform user, workspace member, onboarding draft, tenant, existing review pack, tenant review, and evidence snapshot fixtures
- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and opt-in tenant/review/evidence helpers only
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native relief for system detail and onboarding; monitoring-state-page proof for no new run creation; shared-detail-family proof for preserved view/download access while suspended
- **Closing validation and reviewer handoff**: rerun the exact targeted commands above, verify 404 vs 403 vs business-state outcomes separately, verify system detail source labels remain consistent, verify blocked review-pack starts create no new `ReviewPack` or `OperationRun` and emit no queued or terminal notification, verify already queued or running review-pack runs continue unaffected after later suspension, and verify suspended workspaces still allow authorized review/evidence/download access
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local growth
- **Review-stop questions**: does the state vocabulary stay narrow enough, does the system/admin split remain intact, does suspended read-only coverage avoid broad mutation-sweep scope, and does the blocked-decision transport avoid a second exception framework
- **Escalation path**: document-in-feature if only payload wording or helper reuse needs adjustment; follow-up-spec only if the overlay starts pulling unrelated mutable surfaces into suspension logic
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the testing cost stays local to one overlay resolver and a small set of existing pages/services/resources; no new browser or heavy-governance harness is justified
## Project Structure
### Documentation (this feature)
```text
specs/251-commercial-entitlements-billing-state/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── workspace-commercial-lifecycle-overlay.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/
│ ├── Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── Reviews/CustomerReviewWorkspace.php
│ │ │ ├── Reviews/ReviewRegister.php
│ │ │ ├── Settings/WorkspaceSettings.php
│ │ │ └── Workspaces/ManagedTenantOnboardingWizard.php
│ │ ├── Resources/
│ │ │ ├── EvidenceSnapshotResource.php
│ │ │ ├── EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
│ │ │ ├── ReviewPackResource.php
│ │ │ ├── ReviewPackResource/Pages/ViewReviewPack.php
│ │ │ ├── TenantReviewResource.php
│ │ │ └── TenantReviewResource/Pages/ViewTenantReview.php
│ │ ├── System/Pages/Directory/ViewWorkspace.php
│ │ └── Widgets/Tenant/TenantReviewPackCard.php
│ ├── Models/WorkspaceSetting.php
│ ├── Services/
│ │ ├── Entitlements/WorkspaceCommercialLifecycleResolver.php # likely new bounded overlay service
│ │ ├── Entitlements/WorkspaceEntitlementResolver.php
│ │ ├── ReviewPackService.php
│ │ └── Settings/
│ │ ├── SettingsResolver.php
│ │ └── SettingsWriter.php
│ ├── Support/
│ │ ├── Auth/Capabilities.php
│ │ ├── Auth/PlatformCapabilities.php
│ │ └── Settings/SettingsRegistry.php
├── resources/views/filament/system/pages/directory/view-workspace.blade.php
└── tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded lifecycle overlay service and changes limited to existing settings persistence, system detail, onboarding, review-pack, and read-only review/evidence/download surfaces plus focused Pest coverage.
## Likely Implementation Surfaces
- `app/Support/Settings/SettingsRegistry.php` to register lifecycle-setting definitions and validation using the existing workspace settings infrastructure
- `app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` as the new bounded overlay, with `WorkspaceEntitlementResolver.php` remaining the canonical substrate provider
- `app/Support/Auth/PlatformCapabilities.php` and related platform authorization helpers for one dedicated commercial-lifecycle management capability
- `app/Filament/System/Pages/Directory/ViewWorkspace.php` and `resources/views/filament/system/pages/directory/view-workspace.blade.php` for read-only summary plus the confirmation-protected state-change action
- `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` for contextual lifecycle messaging and activation blocking before tenant mutation
- `app/Services/ReviewPackService.php`, `app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` for shared start gating and tooltip/disabled-state reuse
- Existing read-only consumption surfaces `CustomerReviewWorkspace.php`, `ViewTenantReview.php`, `ViewReviewPack.php`, `ViewEvidenceSnapshot.php`, and the current review-pack download path to prove suspended history/evidence access remains available under existing RBAC
- Focused unit and feature tests under `tests/Unit/Entitlements`, `tests/Feature/System/Directory`, `tests/Feature/Onboarding`, `tests/Feature/ReviewPack`, `tests/Feature/Reviews`, and `tests/Feature/Evidence`
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| One bounded `WorkspaceCommercialLifecycleResolver` | Two real gated behavior families plus preserved read-only consumption need one shared workspace-wide decision layered above existing entitlements | Page-local conditionals in onboarding, review-pack resources/widgets, and system detail would drift immediately and undermine business-state consistency |
| Four-state commercial lifecycle vocabulary | Platform operators need one auditable commercial posture that distinguishes trial, grace, active paid, and suspended/read-only on the single system decision surface | Three unlabeled booleans or ad hoc flags would either collapse grace into suspension or lose the explicit platform-side lifecycle state needed for support and audit |
## Proportionality Review
- **Current operator problem**: The repo can already answer per-key entitlement questions, but it cannot say in one place whether a workspace is currently trialing, in grace, fully active paid, or suspended/read-only, nor can it explain why onboarding and review-pack starts are blocked while history remains readable.
- **Existing structure is insufficient because**: `WorkspaceEntitlementResolver` and current workspace settings expose substrate truth only. They do not provide one workspace-wide lifecycle posture, one system-owned mutation path, or one action-family outcome that distinguishes lifecycle blocks from entitlement blocks and authorization failures.
- **Narrowest correct implementation**: Keep persistence inside existing `workspace_settings`, add only one four-state lifecycle family plus rationale, derive action-family outcomes in one bounded overlay service, mutate it from one system detail page, and apply it only to onboarding activation, review-pack starts, and preserved read-only history/evidence/download semantics.
- **Ownership cost created**: One new state vocabulary, one overlay service, one platform capability, cross-surface copy discipline, and focused lifecycle/read-only tests.
- **Alternative intentionally rejected**: A billing/subscription engine, customer-account model, payment-provider seam, or many local page booleans was rejected because the current release only needs a single workspace commercial overlay on top of the already-real entitlement substrate.
- **Release truth**: current-release truth. The four-state vocabulary is justified now because platform operators already need to set and audit those named postures, even though only `grace` and `suspended_read_only` introduce new blocked outcomes for the two in-scope action families in this slice.
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`
Goals:
- Confirm the narrowest reuse of the existing `entitlements` settings domain and audit path for lifecycle state and rationale.
- Confirm that one bounded overlay service can compose `WorkspaceEntitlementResolver` without creating a second commercial framework.
- Confirm that lifecycle mutation remains platform-only on the existing system workspace detail page and does not leak into `/admin` self-service.
- Confirm that review-pack start blocking happens before `ReviewPack` or `OperationRun` creation and can reuse the current blocked-decision transport.
- Confirm that suspended read-only preservation remains bounded to existing review, evidence, and generated-pack consumption surfaces instead of becoming a broad product-wide suspension sweep.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
Design focus:
- Persist `commercial_lifecycle_state` plus rationale through the existing `entitlements` settings domain instead of adding a new table or billing domain.
- Keep the overlay inside `App\Services\Entitlements` and let it compose `WorkspaceEntitlementResolver` rather than replacing it.
- Extend the existing system workspace detail page with a read-only lifecycle summary and one confirmation-protected `Change commercial state` action, while leaving `WorkspaceSettings` as substrate truth rather than a second mutation plane.
- Gate `ManagedTenantOnboardingWizard` completion from the central lifecycle decision after underlying entitlement truth is known.
- Gate review-pack `Generate pack`, `Regenerate`, and `Export executive pack` starts through `ReviewPackService` and current action surfaces, stopping before any `ReviewPack` or `OperationRun` write when the lifecycle blocks the action.
- Preserve `CustomerReviewWorkspace`, review detail, evidence detail, review-pack detail, and pack download access under current RBAC while suspended, and keep any broader mutable-surface suspension work explicitly out of scope.
## 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`)
- Register lifecycle state and rationale setting definitions under the existing `entitlements` settings domain and wire them into the current workspace-setting audit path.
- Add one bounded `WorkspaceCommercialLifecycleResolver` that composes underlying entitlement decisions and yields action-family outcomes plus suspended read-only allowances.
- Add one dedicated platform capability for commercial lifecycle management and enforce it on the system detail mutation action only.
- Extend `ViewWorkspace` plus its Blade view with current lifecycle state, affected behavior summary, and a confirmation-protected `Change commercial state` action.
- Gate onboarding completion in `ManagedTenantOnboardingWizard` using the shared lifecycle decision while preserving existing tenant operability checks and 404/403 semantics.
- Gate review-pack start surfaces and `ReviewPackService` using the shared lifecycle decision, preserving current queued-start UX when allowed and reusing the existing blocked-decision transport when blocked.
- Prove suspended read-only continuation by asserting existing review/evidence/download surfaces remain available under current RBAC while no new onboarding activation or review-pack run can start.
- Add focused Sail/Pest unit and feature coverage only.
## Constitution Check (Post-Design)
Re-check result: PASS. The design keeps Filament v5 + Livewire v4 compliance intact, leaves provider registration unchanged in `bootstrap/providers.php`, introduces no new globally searchable resource, keeps asset strategy unchanged, preserves strict `/admin` vs `/system` separation, layers one bounded lifecycle resolver above the existing entitlement substrate, and blocks review-pack starts before `OperationRun` creation rather than forking shared run UX.
## Planning Readiness
- Outcome: keep
- No unresolved clarification markers remain in the plan-phase artifacts.
- No application implementation is included in this planning step.
- The next repo-native step is `/speckit.tasks` for an implementation task breakdown, not code changes.
## Implementation Close-Out
- **Workflow outcome**: keep.
- **Implementation result**: one bounded commercial lifecycle overlay was implemented through existing workspace settings, one system-plane `Change commercial state` action, onboarding activation gating, review-pack start allow/warn/block semantics, and preserved suspended read-only review/evidence/download access.
- **Blocked-decision transport**: document-in-feature. The existing `WorkspaceEntitlementBlockedException` transport remains sufficient for review-pack blocked starts; no second business-state exception family was introduced.
- **Preserved read-only scope**: document-in-feature. Suspension stays bounded to onboarding activation and new review-pack starts in this spec; broader mutable-surface suspension remains out of scope.
- **Browser smoke path**: `/system/login` as `operator@tenantpilot.io`, `/system/directory/workspaces/1`, open `Change commercial state`, set `Trial` with rationale, confirm, observe updated lifecycle summary and notification follow-up, then restore `Active paid`.
- **Browser smoke result**: pass after fixing `WorkspaceResolver` to accept Livewire serialized workspace route parameters; the follow-up notification update no longer emits console errors or 404/419 markers.
- **Lane results**: targeted unit/support/system/onboarding/review-pack/read-only Pest lanes passed; dirty-only Pint passed; `git diff --check` passed.

View File

@ -0,0 +1,109 @@
# Quickstart: Commercial Entitlements and Billing-State Maturity
**Date**: 2026-04-28
**Branch**: `251-commercial-entitlements-billing-state`
This quickstart is the intended reviewer flow after implementation. It stays bounded to the commercial lifecycle overlay 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 platform user has directory visibility plus the dedicated commercial lifecycle management capability.
3. Ensure one workspace member can complete onboarding, one reporting operator can manage review packs, and one customer-safe or operator read-only actor can open review/evidence/download surfaces under current RBAC.
4. Seed or factory-create:
- one workspace with untouched lifecycle state
- one onboarding draft in that workspace
- one tenant with an existing review, evidence snapshot, and generated review pack
- one workspace already at or above the managed-tenant activation limit for substrate-block verification
## Scenario 1: Change workspace commercial state from the system plane
1. Open `/system/directory/workspaces/{workspace}` as the authorized platform user.
2. Confirm the page shows:
- current lifecycle state
- source label
- rationale and last-changed attribution
- affected behavior summary for onboarding and review-pack starts
- the underlying entitlement substrate summary for context
3. Use `Change commercial state` to move the workspace to `trial` with rationale.
4. Confirm the page updates immediately and the change is attributable.
5. Repeat with `grace`, `suspended_read_only`, and `active_paid`.
6. Confirm every explicit state change requires rationale, including a return to `active_paid`, and that the `Suspended / read-only` path also requires explicit confirmation.
## Scenario 2: Gate onboarding activation with business-state truth
1. Open `/admin/onboarding/{onboardingDraft}` for a workspace in `trial` or `active_paid`.
2. Confirm the completion step allows `Complete onboarding` when the underlying entitlement substrate also allows it.
3. Switch the same workspace to `grace` from the system plane.
4. Refresh the onboarding draft and confirm:
- the action remains visible for an otherwise authorized actor
- the step explains that expansion is frozen during grace
- no tenant activation occurs
5. Repeat with `suspended_read_only` and confirm the block message changes to read-only suspension semantics instead of a permission failure.
## Scenario 3: Gate review-pack starts before any run is created
1. Use a workspace in `trial` or `active_paid` where the underlying review-pack entitlement allows generation.
2. Trigger the current start family from:
- tenant dashboard review-pack card
- review register export action
- tenant review detail export action
- review-pack detail regenerate action
3. Confirm the existing queued-start UX remains unchanged when allowed.
4. Move the workspace to `grace`.
5. Confirm review-pack starts remain allowed with a grace warning.
6. Start one allowed review-pack action and leave the resulting work queued or running.
7. Move the workspace to `suspended_read_only`.
8. Confirm the already-created run remains visible and continues with the existing run UX.
9. Repeat the same start actions and confirm:
- each surface shows the same lifecycle-based reason
- no new `ReviewPack` row is created
- no new `OperationRun` row is created
- no queued or terminal review-pack notification is emitted for the blocked attempt
## Scenario 4: Preserve read-only review, evidence, and generated-pack access while suspended
1. Keep the workspace in `suspended_read_only`.
2. Open the current read-only consumption surfaces as an already-authorized actor:
- `CustomerReviewWorkspace`
- tenant review detail
- review-pack detail
- evidence snapshot detail
- current review-pack download link
3. Confirm:
- the pages still render
- already-generated review packs remain downloadable
- existing review/evidence history remains visible
- any read-only explanation stays calm and does not masquerade as 403 or 404
4. Confirm the slice does not add broad new suspension behavior to unrelated mutable controls outside the spec boundary.
## RBAC and Plane Semantics Checks
1. Access lifecycle mutation from `/admin` and confirm there is no self-service control surface.
2. Access `/system/directory/workspaces/{workspace}` as a platform user lacking the dedicated lifecycle capability and confirm authorization is enforced without leaking admin-plane truth.
3. Access onboarding or review-pack surfaces as a non-member or wrong-plane actor and confirm 404.
4. Access the same surfaces as an established-scope actor lacking the relevant capability and confirm 403.
5. Access the action as an otherwise authorized actor whose workspace lifecycle blocks the action and confirm a truthful business-state block instead of 403 or 404.
## 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/WorkspaceCommercialLifecycleResolverTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.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:
- payment-provider credentials, invoices, checkout, taxes, or public pricing UI
- customer-account, subscription, or contract models
- automated expiry/reminder/renewal logic
- a second admin-plane commercial settings surface
- a broad suspension engine across unrelated mutable product surfaces

View File

@ -0,0 +1,84 @@
# Research: Commercial Entitlements and Billing-State Maturity
**Date**: 2026-04-28
**Branch**: `251-commercial-entitlements-billing-state`
## Decision 1: Persist lifecycle truth inside the existing `entitlements` settings domain
- **Decision**: Store the workspace commercial lifecycle overlay through explicit `WorkspaceSetting` keys in the existing `entitlements` domain, conceptually `commercial_lifecycle_state` plus `commercial_lifecycle_reason`.
- **Rationale**: Spec 247 already proved that workspace-owned commercial truth belongs in the existing workspace settings infrastructure. Reusing that path keeps audit behavior, validation, and source-of-truth ownership consistent without inventing a billing/account model or a second persistence family.
- **Alternatives considered**:
- New `subscriptions`, `billing_states`, or `customer_accounts` tables: rejected because the spec explicitly forbids broad billing/account scope.
- A separate `commercial` settings domain: rejected because the new state is an overlay on the already-real entitlement substrate, not a second independent settings family.
## Decision 2: Add one bounded lifecycle overlay service above `WorkspaceEntitlementResolver`
- **Decision**: Introduce one bounded `WorkspaceCommercialLifecycleResolver` in `App\Services\Entitlements` that composes `WorkspaceEntitlementResolver` instead of replacing it.
- **Rationale**: The underlying entitlement resolver remains canonical for plan-profile defaults, override values, and per-key allow/block truth. The new feature needs one additional workspace-wide layer that can answer lifecycle state, lifecycle rationale, and action-family outcomes across onboarding, review-pack starts, and preserved read-only history access.
- **Alternatives considered**:
- Extend `WorkspaceEntitlementResolver` until it also owns lifecycle posture: rejected because that would blur substrate truth with the new overlay and make future review of state ordering harder.
- Local page/service conditionals in onboarding, review-pack resources, and system detail: rejected because they would drift immediately.
## Decision 3: Keep system-plane mutation on the existing workspace detail page only
- **Decision**: Make `/system/directory/workspaces/{workspace}` the only mutation surface for lifecycle state changes, with inspection plus a confirmation-protected `Change commercial state` action.
- **Rationale**: The spec requires platform-managed lifecycle mutation. The existing system workspace detail page already exposes commercial truth read-only and is the narrowest platform context that can show state, rationale, and audit attribution without creating a second control plane.
- **Alternatives considered**:
- Add lifecycle mutation to `/admin/settings/workspace`: rejected because the slice must not become a self-service workspace-admin commercial control surface.
- Create a dedicated system commercial page/resource: rejected because the existing workspace detail page already anchors the platform/support workflow.
## Decision 4: Preserve explicit business-state versus authorization semantics
- **Decision**: Keep non-member and wrong-plane access as 404, keep established-scope capability denial as 403, and treat lifecycle blocking or warnings as business-state results for otherwise authorized actors.
- **Rationale**: This is the main operator value of the slice. The commercial lifecycle overlay must explain why an action is blocked without pretending the actor lacks scope or permission.
- **Alternatives considered**:
- Hide blocked actions entirely: rejected because it would erase the commercial explanation the feature exists to provide.
- Return 403 for lifecycle blocks: rejected because it would conflate business state with authorization.
## Decision 5: Review-pack lifecycle blocking must happen before `ReviewPack` or `OperationRun` creation
- **Decision**: Reuse `ReviewPackService` as the hard enforcement boundary and block lifecycle-restricted starts before any `ReviewPack` or `OperationRun` write occurs.
- **Rationale**: Current review-pack start surfaces already converge on `ReviewPackService`. Blocking at the service boundary prevents UI-surface bypass and preserves the shared OperationRun start UX for allowed actions.
- **Alternatives considered**:
- UI-only disabling on each widget/resource/page action: rejected because it would not protect direct action execution.
- A new review-pack lifecycle queue/framework: rejected because the slice changes eligibility only, not run orchestration.
## Decision 6: Reuse the existing blocked-decision transport if it can carry lifecycle metadata cleanly
- **Decision**: Prefer reusing `WorkspaceEntitlementBlockedException` and extending its decision payload for lifecycle blocks, rather than introducing a second parallel business-state exception family.
- **Rationale**: Review-pack widgets/resources already catch `WorkspaceEntitlementBlockedException` and project its `block_reason` into user-visible feedback. Reusing that transport keeps the change narrow unless implementation proves the class name or payload shape is too substrate-specific.
- **Alternatives considered**:
- New `WorkspaceCommercialLifecycleBlockedException`: rejected for now because it would widen changes across all review-pack action surfaces without proving extra value.
- Plain string returns without a shared decision payload: rejected because the UI surfaces already consume structured block context.
## Decision 7: Preserve suspended read-only access by leaving existing history/evidence/download routes outside the new gate
- **Decision**: Keep `CustomerReviewWorkspace`, `ViewTenantReview`, `ViewReviewPack`, `ViewEvidenceSnapshot`, and current review-pack download access outside the new lifecycle start gate, while allowing them to show a calm read-only explanation when helpful.
- **Rationale**: The feature promise is not "suspend everything." It is "block future starts while preserving safe existing history." Existing view/download routes already encode current RBAC and redaction semantics and are the narrowest place to preserve that truth.
- **Alternatives considered**:
- Broad product-wide suspension of all mutable controls: rejected because the spec explicitly forbids a broad suspension engine.
- No plan for preserved read access: rejected because suspension would otherwise appear as total lockout and break the evidence/history requirement.
## Decision 8: Keep the four-state vocabulary, but justify it narrowly
- **Decision**: Keep exactly four lifecycle states: `trial`, `grace`, `active_paid`, and `suspended_read_only`.
- **Rationale**: The spec requires these named postures, and platform operators need to set and audit them explicitly from one system surface. `grace` and `suspended_read_only` have immediate distinct action-family consequences. `trial` remains in scope because the platform/support workflow and audit trail need to distinguish temporary non-paid posture from steady active paid posture now, even though both allow the two in-scope gated behavior families.
- **Alternatives considered**:
- Collapse to three states by removing `trial`: rejected because it would erase a required current-release commercial posture and force later renaming/migration when trial lifecycle work grows.
- Persist only booleans like `is_suspended` and `is_in_grace`: rejected because that would not yield one clear operator-facing commercial state.
## Decision 9: Prove the slice with focused unit and feature lanes only
- **Decision**: Use one unit family for lifecycle resolution and focused feature tests for system mutation, onboarding gating, review-pack no-run blocking, and suspended read-only consumption.
- **Rationale**: The primary risk is correctness of decision ordering and bounded surface behavior, not browser layout or heavy orchestration.
- **Alternatives considered**:
- Browser tests: rejected because no browser-only interaction risk is introduced in the planning slice.
- Heavy-governance suite expansion: rejected because the scope is feature-local and uses existing surfaces.
## Decision 10: Leave panels, assets, and global search unchanged
- **Decision**: Do not add new panels, provider registration changes, global-search resources, or Filament assets as part of this slice.
- **Rationale**: The feature is a business-state overlay inside existing admin and system surfaces. Infrastructure changes would widen scope without helping the current release.
- **Alternatives considered**:
- New commercial panel: rejected because `/system` detail already anchors the platform workflow.
- Asset-backed custom commercial UI: rejected because current Filament components and the existing Blade detail view are sufficient.

View File

@ -0,0 +1,332 @@
# Feature Specification: Commercial Entitlements and Billing-State Maturity
**Feature Branch**: `251-commercial-entitlements-billing-state`
**Created**: 2026-04-28
**Status**: Draft
**Input**: User description: "Commercial lifecycle follow-up on top of the already-real Spec 247 entitlement substrate, with one central workspace lifecycle resolution, bounded lifecycle states, two real gated behaviors, explicit read-only suspension semantics, and audited state changes without expanding into a billing engine."
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot already resolves plan-profile entitlements for a workspace, but it still lacks one central commercial lifecycle state that explains whether the workspace is in trial, grace, normal paid use, or suspended/read-only posture.
- **Today's failure**: Operators can hit blocked onboarding or reporting actions without one consistent business-state explanation, and a future suspension or grace posture would otherwise be implemented as scattered local conditionals or mistaken as RBAC denial.
- **User-visible improvement**: Platform operators can set one auditable workspace commercial lifecycle state, and tenant/workspace operators then see a truthful allow, warn, or read-only message directly at onboarding and review-pack action surfaces without losing safe access to existing history and evidence.
- **Smallest enterprise-capable version**: Add one platform-managed workspace commercial lifecycle overlay on top of the existing entitlement substrate, resolve four bounded lifecycle states, gate managed-tenant onboarding activation plus review-pack start actions from that central decision, and preserve safe read-only access to existing review/evidence history while suspended.
- **Explicit non-goals**: No payment providers, invoicing, taxes, accounting, checkout, public pricing, website work, customer-account modeling, subscription engine, automated renewal reminders, broad entitlement spread, or customer self-service lifecycle management.
- **Permanent complexity imported**: One bounded lifecycle state family, one small central lifecycle resolution layer on top of the existing entitlement substrate, one platform-side state change surface with audit, and focused unit plus feature coverage.
- **Why now**: This directly extends real repo truth from Spec 247 and `WorkspaceEntitlementResolver`, so it is implementation-ready as a narrow follow-up. Localization remains a broader missing foundation, and external support-desk handoff still lacks a concrete external target.
- **Why not local**: The same commercial posture must drive system support visibility, onboarding activation, review-pack generation, and suspended read-only access rules. Local page checks would drift immediately and recreate the current manual explanation problem.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New state axis, foundation-sounding commercial theme, and multi-surface touchpoint. Defense: this slice is limited to one overlay on top of an existing resolver, one platform mutation surface, two already-real gated behaviors, and explicit read-only preservation instead of a broader billing platform.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/system/directory/workspaces/{workspace}` for platform-side inspection and lifecycle state change
- `/admin/onboarding/{onboardingDraft}` for managed-tenant onboarding activation
- `/admin/reviews` plus existing tenant review detail, tenant dashboard, and review-pack registry/detail surfaces for `Generate pack`, `Regenerate`, and `Export executive pack`
- existing read-only review, evidence, and generated-pack consumption surfaces that must remain available while suspended/read-only
- **Data Ownership**: Commercial lifecycle state remains workspace-owned truth and is stored through the existing workspace settings infrastructure. Existing plan profiles and entitlement decisions from Spec 247 remain the underlying workspace-owned substrate. Tenant-owned review packs, evidence snapshots, review history, and onboarding records stay tenant-owned and are not remodeled by this slice.
- **RBAC**: Platform users with directory visibility plus a dedicated commercial lifecycle management capability may inspect and change state on `/system`. Workspace or tenant members keep their existing onboarding and review-pack capabilities on `/admin`, but lifecycle state is a business-state overlay rather than a self-service setting. Non-members and wrong-plane actors continue to receive 404. Members missing capability continue to receive 403. Members with the required capability but blocked by lifecycle state receive a truthful business-state block instead of an authorization failure.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice does not introduce a new tenantless collection or cross-tenant list.
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. The lifecycle overlay never reveals tenant-owned history or artifacts outside the already-authorized workspace and tenant scope.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, action gating, system detail controls, operation-start blocking, evidence/report viewers
- **Systems touched**: existing workspace settings persistence, existing workspace entitlement resolution, system workspace detail view, onboarding activation gate, review-pack generation entry family, audit logging, and existing read-only review/evidence/download surfaces
- **Existing pattern(s) to extend**: existing workspace entitlement resolver and summary pattern, existing workspace-setting audit path, existing review-pack start UX, existing onboarding activation gate, and existing system detail summary surfaces
- **Shared contract / presenter / builder / renderer to reuse**: the current workspace entitlement resolution path and its audit-backed settings persistence remain the canonical substrate; this slice adds one bounded commercial lifecycle decision layer on top rather than a second parallel commercial framework
- **Why the existing shared path is sufficient or insufficient**: The current entitlement substrate is already sufficient for plan defaults, overrides, and per-key allow/block decisions. It is insufficient for one workspace-wide lifecycle posture that can say "expansion frozen" or "read-only suspended" consistently across multiple surfaces.
- **Allowed deviation and why**: none. No surface may invent local lifecycle labels, local business-state copy, or page-specific suspension rules.
- **Consistency impact**: State labels, source labels, block reasons, and read-only explanations must mean the same thing on the system workspace page, onboarding completion step, review-pack start actions, and preserved read-only review/evidence surfaces.
- **Review focus**: Reviewers must verify that all in-scope surfaces consume one shared lifecycle decision, that lifecycle overlay semantics do not expand access beyond current entitlements, and that suspended read-only messaging does not drift across surfaces.
## 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 queued-start, `Open operation`, and canonical run-link behavior remain unchanged when lifecycle state allows the start action
- **Delegated start/completion UX behaviors**: queued toast, `Open operation` link, dedupe behavior, and terminal lifecycle feedback stay on the existing review-pack path when allowed. A lifecycle block stops earlier and produces no queued-start feedback because no run is created.
- **Local surface-owned behavior that remains**: local surfaces only render lifecycle state, blocked reason, and the safe next step. They do not replace the existing review-pack run UX.
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: central lifecycle mechanism for existing review-pack runs 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 is changed. Commercial lifecycle state is platform-core workspace truth and must remain provider-neutral even when it gates provider-backed review-pack workflows.
## 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 |
|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | yes | Native Filament system detail page | detail summary, header actions, status messaging | detail page, header action, summary card | no | Single platform mutation surface only |
| Managed tenant onboarding activation gate | yes | Native Filament wizard | action gating, helper text, business-state callout | completion step, confirmation action | no | Reuses the existing activation step |
| Review-pack generation entry family | yes | Native Filament widget/resource/page actions | operation-start gating, helper text, state badges | widget action, detail action, list/header action | no | Only `Generate pack`, `Regenerate`, and `Export executive pack` are in scope |
| Existing read-only review, evidence, and generated-pack consumption surfaces | yes | Native Filament detail and download surfaces | evidence/report viewers, detail messaging | detail page, download action, read-only summary | no | No new routes; the slice only preserves safe read-only availability during suspension |
## 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 |
|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | Primary Decision Surface | Platform operator decides whether a workspace should remain trial, move into grace, return to active paid, or become suspended/read-only | Current state, rationale, affected action families, and last changed attribution | Existing entitlement summary and related workspace diagnostics | Primary because this is the one place where commercial posture is intentionally changed | Follows platform support/commercial workflow rather than customer admin navigation | Prevents founders or support staff from reconstructing state from ad hoc notes and blocked actions |
| Managed tenant onboarding activation gate | Primary Decision Surface | Workspace operator decides whether the current tenant may be activated now | Lifecycle state, whether activation is allowed, and the business-state reason when blocked | Existing onboarding verification and readiness diagnostics remain secondary | Primary because onboarding completion is the actual high-impact mutation point | Keeps the commercial decision inside the activation workflow | Removes the need to ask support whether a block is about permissions or billing state |
| Review-pack generation entry family | Secondary Context Surface | Reporting operator decides whether to start, retry, or export a review pack from the current tenant or review context | Lifecycle state, whether the start action is blocked, and the safe fallback when suspended/read-only | Existing run state, artifact truth, and review history remain secondary | Not primary because the family exists to continue reporting/review workflows, not to manage commercial posture itself | Stays inside existing report-generation workflows | Avoids a second support lookup just to understand why generation is blocked |
| Existing read-only review, evidence, and generated-pack consumption surfaces | Tertiary Evidence / Diagnostics Surface | Customer-safe or operator read-only consumer verifies existing history while the workspace is suspended/read-only | Existing history, evidence, and generated pack availability plus a calm read-only explanation | Raw provider or support diagnostics remain secondary and capability-gated | Not primary because these surfaces answer "what history is still safe to read" rather than "what state should change" | Preserves evidence-first review consumption instead of forcing new export workarounds | Prevents suspended workspaces from looking completely unavailable when history should still be readable |
## 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 |
|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | support-platform, operator-platform | Current lifecycle state, rationale, last changed attribution, and affected behavior summary | Existing workspace entitlement summary and tenant counts | No raw settings payload or internal debug data by default | `Change commercial state` | Raw settings rows and internal debugging remain hidden | The page states the lifecycle blocker once and reuses the same labels later rather than restating them differently |
| Managed tenant onboarding activation gate | operator-MSP | Activation allowed/blocked, current lifecycle state, and why the block is business-state rather than permission-state | Existing readiness and verification diagnostics already on the wizard | No support/raw payloads on the default path | `Complete onboarding` when allowed, otherwise `Request commercial review` | Deeper commercial diagnostics stay off the onboarding surface | The step shows one lifecycle explanation and does not restate the whole workspace commercial profile |
| Review-pack generation entry family | operator-MSP | Start action availability, current lifecycle state, and the safe fallback when generation is blocked | Existing run state and artifact status | No raw support diagnostics on start surfaces | `Generate pack`, `Regenerate`, or `Export executive pack` when allowed; otherwise `View current pack` | System-only lifecycle controls stay off these surfaces | One shared lifecycle reason is reused across all in-scope start actions |
| Existing read-only review, evidence, and generated-pack consumption surfaces | customer-read-only, operator-MSP | What history remains available and why the workspace is read-only rather than fully inaccessible | Existing review history and artifact provenance | Support/raw details remain collapsed or gated | `View current review` or `Download current pack` | Any mutation affordance stays blocked in suspended/read-only posture | The read-only explanation appears once and later sections add evidence rather than repeating the same blocker |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | System / Detail / Diagnostics | Read-only detail with bounded mutation action | Change the workspace lifecycle state | Dedicated workspace detail page | forbidden | Existing admin-workspace and related navigation stay secondary | `Change commercial state` contains the high-risk `Suspended / read-only` path with explicit confirmation | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity plus current lifecycle state | Commercial lifecycle | Current state, rationale, and affected behaviors | Acceptable detail-surface exception because mutation stays bounded to one header action on the detail page |
| Managed tenant onboarding activation gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because commercial state blocks expansion | In-page completion step | forbidden | Existing back-navigation and tenant links stay secondary | Existing `Cancel draft` and `Delete draft` remain the only destructive actions | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context plus current tenant | Onboarding commercial state | Activation allowed or blocked and why | Existing wizard exception remains valid |
| Review-pack generation entry family | Contextual action family | Widget/resource/page start actions | Start, retry, or export a review pack when allowed | Explicit action on the current tenant or review context | mixed - existing registry rows may still open detail, but start actions remain explicit | Existing `View` and `Download` stay secondary and outside the blocked start gate | Existing destructive actions remain out of scope and keep current placement | `/admin/reviews` plus existing tenant review-pack collection surfaces | Existing tenant review detail and review-pack detail surfaces | Active workspace, active tenant, review or pack context | Review-pack generation | Start allowed or blocked, and the safe read-only fallback | Grouped-action family exception is documented here so all start actions share one gate |
| Existing read-only review, evidence, and generated-pack consumption surfaces | Detail / Report viewer / Download | Read-only detail and artifact consumption | View history or download an already-generated pack | Existing review or pack detail page | allowed where the current collection already opens detail | Supporting navigation remains secondary | none | Existing review and review-pack collections | Existing review, evidence, and review-pack detail routes | Active workspace, active tenant, current artifact or review | Review history / Generated pack | Safe read-only availability during suspension | No new surface type introduced |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | Platform commercial or support operator | Decide the current commercial posture of a workspace | System detail page | What lifecycle state should this workspace be in now? | State, rationale, affected behaviors, and last changed attribution | Existing entitlement summary and workspace diagnostics | commercial lifecycle, entitlement substrate | TenantPilot only | Change commercial state | Set suspended/read-only |
| Managed tenant onboarding activation gate | Workspace owner or manager completing onboarding | Decide whether the current tenant can be activated now | Guided workflow step | Can I activate this tenant under the current commercial posture? | Current lifecycle state, whether activation is allowed, and the block reason when not | Existing verification and bootstrap detail | onboarding readiness, commercial lifecycle, entitlement availability | TenantPilot only for activation state | Complete onboarding | Cancel draft, Delete draft |
| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether a new pack run may start now | Contextual start-action family | Can I start, retry, or export a pack from this context? | Current lifecycle state, whether the start action is blocked, and the safe fallback | Existing run state, review status, and artifact truth | commercial lifecycle, entitlement availability, run state, artifact status | TenantPilot only until the existing run starts | Generate pack, Regenerate, Export executive pack, View current pack | Existing destructive actions remain unchanged and out of scope |
| Existing read-only review, evidence, and generated-pack consumption surfaces | Customer-safe reader or workspace operator | Consume already-generated history safely while the workspace is read-only | Read-only detail and download surfaces | What history can I still read or download safely? | Existing review/evidence/download truth plus a calm read-only explanation | Raw provider diagnostics and support-only detail | commercial lifecycle, artifact availability | none | View current review, Download current pack | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes - one workspace-owned commercial lifecycle state becomes current-release business truth, but it is stored through existing workspace settings rather than a new table
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes - one bounded lifecycle resolution layer on top of the existing entitlement substrate
- **New enum/state/reason family?**: yes - the four-state lifecycle family (`trial`, `grace`, `active_paid`, `suspended_read_only`)
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Support and operators cannot truthfully explain whether a workspace is in a normal commercial state, an expansion freeze, or a read-only suspension without reconstructing the answer from scattered surface behavior.
- **Existing structure is insufficient because**: Spec 247 gives per-key entitlement truth, but it does not provide one workspace-wide lifecycle posture that can say "activation blocked but reading is still safe" or "new runs blocked while history remains available."
- **Narrowest correct implementation**: Keep persistence inside the existing workspace settings infrastructure, add one small state family and one shared resolution layer, mutate it from one system detail page, and apply it only to two already-real start behaviors plus suspended read-only preservation.
- **Ownership cost**: One state vocabulary, one additional decision layer, cross-surface copy discipline, and focused tests for state transitions plus allowed/blocked behavior.
- **Alternative intentionally rejected**: A new subscription/customer-account model or many per-surface lifecycle flags was rejected because the repo has no current billing domain and the smallest safe slice only needs one central commercial posture.
- **Release truth**: current-release truth with later follow-up candidates for automation, billing integration, and broader lifecycle-aware entitlement spread
### 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 default state resolution, state precedence over existing entitlements, and state-to-behavior mapping. Focused feature coverage proves platform mutation, audit logging, onboarding blocks, review-pack start blocks, and preserved read-only access without expanding into browser or heavy-governance lanes.
- **New or expanded test families**: one bounded lifecycle resolver unit family plus focused extensions to the existing system detail, onboarding, review-pack, and preserved read-only feature families
- **Fixture / helper cost impact**: Add only workspace, platform user, workspace member, onboarding draft, active tenant count, existing review pack, and existing evidence/history fixtures required to prove the state consequences. Avoid payment-provider mocks, browser harnesses, or new heavy support fixtures.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, shared-detail-family, monitoring-state-page
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the system detail mutation surface and onboarding gate. Review-pack gating still needs monitoring-state assertions to prove blocked starts create no run, while suspended read-only preservation needs one detail/download assertion on existing artifact surfaces.
- **Reviewer handoff**: Reviewers must confirm that lifecycle blocks remain distinct from 404 and 403 outcomes, that source labels stay consistent on the system detail surface, that `grace` and `suspended_read_only` do not collapse into one behavior, that blocked review-pack starts create no queued or terminal notification, that already queued or running review-pack runs remain unaffected by later suspension, and that existing read-only history/download access remains available under current RBAC.
- **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/WorkspaceCommercialLifecycleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
## Scope Boundaries *(required for this slice)*
### In Scope
- One central workspace commercial lifecycle overlay with exactly four states: `trial`, `grace`, `active_paid`, and `suspended_read_only`
- One platform-managed lifecycle change path with rationale and audit, persisted through the existing workspace settings infrastructure
- One shared lifecycle resolution path layered on top of the existing Spec 247 entitlement substrate
- Lifecycle gating of managed-tenant onboarding activation
- Lifecycle gating of review-pack `Generate pack`, `Regenerate`, and `Export executive pack` entry points
- Suspended/read-only preservation of authorized review history, evidence, and already-generated review-pack consumption
- Explicit business-state messaging that distinguishes lifecycle blocks from RBAC failures
### Non-Goals
- Payment providers, invoices, taxes, accounting, checkout, public pricing, website work, and payment failure workflows
- New customer-account, subscription, contract, or offer models
- Automated timers, expiries, reminders, or scheduled state transitions
- Customer self-service state changes from the workspace admin plane
- Broad entitlement expansion across seats, exports, retention, support SLAs, or unrelated feature flags
- Broad suspension logic across every mutable surface in the product
- A second commercial control plane outside the existing system workspace detail flow
## Assumptions
- Spec 247 remains the canonical entitlement substrate. Commercial lifecycle state is an overlay that can warn or restrict, not a replacement for plan-profile and per-key entitlement truth.
- Commercial lifecycle mutation is platform-managed in this slice. Workspace and tenant operators may observe the resulting state where it matters, but they do not change it themselves.
- If no explicit lifecycle state has been set for a workspace, the system resolves to `active_paid` so that current repo behavior stays unchanged until a platform operator intentionally selects a different state.
- `grace` is intentionally narrower than `suspended_read_only`: it freezes new managed-tenant activation but continues to allow existing review-pack start behavior when the underlying entitlement substrate still allows it.
- `suspended_read_only` preserves existing review/evidence/download access under current RBAC and redaction rules, but blocks new onboarding activation and new review-pack start actions.
## Risks
- `grace` and `suspended_read_only` can drift into near-duplicates if blocked-action copy and tests do not keep their consequences distinct.
- A later customer-account or billing source could require revisiting how manual lifecycle transitions are sourced, even though that broader domain is intentionally out of scope here.
- A future admin-plane commercial settings surface could confuse ownership if it appears without preserving platform-only mutation authority.
- Mid-flight review-pack runs created before a workspace becomes suspended could create confusion if the product does not clearly state that this slice only blocks future starts.
## Deferred Adjacent Candidates
- **Localization v1** remains a separate, broader foundation candidate because it requires cross-product locale resolution and copy governance beyond this bounded commercial lifecycle slice.
- **External Support Desk / PSA Handoff** remains a separate candidate because repo docs still do not define one concrete external desk target to hand off into.
- Broader billing lifecycle automation, reminders, and external billing-source integration stay deferred until a real account and payment domain exists in repo truth.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Set one workspace commercial lifecycle state centrally (Priority: P1)
As a platform commercial or support operator, I want to set a workspace's current commercial lifecycle state once so downstream product behavior follows one audited source of truth instead of local exceptions.
**Why this priority**: Without one central lifecycle state, every later gate or support explanation would duplicate commercial truth and drift away from the already-real entitlement substrate.
**Independent Test**: Open the existing system workspace detail surface, change the lifecycle state with rationale, and verify that the new state is visible there and auditable without touching onboarding or reporting flows.
**Acceptance Scenarios**:
1. **Given** a workspace has no explicit commercial lifecycle state, **When** an authorized platform operator sets it to `trial` with rationale, **Then** the workspace resolves to `trial`, the change is auditable, and the system detail surface shows the new state and rationale.
2. **Given** a workspace is currently in `grace`, **When** an authorized platform operator changes it to `suspended_read_only`, **Then** the previous state is replaced, the new state is auditable, and later gated surfaces consume the new state.
3. **Given** a workspace is in `suspended_read_only`, **When** an authorized platform operator returns it to `active_paid`, **Then** future gated actions again use the normal underlying entitlement substrate instead of the suspended overlay.
---
### User Story 2 - Truthfully block tenant activation when lifecycle state freezes expansion (Priority: P1)
As an authorized workspace operator, I want the onboarding completion step to tell me whether the tenant may be activated under the current commercial lifecycle state so I can distinguish business-state blocking from permissions or onboarding readiness problems.
**Why this priority**: Managed-tenant activation is the highest-risk first-slice mutation and the clearest place where a grace or suspended posture must stop expansion without ambiguity.
**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that the same action becomes allowed or blocked with the correct business-state explanation before any activation mutation happens.
**Acceptance Scenarios**:
1. **Given** a workspace is `trial` or `active_paid` and the existing entitlement substrate allows activation, **When** an authorized operator reaches the onboarding completion step, **Then** the step allows completion and no lifecycle block is shown.
2. **Given** a workspace is in `grace`, **When** the same operator reaches the completion step, **Then** the action remains visible but blocked with a business-state explanation that new managed-tenant activation is frozen during grace.
3. **Given** a workspace is in `suspended_read_only`, **When** the operator reaches the same step, **Then** activation is blocked before any tenant mutation occurs and the step explains that the workspace is read-only rather than lacking permission.
---
### User Story 3 - Block new review-pack starts while preserving safe historical access (Priority: P2)
As a reporting operator or customer-safe reader, I want new review-pack start actions to obey the current commercial lifecycle state while already-generated history remains safely readable so suspension does not erase needed evidence.
**Why this priority**: Review-pack generation already exists on multiple real surfaces, and suspension is only trustworthy if it blocks new starts consistently while preserving safe access to history and already-generated evidence.
**Independent Test**: Seed a workspace with an existing generated pack and history, switch it to `suspended_read_only`, verify that `Generate pack`, `Regenerate`, and `Export executive pack` stop before any new run or artifact is created, that blocked starts emit no queued or terminal review-pack notification, that already queued or running review-pack work continues unchanged, and then confirm that authorized readers can still view or download the already-generated artifacts.
**Acceptance Scenarios**:
1. **Given** a workspace is `active_paid` or `trial` and the existing review-pack entitlement allows generation, **When** an authorized operator starts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the current review-pack flow continues unchanged.
2. **Given** a workspace is in `grace` and the underlying review-pack entitlement allows generation, **When** an authorized operator starts the same action, **Then** the action remains allowed with a grace warning and without blocking the run.
3. **Given** a workspace is in `suspended_read_only`, **When** an authorized operator attempts `Generate pack`, `Regenerate`, or `Export executive pack`, **Then** the action is blocked before any new `ReviewPack` or `OperationRun` is created and no queued or terminal review-pack notification is emitted for the blocked attempt.
4. **Given** a review-pack run was already created while the workspace lifecycle state still allowed it, **When** the workspace later moves to `suspended_read_only`, **Then** the existing queued or running review-pack work may complete unchanged because this slice only blocks future start attempts.
5. **Given** a workspace is in `suspended_read_only` and already has generated review packs, evidence, or review history, **When** an authorized reader opens or downloads those existing artifacts, **Then** the existing read-only access continues under current RBAC and redaction rules.
### Edge Cases
- A workspace with no explicit lifecycle state must still resolve deterministically to `active_paid` so current Spec 247 behavior does not change accidentally.
- If the lifecycle state allows a behavior but the underlying entitlement substrate blocks it, the underlying entitlement block still applies and must remain distinguishable from lifecycle blocking.
- If the lifecycle state becomes `suspended_read_only` while a review-pack run is already queued or running, the existing run may complete; the new state only blocks future start attempts in this slice.
- A workspace member who lacks the relevant onboarding or review-pack capability must still receive 403 even when the workspace lifecycle state is otherwise permissive.
- A non-member or wrong-plane actor must not learn whether a workspace is in `grace` or `suspended_read_only`; those requests continue to resolve as 404.
- Suspended/read-only behavior must never revoke access to already-generated artifacts or review/evidence history that the actor is otherwise allowed to read.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes runtime behavior and writes workspace-owned commercial state, but it adds no Microsoft Graph calls, no new provider dispatch path, and no new queued workflow family. Lifecycle state changes use the existing workspace settings infrastructure and audit foundation. Existing review-pack `OperationRun` behavior is reused only when lifecycle state allows a start action.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new business-state family because current-release operator workflows now need a workspace-wide commercial posture that per-key entitlement decisions cannot express alone. A narrower local-only approach would still scatter lifecycle semantics across onboarding and review-pack surfaces.
**Constitution alignment (XCUT-001):** All in-scope gated behaviors and preserved read-only surfaces must consume the same lifecycle decision. No local page is allowed to invent its own trial, grace, or suspension semantics.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Blocked onboarding and blocked review-pack starts must show customer-safe or operator-safe default content first, with diagnostics and support-only detail remaining secondary. Suspended read-only surfaces must preserve one calm next step instead of turning history surfaces into error pages.
**Constitution alignment (PROV-001):** Commercial lifecycle state is platform-core workspace truth and must not import provider-specific vocabulary or billing-provider semantics.
**Constitution alignment (TEST-GOV-001):** Proof remains in focused unit plus feature lanes. New fixtures stay limited to workspace, platform operator, workspace member, onboarding draft, tenant count, and existing review-pack/evidence artifacts.
**Constitution alignment (OPS-UX):** This feature does not create a new run family. Existing review-pack generation keeps the current queued toast, operation link, and terminal notification path when lifecycle state allows it. Blocked lifecycle starts create no run and no run lifecycle feedback.
**Constitution alignment (OPS-UX-START-001):** Lifecycle gating sits before review-pack run creation and delegates all allowed queued-start UX to the existing shared review-pack path.
**Constitution alignment (RBAC-UX):** Two authorization planes are involved: platform `/system` for lifecycle mutation and tenant/admin `/admin` for contextual blocked-or-allowed behavior. Wrong-plane or non-member requests remain 404. Members missing capability remain 403. Lifecycle blocking is a product-state response for otherwise-authorized actors and must not masquerade as authorization failure.
**Constitution alignment (BADGE-001):** If lifecycle badges or state chips are rendered, their labels and visual semantics must come from one shared lifecycle vocabulary rather than page-local color mapping.
**Constitution alignment (UI-FIL-001):** The slice must extend existing native Filament detail, wizard, widget, resource, and download surfaces. No custom commercial panel or page-local status language is allowed.
**Constitution alignment (UI-NAMING-001):** Primary labels remain product-facing and specific: `Trial`, `Grace`, `Active paid`, `Suspended / read-only`, `Change commercial state`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. Billing-provider or checkout terminology remains out of scope.
**Constitution alignment (DECIDE-001):** The system workspace detail page is the one primary commercial decision surface. Onboarding and review-pack surfaces remain contextual decision points that only show the commercial truth required for the immediate action. Existing history/evidence surfaces remain tertiary read-only contexts.
**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 system detail, guided onboarding, grouped review-pack actions, and read-only artifact consumption patterns. It may not create a second admin-plane commercial management surface or redundant inspect actions.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Lifecycle mutation stays on the system workspace detail page. Onboarding completion remains the primary activation action. Review-pack start actions remain the primary reporting mutations where they already exist. View/download history remains secondary but available during suspension.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin lifecycle overlay is justified because direct reads from the existing entitlement substrate cannot express one workspace-wide read-only posture. Tests must prove business outcomes such as allowed, warned, blocked, and preserved-read behavior rather than badge rendering alone.
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with the documented system detail exception for one bounded mutation action, the existing onboarding wizard exception, and the existing review-pack action family. No empty action groups or redundant view actions are introduced by this slice.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature extends the existing system detail, onboarding, and review-pack surfaces with bounded state information only. It does not create a new commercial page shell or duplicate summary screen.
### Functional Requirements
- **FR-251-001 Central lifecycle state**: The system MUST resolve one commercial lifecycle state per workspace with exactly four values: `trial`, `grace`, `active_paid`, and `suspended_read_only`.
- **FR-251-002 Existing entitlement substrate remains canonical**: The system MUST layer lifecycle state on top of the existing Spec 247 entitlement substrate rather than replacing plan profiles, entitlement keys, or override logic.
- **FR-251-003 Deterministic default**: If no explicit lifecycle state has been stored for a workspace, the system MUST resolve to `active_paid` so existing behavior remains unchanged until an operator intentionally changes state.
- **FR-251-004 Workspace-owned persistence**: The system MUST store lifecycle state and rationale through the existing workspace settings infrastructure instead of introducing a new customer-account, subscription, or billing table.
- **FR-251-005 Platform-managed mutation**: Only authorized platform users MAY change or override lifecycle state in this slice, and the workspace or tenant admin plane MUST NOT become a self-service lifecycle control surface.
- **FR-251-006 Decision shape**: The effective lifecycle decision MUST include the state, source, operator-visible rationale, last changed attribution, and a summary of which in-scope behaviors are currently warned, allowed, or blocked.
- **FR-251-007 State precedence**: Lifecycle state MUST apply after the existing entitlement substrate and MAY only warn or restrict. It MUST NOT expand access beyond what the underlying entitlement decision already allows.
- **FR-251-008 Onboarding activation gate**: Managed-tenant onboarding activation MUST consult the shared lifecycle decision before mutation. `grace` and `suspended_read_only` MUST block activation before any tenant activation state changes occur.
- **FR-251-009 Review-pack start gate**: `Generate pack`, `Regenerate`, and `Export executive pack` MUST consult the shared lifecycle decision before creating or reusing a `ReviewPack` or `OperationRun`. `suspended_read_only` MUST block those actions before any new run or artifact start occurs.
- **FR-251-010 Grace semantics**: `grace` MUST have a distinct behavioral consequence from `active_paid` by freezing new managed-tenant onboarding activation while leaving in-scope review-pack start behavior under the existing entitlement substrate.
- **FR-251-011 Suspended read-only semantics**: `suspended_read_only` MUST block onboarding activation and review-pack start actions while preserving authorized read-only access to existing review history, evidence, and already-generated review-pack consumption.
- **FR-251-012 In-flight behavior boundary**: A lifecycle state change to `suspended_read_only` MUST affect future start attempts only in this slice and MUST NOT retroactively cancel already-created review-pack runs.
- **FR-251-013 Message semantics**: Gated surfaces MUST clearly distinguish lifecycle business-state blocking from entitlement-limit blocking and from authorization failure.
- **FR-251-014 System visibility**: The system workspace detail surface MUST show the current lifecycle state, rationale, affected behavior summary, and last changed attribution to authorized platform users.
- **FR-251-015 Auditability**: Every lifecycle state change and manual override MUST create an auditable record containing old state, new state, actor, and rationale.
- **FR-251-016 No scattered lifecycle conditionals**: Onboarding, review-pack generation, and preserved read-only surfaces MUST use the shared lifecycle decision rather than local page-specific commercial booleans.
- **FR-251-017 Bounded non-goals**: This slice MUST NOT introduce payment providers, invoices, taxes, accounting, checkout, public pricing, website work, customer-account modeling, broad billing automation, or broad entitlement spread beyond the in-scope behaviors above.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace commercial-state controls | existing system workspace detail surface | none on collection | dedicated detail route | none | none | N/A | `Change commercial state` with bounded state selection and rationale; `Suspended / read-only` path requires explicit confirmation | N/A | yes | Existing system-detail exception remains bounded to one platform mutation surface |
| Managed tenant onboarding activation gate | existing onboarding wizard completion step | existing back-navigation and tenant links | N/A - guided workflow | none | none | existing onboarding start state unchanged | `Complete onboarding` remains the primary action and becomes lifecycle-gated | N/A | yes - existing onboarding activation audit path | Existing wizard exception remains valid |
| Review-pack generation entry family | existing tenant dashboard, review register, tenant review detail, and review-pack detail/registry surfaces | current `Generate pack`, `Regenerate`, and `Export executive pack` actions stay primary where already present | existing registry/detail affordances remain unchanged | existing `View` or `Download` shortcuts remain secondary where already present | none | existing `Generate` CTA remains where already present | existing start actions are lifecycle-gated; `View` and `Download` remain outside the blocked-start gate | N/A | no new audit requirement for blocked attempts | Grouped action family stays consistent and does not invent new local start actions |
| Existing read-only review, evidence, and generated-pack consumption surfaces | existing review/evidence/detail/download surfaces | none | existing detail routes | existing `View` or `Download` actions remain available under current RBAC | none | N/A | existing read-only view/download actions remain available during suspension | N/A | no new audit action; read-only continuation only | No new surface is created; the slice only preserves availability semantics |
### Key Entities *(include if feature involves data)*
- **Workspace Commercial Lifecycle Setting**: Workspace-owned commercial posture consisting of lifecycle state, rationale, and last change attribution, persisted through the existing workspace settings infrastructure.
- **Effective Commercial Lifecycle Decision**: Derived decision that overlays the existing entitlement substrate and answers whether in-scope behaviors are allowed, warned, or blocked, plus why.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Authorized platform operators can inspect and change a workspace commercial lifecycle state from one system workspace detail surface and see the updated state plus rationale immediately afterward.
- **SC-002**: Authorized workspace operators can determine in under 30 seconds whether onboarding activation or review-pack start is blocked by commercial state rather than by missing permission or underlying entitlement limits.
- **SC-003**: 100% of `suspended_read_only` blocked onboarding or review-pack start attempts stop before activation mutation or new run/artifact creation, while authorized readers still retain access to already-generated history and evidence.
- **SC-004**: Every commercial lifecycle state change produces one auditable old-state to new-state record with actor and rationale, and platform support can inspect that state from one canonical system surface.

View File

@ -0,0 +1,190 @@
---
description: "Task list for feature implementation"
---
# Tasks: Commercial Entitlements and Billing-State Maturity
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` plus `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
## Test Governance Notes
- Lane assignment: `fast-feedback` and `confidence` are the narrowest sufficient proof for resolver precedence, system-plane mutation, onboarding gating, review-pack start blocking, and preserved suspended read-only continuation.
- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/` plus focused `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/`; do not widen this slice into browser or heavy-governance families.
- Reuse existing workspace, platform-user, workspace-member, onboarding-draft, tenant, review-pack, and evidence fixtures; any new helper or factory state must stay opt-in and cheap by default.
- If implementation needs a bounded exception for blocked-decision transport or preserved read-only scope, record `document-in-feature` or `follow-up-spec` in the final close-out task instead of widening feature scope.
## Scope Control Notes
- Keep implementation inside one commercial lifecycle overlay, one system-plane lifecycle mutation surface, managed-tenant onboarding activation gating, review-pack generation/regeneration/export gating, and preserved read-only review/evidence/download semantics while suspended.
- Do not add payment provider, invoicing, checkout, website, customer-account, localization, external support-desk handoff, or broad billing-platform work.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Lock the bounded slice, contract semantics, and validation plan before runtime edits begin.
- [x] T001 Review the bounded slice, explicit non-goals, scope-control decisions, and review outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md`
- [x] T002 [P] Review the lifecycle-state model, system/admin split, preserved read-only contract, and 404 versus 403 versus business-state semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/contracts/workspace-commercial-lifecycle-overlay.logical.openapi.yaml`
- [x] T003 [P] Confirm the focused Sail/Pest proof commands and reviewer scenarios in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared lifecycle primitives that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Register the commercial lifecycle state and rationale setting definitions, validation metadata, and operator-facing labels in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Settings/SettingsRegistry.php`
- [x] T005 [P] Add the bounded four-state catalog, action-decision matrix, and shared overlay resolution logic in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`
- [x] T006 Thread lifecycle setting resolution, default `active_paid` fallback, and lifecycle change attribution through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`
**Checkpoint**: Foundation ready. User story work can now proceed independently without inventing local lifecycle state.
---
## Phase 3: User Story 1 - Set Workspace Commercial State Centrally (Priority: P1) 🎯 MVP
**Goal**: Let an authorized platform operator inspect and change one workspace commercial lifecycle state from the existing system workspace detail surface.
**Independent Test**: Open `/system/directory/workspaces/{workspace}` as an authorized and unauthorized platform actor, change the lifecycle state with rationale, and verify the page shows current state, affected behavior summary, last-changed attribution, and audit-backed mutation semantics without creating a second control plane.
### Tests for User Story 1
- [x] T007 [P] [US1] Add unit coverage for default `active_paid` fallback, explicit stored states, `default_active_paid` versus `workspace_setting` source resolution, grace versus suspended action outcomes, and last-change attribution in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- [x] T008 [P] [US1] Extend system-plane feature coverage for lifecycle summary and source-label rendering, capability-gated mutation, confirmation plus rationale validation for every explicit transition, and 404 versus 403 semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`
### Implementation for User Story 1
- [x] T009 [US1] Add the dedicated commercial-lifecycle management capability and apply it to the system workspace detail action surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Auth/PlatformCapabilities.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`
- [x] T010 [US1] Project the shared lifecycle state, source label, rationale, affected-behavior summary, and last-changed attribution onto `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/system/pages/directory/view-workspace.blade.php`
- [x] T011 [US1] Add the confirmation-protected `Change commercial state` action with audited old/new state writes and rationale validation for every explicit lifecycle transition in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php`
**Checkpoint**: User Story 1 is independently functional when the system plane exposes one canonical lifecycle decision and one audited mutation path.
---
## Phase 4: User Story 2 - Truthfully Gate Managed-Tenant Activation (Priority: P1)
**Goal**: Keep onboarding completion visible to otherwise authorized workspace actors while blocking activation with business-state truth when `grace` or `suspended_read_only` freezes expansion.
**Independent Test**: Seed workspaces in `trial`, `active_paid`, `grace`, and `suspended_read_only`, open the existing onboarding completion step, and verify that activation is either allowed or blocked with the correct lifecycle explanation before any tenant activation mutation occurs.
### Tests for User Story 2
- [x] T012 [P] [US2] Extend onboarding feature coverage for trial/active allow, grace block, suspended block, and 404 versus 403 versus business-state outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
### Implementation for User Story 2
- [x] T013 [US2] Project the shared lifecycle decision onto the onboarding completion step and helper text in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T014 [US2] Enforce lifecycle blocking before any tenant activation mutation or onboarding completion audit path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T015 [US2] Keep grace and suspended explanations distinct from entitlement-limit and authorization failures by sourcing block messaging from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
**Checkpoint**: User Story 2 is independently functional when onboarding activation exposes one truthful lifecycle decision and never mutates tenant state after a commercial-state block.
---
## Phase 5: User Story 3 - Block New Review-Pack Starts While Preserving Read-Only History (Priority: P2)
**Goal**: Reuse one lifecycle decision for `Generate pack`, `Regenerate`, and `Export executive pack` while keeping current review, evidence, and already-generated pack consumption available under existing RBAC during suspension.
**Independent Test**: Switch a workspace with existing review history, evidence, and generated packs to `suspended_read_only`, verify that all in-scope start actions block before any new `ReviewPack` or `OperationRun` write occurs, and confirm that authorized actors can still view or download existing artifacts.
### Tests for User Story 3
- [x] T016 [P] [US3] Extend review-pack feature coverage for allowed `trial`/`active_paid`, warned-but-allowed `grace` starts, blocked `suspended_read_only` starts, no new `ReviewPack` or `OperationRun` writes, no queued or terminal notification on blocked starts, and already queued or running review-pack work remaining unaffected by later suspension in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
- [x] T017 [P] [US3] Extend suspended read-only consumption coverage for customer review workspace access, current pack download, and evidence snapshot detail access in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
### Implementation for User Story 3
- [x] T018 [US3] Enforce lifecycle gating before any new `ReviewPack`, `OperationRun`, or blocked-start notification path and reuse the existing blocked-decision transport instead of adding a second exception path while leaving already-created runs unaffected in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/ReviewPackService.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Exceptions/Entitlements/WorkspaceEntitlementBlockedException.php`
- [x] T019 [P] [US3] Project lifecycle allow/warn/block messaging onto the tenant dashboard and review register start surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
- [x] T020 [P] [US3] Gate `Generate pack`, `Regenerate`, and `Export executive pack` actions while keeping `View` and `Download` affordances unchanged in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
- [x] T021 [US3] Preserve suspended read-only review history, evidence, and generated-pack consumption without widening into a broader suspension sweep in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
**Checkpoint**: User Story 3 is independently functional when all in-scope start actions share one lifecycle gate and suspended workspaces still retain safe read-only access to existing history and evidence.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Run the narrow validation lanes, format touched files, and capture the feature-local close-out without widening scope.
- [x] T022 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- [x] T023 Run the targeted system-plane and onboarding Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`
- [x] T024 Run the targeted review-pack, blocked-start no-notification, in-flight-boundary, and preserved-read-only Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
- [x] T025 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/quickstart.md`
- [x] T026 Record the final guardrail close-out, lane results, workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` or `follow-up-spec` note for blocked-decision transport or preserved read-only scope in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/251-commercial-entitlements-billing-state/checklists/requirements.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: starts 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 lifecycle setting and resolver primitives exist.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: first shippable increment once Phase 2 is complete.
- **US2 (P1)**: independently testable after Phase 2 and should follow US1 in the main implementation loop because the system-plane lifecycle vocabulary and audit semantics become canonical there.
- **US3 (P2)**: independently testable after Phase 2 and should merge after US1 because review-pack surfaces must reuse the same lifecycle vocabulary and blocked-decision transport.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap before implementation.
- Complete the shared service or enforcement seam before wiring multiple UI entry points that depend on it.
- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story.
---
## Parallel Opportunities
### Phase 1
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
### Phase 2
- T004 and T005 can run in parallel.
- T006 should follow once the lifecycle setting keys and resolver shape exist.
### User Story 1
- T007 and T008 can run in parallel.
- T009 can proceed before T010 and T011, but T010 and T011 should stay coordinated because both touch `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php`.
### User Story 2
- T012 can run in parallel with any remaining US1 validation once Phase 2 is complete.
- T013, T014, and T015 should stay sequential because they all tighten the same onboarding completion boundary.
### User Story 3
- T016 and T017 can run in parallel.
- After T018 establishes the service-level gate, T019 and T020 can run in parallel.
- T021 should follow the shared start-gate work so preserved read-only semantics stay bounded to existing consumption surfaces.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **Phase 2 + User Story 1 + User Story 2**. This is the smallest slice that creates canonical lifecycle truth, exposes the one platform-side mutation surface, and proves a real business-state consequence (`grace` / `suspended_read_only` onboarding activation gating) without yet widening into review-pack and preserved-history follow-up.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate system-plane lifecycle mutation plus audit semantics.
3. Deliver US2 and validate onboarding business-state gating.
4. Deliver US3 and validate review-pack start blocking plus preserved suspended read-only history/evidence/download access.
5. Finish with Phase 6 validation, formatting, and feature-local close-out recording.

View File

@ -0,0 +1,60 @@
# Specification Quality Checklist: Platform Localization v1 (DE/EN)
**Purpose**: Validate specification completeness and quality before proceeding to implementation planning
**Created**: 2026-04-28
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Business value and operator outcomes stay explicit
- [x] Locale precedence, persistence ownership, and invariance boundaries are explicit
- [x] Runtime-governance sections are present for an implementation-ready spec package
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Acceptance scenarios are defined for the primary user journeys
- [x] Edge cases are identified
- [x] Scope is clearly bounded to platform runtime localization, not website or broad documentation translation
- [x] Dependencies and assumptions are identified
## Feature Readiness
- [x] The first slice is small enough for a bounded implementation loop
- [x] The plan identifies the concrete repo surfaces likely to change
- [x] The tasks are ordered, testable, and grouped by user story
- [x] No unresolved product question blocks safe implementation of the first slice; system-panel scope is explicitly limited to explicit override plus system default in v1
## Governance Readiness
- [x] New persistence is justified and remains minimal
- [x] Provider-boundary handling and glossary reuse are explicit
- [x] Existing RBAC and tenant/workspace isolation remain authoritative
- [x] Operator-facing surface changes include the required UI contract sections
- [x] Livewire v4 compliance, unchanged provider registration location, unchanged global-search semantics, no destructive-action additions, and unchanged asset strategy are explicit in the package
- [x] Export, audit, raw payload, and machine-readable invariance is explicit
## UI / Surface Review Gate
- [x] Applicability is explicit: this feature changes operator-facing shell, governance, monitoring, and customer-safe viewer surfaces, so a full review gate applies
- [x] Spec, plan, and tasks carry forward the same mixed native/custom classification, shared-family relevance, state-layer ownership, and no-current-exception posture
- [x] The slice stays native/shared-primitives first: one shared context bar, one workspace settings path, one locale resolver, and no second shell or page-local locale system
- [x] Repository signal handling is explicit as `review-mandatory`, with no current exception path or hidden parallel UX language
- [x] Required test-profile depth is explicit: `global-context-shell`, `standard-native-filament`, and `shared-detail-family`, with focused proof commands only
- [x] Audience-aware disclosure remains intact: localization changes decision-first UI copy, while support/raw payloads and machine-readable artifacts remain hidden or invariant
## Review Outcome
- [x] Review outcome class chosen: `acceptable-special-case`
- [x] Workflow outcome chosen: `keep`
- [x] Final note location is explicit: any implementation-era translation exceptions are recorded in the active feature close-out task `T022`; the prep package itself needs no current exception note
## Notes
- This checklist completes the implementation-ready package alongside `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/`, and `tasks.md`.
- The active slice stays bounded to one locale foundation, two supported locales, one workspace-bound personal preference path, one workspace default path, system-panel explicit-override support only, and first-wave translation coverage for the most visible runtime surfaces.
- Current review outcome is `acceptable-special-case / keep` because the package is intentionally broad across surfaces but remains bounded to one shared locale foundation and one first-wave translation inventory.
- Implementation close-out on 2026-04-28 completed the targeted fast-feedback/confidence Pest lanes, dirty Pint, browser smoke, and post-implementation analysis/fix loop. Any remaining English text is documented as broader pre-existing localization debt outside the bounded first-wave slice, not as an open blocker for this spec.

View File

@ -0,0 +1,177 @@
openapi: 3.1.0
info:
title: Platform Localization Logical Contract
version: 0.1.0
summary: Logical contract for locale resolution, preference persistence, and invariant machine-format behavior.
paths:
/localization/context:
get:
summary: Resolve the effective locale for the current request.
operationId: resolveLocalizationContext
description: Admin and tenant planes may resolve from explicit override, user preference, workspace default, or system default. The system plane resolves from explicit override or system default only in v1.
responses:
'200':
description: Effective locale context for the current request.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedLocaleContext'
/localization/override:
put:
summary: Set or replace the explicit temporary locale override that sits first in the precedence chain.
operationId: updateExplicitLocaleOverride
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LocaleOverrideUpdate'
responses:
'200':
description: Updated locale context after setting the override.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedLocaleContext'
'400':
description: Unsupported or malformed locale input was rejected and the request falls back safely.
delete:
summary: Clear the explicit temporary locale override and return to inherited behavior.
operationId: clearExplicitLocaleOverride
responses:
'204':
description: Explicit override cleared.
/users/me/locale-preference:
put:
summary: Persist the authenticated user's personal locale preference.
operationId: updateUserLocalePreference
description: Applies to the workspace-bound `User` actor on admin and tenant planes only. System-panel `PlatformUser` actors do not get a persisted locale preference in v1.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserLocalePreferenceUpdate'
responses:
'200':
description: Updated locale context after saving the preference.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedLocaleContext'
'400':
description: Unsupported or malformed locale input was rejected and the request falls back safely.
'403':
description: Caller is authenticated but the current surface or policy does not allow personal locale preference mutation.
'404':
description: The personal preference path is unavailable in the current plane or membership context, including system-panel requests.
/workspaces/{workspaceId}/settings/localization/default-locale:
put:
summary: Persist the workspace-owned default locale through the existing settings surface.
operationId: updateWorkspaceDefaultLocale
description: Applies to workspace-scoped admin and tenant flows only. The system plane does not inherit workspace default in v1.
parameters:
- name: workspaceId
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WorkspaceDefaultLocaleUpdate'
responses:
'200':
description: Updated workspace default locale metadata.
content:
application/json:
schema:
$ref: '#/components/schemas/WorkspaceLocaleSetting'
'400':
description: Unsupported or malformed locale input was rejected.
'403':
description: Caller is a workspace member but lacks permission to manage workspace settings.
'404':
description: Workspace is inaccessible in the current plane or membership context.
components:
schemas:
SupportedLocale:
type: string
enum:
- en
- de
LocaleSource:
type: string
enum:
- explicit_override
- user_preference
- workspace_default
- system_default
ResolvedLocaleContext:
type: object
required:
- locale
- source
- fallback_locale
- machine_artifacts_invariant
properties:
locale:
$ref: '#/components/schemas/SupportedLocale'
source:
$ref: '#/components/schemas/LocaleSource'
fallback_locale:
type: string
const: en
user_preference_locale:
anyOf:
- $ref: '#/components/schemas/SupportedLocale'
- type: 'null'
workspace_default_locale:
anyOf:
- $ref: '#/components/schemas/SupportedLocale'
- type: 'null'
machine_artifacts_invariant:
type: boolean
const: true
UserLocalePreferenceUpdate:
type: object
required:
- preferred_locale
properties:
preferred_locale:
anyOf:
- $ref: '#/components/schemas/SupportedLocale'
- type: 'null'
description: Null clears the personal preference and returns the user to inherited behavior.
LocaleOverrideUpdate:
type: object
required:
- override_locale
properties:
override_locale:
$ref: '#/components/schemas/SupportedLocale'
description: Sets the explicit temporary override that takes precedence over persisted preference and workspace default.
WorkspaceDefaultLocaleUpdate:
type: object
required:
- default_locale
properties:
default_locale:
anyOf:
- $ref: '#/components/schemas/SupportedLocale'
- type: 'null'
description: Null returns the workspace to system-default inheritance.
WorkspaceLocaleSetting:
type: object
required:
- workspace_id
- default_locale
properties:
workspace_id:
type: integer
default_locale:
anyOf:
- $ref: '#/components/schemas/SupportedLocale'
- type: 'null'

View File

@ -0,0 +1,65 @@
# Data Model: Platform Localization v1 (DE/EN)
## Supported Locale Set
| Value | Meaning | Notes |
|---|---|---|
| `en` | English | System default and controlled fallback in v1 |
| `de` | German | First additional supported locale |
## Locale Sources
| Source | Ownership | Persistence | Allowed Values | Notes |
|---|---|---|---|---|
| `tenantpilot.locale_override` | request or session scoped | transient | `en`, `de` | Explicit temporary choice for the current browsing context |
| `users.preferred_locale` | user-owned | persisted on `users` | `en`, `de`, `null` | Personal preference; `null` means inherit |
| `localization.default_locale` | workspace-owned | existing workspace settings infrastructure | `en`, `de`, `null` | Workspace default for users without a personal preference |
| `config('app.locale')` | system-owned | config | `en` initially | Final fallback anchor |
## Precedence Rule
1. Explicit override
2. User preference
3. Workspace default
4. System default
If the chosen source is missing, malformed, or unsupported, resolution falls back to the next valid source until a supported locale is found. The final controlled fallback is English.
## Plane-Specific Resolution
- **Admin and tenant panels**: use the full precedence rule above.
- **System panel**: uses `explicit override -> system default` only in v1 because system actors authenticate as `PlatformUser` and do not get a persisted locale preference or workspace-default inheritance in this slice.
## Derived Resolved Locale Context
| Field | Type | Meaning |
|---|---|---|
| `locale` | string | Effective locale for the current request (`en` or `de`) |
| `source` | string | One of `explicit_override`, `user_preference`, `workspace_default`, `system_default` |
| `fallback_locale` | string | Controlled fallback locale, `en` in v1 |
| `workspace_default_locale` | string or null | Current workspace default when a workspace context exists |
| `user_preference_locale` | string or null | Persisted personal locale preference for workspace-bound users; `null` on the system plane |
## Persistence Shape
- **User preference**: add one nullable locale preference field to the current workspace-bound user-owned surface.
- **Workspace default**: add one workspace setting definition under a localization-specific domain using the existing settings infrastructure.
- **No new table**: the first slice does not create a generic preferences or translation state table, and it does not add a second locale-preference store for `PlatformUser`.
## Translation Catalog Ownership
| Catalog Family | Ownership | Notes |
|---|---|---|
| `lang/en/*.php` | canonical English source | Existing `findings.php` and `baseline-compare.php` remain authoritative English catalogs |
| `lang/de/*.php` | German translation mirror | Added only for the selected first-wave surface families |
| generic shell or settings catalogs | platform runtime | Used for shell/auth/context-bar and shared operator text that does not belong to one domain file |
## Invariance Boundaries
The following stay non-localized in v1:
- raw JSON and provider payloads
- audit entries and machine-readable audit values
- stored report payloads and exported artifact data
- identifiers, slugs, route parameters, and query semantics
- global-search scope, authorization outcomes, and tenant/workspace context selection

View File

@ -0,0 +1,287 @@
# Implementation Plan: Platform Localization v1 (DE/EN)
**Branch**: `252-platform-localization-v1` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Add one bounded locale foundation for the platform runtime only: admin and tenant panels use the full locale precedence chain (`explicit override -> user preference -> workspace default -> system default`), while the system panel uses the v1 subset (`explicit override -> system default`) because it authenticates a separate platform actor.
- Keep persistence narrow and repo-native: store the workspace default locale through the existing workspace settings infrastructure, persist the personal locale preference directly on the workspace-bound user surface, and avoid a generic preferences framework, a second settings stack, or a second preference store for `PlatformUser`.
- Translate the panel shell and the highest-signal governance surfaces first, including the shared context bar, auth copy, Findings, Baseline Compare, representative workspace and tenant membership tables, monitoring and operations feedback, and customer-safe review or report viewer chrome, while keeping exports, audit logs, JSON payloads, and other machine-readable artifacts invariant.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4, Laravel translator, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), current panel providers, existing Filament notifications and view layer
**Storage**: PostgreSQL via one workspace-bound user-owned locale preference field plus one workspace-owned locale default setting; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de`
**Testing**: Pest unit and feature tests via Laravel Sail
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Monorepo Laravel web application in `apps/platform` with admin, tenant, and system Filament panels
**Project Type**: web
**Performance Goals**: No extra remote calls during locale resolution, constant-time locale lookup from request/session + current user + workspace settings, and no measurable overhead on ordinary panel navigation or Livewire round-trips
**Constraints**: Exactly two locales (`en`, `de`), no `apps/website` scope, no new global-search semantics, no RBAC behavior change, invariant CSV/JSON/audit/raw payloads, and no generic preference framework
**Scale/Scope**: One locale resolver, one request-time locale application seam, one workspace default setting, one workspace-bound personal preference path, system-panel explicit-override support only, and first-wave translation coverage for shell/auth plus core governance surface families
## Filament v5 / Panel Notes
- **Livewire v4.0+ compliance**: The slice stays inside existing Filament v5 pages, widgets, resources, render hooks, and Livewire-backed request flows. 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 global-search resource is introduced and no global-search routing or authorization semantics are changed. Localization only affects visible copy where current search access already exists.
- **Destructive and high-impact actions**: No destructive action is added by this slice. Locale preference and workspace default changes are low-risk settings mutations; they still use existing authorization and settings audit paths where applicable.
- **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 elsewhere in the product.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mixed
- **Shared-family relevance**: shell navigation, auth copy, workspace settings, notifications, status messaging, dashboard and compare surfaces, customer-safe report viewers
- **State layers in scope**: shell, page, detail, URL-query or session
- **Audience modes in scope**: operator-MSP, support-platform, customer-read-only
- **Decision/diagnostic/raw hierarchy plan**: decision-first on shell and core governance surfaces; diagnostics-second on monitoring and operational feedback surfaces; support/raw payloads stay third and unchanged
- **Raw/support gating plan**: unchanged capability-gated or collapsed raw detail; localization applies only to surrounding UI copy, not to raw payloads or audit artifacts
- **One-primary-action / duplicate-truth control**: the shell remains the one place where language is chosen intentionally; all other surfaces consume the resolved locale and do not become independent configuration surfaces
- **Handling modes by drift class or surface**: review-mandatory because mixed-language drift across shell, notifications, and core governance surfaces would undercut the shared locale contract immediately
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: global-context-shell, standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: no page-local locale state, no custom translation framework, no second shell, and no localized machine artifacts
- **Active feature PR close-out entry**: Guardrail
## Review Outcome
- **Outcome class**: acceptable-special-case
- **Workflow outcome**: keep
- **Why this remains acceptable**: the package touches multiple surface families, but every change is still anchored to one shared locale contract and a tightly bounded first-wave translation inventory.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `bootstrap/app.php`, panel providers (`AdminPanelProvider`, `TenantPanelProvider`, `SystemPanelProvider`), shared topbar render hook and `resources/views/filament/partials/context-bar.blade.php`, existing auth/login pages, workspace settings infrastructure, `User` model persistence, `PlatformUser`-backed system auth behavior, translation catalogs under `lang/`, Filament notifications, and representative governance/detail pages and report viewers
- **Shared abstractions reused**: existing translation helpers (`__()` and Laravel translator), existing settings registry/resolver/writer, current workspace context resolution, current panel render hooks, and existing Filament notification and page/resource surfaces
- **New abstraction introduced? why?**: one bounded `LocaleResolver` plus one request-time application seam are justified because the repo currently lacks any single locale precedence decision that can serve shell, auth, Livewire, notifications, and report viewers consistently
- **Why the existing abstraction was sufficient or insufficient**: Laravel translation helpers are already sufficient for rendering translated strings, and the workspace settings infrastructure is already sufficient for a workspace default on admin and tenant planes. They are insufficient because there is no central locale resolution contract and no workspace-bound user locale preference path today.
- **Bounded deviation / spread control**: no generic preferences registry, no page-local language switches, and no second translation catalog scheme beyond standard Laravel `lang/{locale}` files
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: N/A
- **Delegated UX behaviors**: N/A
- **Surface-owned behavior kept local**: localized copy only on existing run and monitoring surfaces
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: locale resolution, glossary translation, UI copy, and viewer chrome language behavior
- **Neutral platform terms / contracts preserved**: `Finding`, `Baseline`, `Drift`, `Risk Accepted`, `Evidence Gap`, `Run`, `Alert`, `Workspace`, `Tenant`
- **Retained provider-specific semantics and why**: none; provider payloads remain untranslated and raw where they already exist
- **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 - the slice changes operator-facing runtime copy and locale choice only; it does not introduce new inventory or backup truth.
- Read/write separation: PASS - the only new writes are low-risk preference mutations using existing user/workspace ownership and current settings patterns.
- Graph contract path: PASS - no new Microsoft Graph path is introduced.
- Deterministic capabilities: PASS - authorization semantics for shell, workspace settings, and read-only viewers remain unchanged.
- RBAC-UX: PASS - `/admin`, `/admin/t`, and `/system` remain separated; language choice does not alter 404 versus 403 semantics.
- Workspace isolation: PASS - workspace selection and tenant selection stay authoritative, and locale does not create a second context layer.
- RBAC-UX destructive confirmation: N/A - no destructive action is introduced.
- RBAC-UX global search: PASS - search scope and visibility remain unchanged.
- Tenant isolation: PASS - translated labels and fallback text must not leak inaccessible tenant or workspace information.
- Run observability: PASS - no new run family or start flow is introduced.
- OperationRun start UX: N/A - no start semantics change.
- Ops-UX 3-surface feedback: PASS - only existing copy becomes locale-aware; lifecycle and notification mechanics stay unchanged.
- Ops-UX lifecycle: N/A - no lifecycle contract change.
- Ops-UX summary counts: N/A - no summary shape change.
- Ops-UX guards: N/A - no new run guard family is planned.
- Ops-UX system runs: N/A - unchanged.
- Automation: N/A - no new queued or scheduled workflow family is introduced.
- Data minimization: PASS - no new sensitive payload storage is introduced.
- Test governance (TEST-GOV-001): PASS - the plan stays in focused unit + feature lanes with explicit proof commands and limited fixture growth.
- Proportionality (PROP-001): PASS - persistence stays to one workspace-bound user-owned preference and one workspace setting; one resolver is the narrowest viable shared seam.
- No premature abstraction (ABSTR-001): PASS - no registry, strategy system, or framework is planned beyond one locale resolver and supported-locale allowlist.
- Persisted truth (PERSIST-001): PASS - the new persisted values represent real workspace-bound user and workspace-owned preference truth, while the system plane remains explicit-override or system-default only.
- Behavioral state (STATE-001): PASS - the locale set changes real request behavior, formatting, and translated surface output.
- UI semantics (UI-SEM-001): PASS - the plan favors direct domain-to-translation mapping instead of a new interpretation framework.
- Shared pattern first (XCUT-001): PASS - existing translator, panel hooks, settings stack, and existing page/resource surfaces are reused first.
- Provider boundary (PROV-001): PASS - localization is platform-core and provider-neutral.
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): PASS - one resolver, one middleware path, two locales, and a bounded first-wave surface inventory.
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): PASS - the whole locale foundation remains in one coherent spec and explicitly avoids website/email/framework drift.
- Badge semantics (BADGE-001): PASS - translated badges continue to use existing central semantics rather than new color or state mappings.
- Filament-native UI (UI-FIL-001): PASS - the slice extends existing Filament pages/resources/widgets, login pages, and render-hook partials.
- Filament-native UI local Blade/Tailwind: PASS - existing custom Blade surfaces like the shared context bar and selected viewer shells remain in Filament visual language.
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): PASS - no new surface type is introduced.
- Decision-first operating model (DECIDE-001): PASS - shell choice happens once, and primary governance surfaces stay decision-first.
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): PASS - localization improves readability without exposing hidden diagnostics or translating raw payloads.
- UI/UX inspect model (UI-HARD-001): PASS - no inspect/open model changes are planned.
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): PASS - existing action hierarchy remains intact.
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): PASS - canonical nouns remain stable across translated shells and pages.
- UI/UX placeholder ban (UI-HARD-001): PASS - no placeholder controls are planned.
- UI naming (UI-NAMING-001): PASS - translated labels preserve `Verb + Object` semantics and canonical domain vocabulary.
- Operator surfaces (OPSURF-001): PASS - shell, governance, monitoring, and customer-safe viewers stay explicit and bounded.
- Operator surface page contract: PASS - the spec already defines the affected surface contracts.
- Filament UI Action Surface Contract: PASS - no new action family is introduced beyond one shell-level locale control and a workspace settings field.
- Filament UI UX-001 (Layout & IA): PASS - the slice extends existing shells, settings, and detail surfaces only.
- Action-surface discipline (ACTSURF-001 / HDR-001): PASS - language choice stays on the shell or settings surfaces and is not duplicated on every page.
- UI review workflow: PASS - guardrail classification, shell ownership, fallback behavior, and invariant machine-format rules remain explicit in this plan.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for locale precedence and validation; `Feature` for request-time application, workspace and personal preference flows, translated core surfaces, localized feedback, and invariant machine-format behavior
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: the core risk is deterministic resolution and rendered surface behavior across existing request paths, not browser-only interaction nuance or heavy governance semantics
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php`
- **Fixture / helper / factory / seed / context cost risks**: limited to users, workspaces, memberships, workspace settings, session override state, and representative governance surface fixtures; add one focused wrong-plane or non-member and missing-capability proof path without widening the test family
- **Expensive defaults or shared helper growth introduced?**: no - implementation should reuse existing factories and add only thin locale helpers where repeated assertions demand it
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: global-context-shell proof for request-wide locale behavior, standard-native relief for ordinary Filament surfaces, and shared-detail-family proof for localized report viewer chrome with invariant artifacts
- **Closing validation and reviewer handoff**: rerun the exact targeted commands above, verify admin login, tenant panel, and system panel locale continuity, verify unsupported locale fallback behavior, verify dashboard plus core governance surfaces do not render raw keys, verify wrong-plane or non-member 404 and member-but-no-capability 403 behavior stays unchanged under locale changes, and verify exported or audited machine formats remain stable with no new remote locale lookups introduced
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local growth
- **Review-stop questions**: does one resolver truly own locale precedence, does Livewire preserve the selected locale, does the first-wave translation scope stay bounded, and do exports or audit payloads remain invariant
- **Escalation path**: document-in-feature if one surface family needs temporary English-only fallback; follow-up-spec only if a later broader email or website localization program becomes necessary
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the planned work stays bounded to the platform runtime and current high-signal governance surfaces; broader public-site or multi-locale expansion remains explicitly out of scope
## Project Structure
### Documentation (this feature)
```text
specs/252-platform-localization-v1/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── locale-resolution-and-translation-governance.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Http/
│ │ └── Middleware/
│ │ └── ApplyResolvedLocale.php
│ ├── Models/
│ │ └── User.php
│ ├── Providers/
│ │ ├── AppServiceProvider.php
│ │ └── Filament/
│ │ ├── AdminPanelProvider.php
│ │ ├── TenantPanelProvider.php
│ │ └── SystemPanelProvider.php
│ ├── Services/
│ │ ├── Localization/
│ │ │ └── LocaleResolver.php
│ │ └── Settings/
│ │ ├── SettingsResolver.php
│ │ └── SettingsWriter.php
│ └── Support/
│ └── Settings/
│ └── SettingsRegistry.php
├── bootstrap/
│ └── app.php
├── database/
│ └── migrations/
│ └── *_add_preferred_locale_to_users_table.php
├── lang/
│ ├── de/
│ └── en/
├── resources/views/
│ └── filament/
│ ├── partials/context-bar.blade.php
│ ├── pages/
│ ├── widgets/
│ └── system/
└── tests/
├── Feature/
│ ├── Filament/Localization/
│ └── Localization/
└── Unit/
└── Localization/
```
**Structure Decision**: Single Laravel/Filament application inside `apps/platform`, with one new bounded localization resolver, one request-time locale application path, one workspace-bound user preference mutation path, one workspace-owned default setting, system-panel explicit-override support only, and focused translation catalog growth for selected existing surfaces.
## Likely Implementation Surfaces
- `bootstrap/app.php` plus a new `app/Http/Middleware/ApplyResolvedLocale.php` for request-time locale application in ordinary web requests and panel traffic
- `app/Providers/Filament/AdminPanelProvider.php`, `TenantPanelProvider.php`, and `SystemPanelProvider.php` for consistent panel-level middleware and shell affordances
- `resources/views/filament/partials/context-bar.blade.php` as the shared topbar language-control anchor for the admin and tenant panels
- current auth pages such as `app/Filament/Pages/Auth/Login.php` and `app/Filament/System/Pages/Auth/Login.php` for translated login and auth-adjacent copy
- `app/Models/User.php` plus a user migration for the personal locale preference field, while `PlatformUser` remains on explicit override plus system default only
- `app/Support/Settings/SettingsRegistry.php`, `app/Services/Settings/SettingsResolver.php`, `app/Services/Settings/SettingsWriter.php`, and `app/Filament/Pages/Settings/WorkspaceSettings.php` for the workspace-owned default locale path and audit-backed save semantics
- a new `app/Services/Localization/LocaleResolver.php` for precedence, supported-locale validation, and fallback behavior
- `lang/en/*` and new `lang/de/*` catalogs for shell, Findings, Baseline Compare, monitoring, operations, workspace or tenant management tables, and customer-safe review or report viewer shells
- representative existing surfaces such as `app/Filament/Pages/TenantDashboard.php`, `app/Filament/System/Pages/Dashboard.php`, `app/Filament/Resources/FindingResource.php`, `app/Filament/Pages/BaselineCompareLanding.php`, `resources/views/filament/pages/baseline-compare-landing.blade.php`, `resources/views/filament/widgets/tenant/tenant-review-pack-card.blade.php`, `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`, and `app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php`
- localized feedback surfaces such as current Filament notifications, validation messages, and relative-time labels already present across monitoring, onboarding, and review surfaces
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| One bounded `LocaleResolver` | Shell, auth, Livewire, notifications, and report viewers need one deterministic locale source | Page-local or panel-local locale reads would drift immediately and make fallback behavior inconsistent |
| One new workspace locale setting plus one personal preference field | The roadmap precedence chain requires real persisted workspace and user truth | Session-only locale switching would not satisfy inherited defaults or stable user choice |
## Proportionality Review
- **Current operator problem**: The product is partially translation-aware but not intentionally localized. Operators cannot choose a language reliably, and current core surfaces mix raw English with extracted translations.
- **Existing structure is insufficient because**: Laravel translation helpers alone do not answer which locale to use, when to inherit workspace defaults, how to persist a user choice, or how to keep Livewire and report-viewer surfaces aligned.
- **Narrowest correct implementation**: exactly two locales, one locale resolver where admin/tenant panels use the full precedence chain and the system panel uses explicit override plus system default, one workspace-bound user preference field, one workspace setting, one request-time application path, and a bounded first-wave translation inventory on existing high-signal surfaces.
- **Ownership cost created**: ongoing EN/DE catalog maintenance, one resolver, one migration, one workspace setting key, and regression tests for fallback plus invariant machine formats.
- **Alternative intentionally rejected**: a generic preferences framework, broad website/email program, or translating every page first was rejected because the current release needs a runtime foundation, not a full localization platform.
- **Release truth**: current-release truth. Core governance, monitoring, and customer-safe review surfaces already need language continuity in the live platform.
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md`
Goals:
- Confirm the narrowest persistence shape for user preference plus workspace default without creating a generic preferences subsystem.
- Confirm the cleanest request-time locale application seam across normal web and Livewire requests for all three current panels, while keeping the system panel on explicit override plus system default only.
- Confirm which first-wave governance and viewer surfaces are already translation-aware enough to translate now and which ones still rely on raw English strings.
- Confirm invariant machine-format boundaries for exports, audit entries, report payloads, and raw evidence.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md`
Design focus:
- Persist one personal locale preference directly on the workspace-bound user-owned surface and one workspace default locale through the existing settings infrastructure.
- Add one bounded locale resolver plus one request-time middleware or application path shared by admin, tenant, and system panels, with explicit override plus system default only on the system plane.
- Place the user-facing locale switch on the existing shared shell or context surface instead of inventing a new page shell.
- Translate first-wave shell, governance, monitoring, and customer-safe viewer surfaces using standard Laravel catalogs and controlled English fallback.
- Keep exports, audit logs, raw JSON, and machine-readable artifacts invariant even when the surrounding UI becomes locale-aware.
## Implementation Close-Out
- **Workflow outcome**: keep.
- **Validation lanes completed**: fast-feedback and confidence.
- **Targeted proof results**:
- `./vendor/bin/sail artisan test --compact tests/Unit/Localization/LocaleResolverTest.php ... tests/Feature/Localization/MachineFormatInvarianceTest.php` passed with 15 tests and 103 assertions.
- `./vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsAuditTest.php tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php` passed with 18 tests and 248 assertions.
- `./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php` passed with 24 tests and 135 assertions.
- `./vendor/bin/sail bin pint --dirty --format agent` passed.
- `git diff --check` passed.
- **Browser smoke result**: passed on `http://localhost/admin/settings/workspace`, `http://localhost/admin/t/18000000-0000-4000-8000-000000000180`, and `http://localhost/admin/reviews/workspace`. The smoke verified the shared language switch from English to German, German locale menu state, tenant dashboard German navigation/title, customer review workspace German viewer chrome, no raw `localization.*` keys, and no current browser console errors from the tested tab.
- **Guardrail close-out**: acceptable-special-case / keep remains valid because the implementation still uses one resolver, one middleware seam, one user preference field, one workspace setting key, standard Laravel catalogs, and no localized machine artifact path.
- **document-in-feature note**: broader pre-existing Workspace Settings sections and deeper diagnostic/payload text outside the locale setting and review/report chrome may still render English in German mode. This is recorded as existing unrelated localization debt rather than widened into this first platform-runtime slice; the active implementation localizes the new locale controls, workspace default locale field, core shell/dashboard labels, Findings/Baseline catalog coverage, notifications, and customer-safe review/report chrome.

View File

@ -0,0 +1,39 @@
# Quickstart: Platform Localization v1 (DE/EN)
## Goal
Implement one deterministic locale foundation for the platform runtime, then translate the first-wave shell and governance surfaces without changing authorization or machine-readable artifact 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/Localization/LocaleResolverTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Smoke Focus
1. Open the admin login page and a representative system-panel page, then verify locale-specific auth and system copy using explicit override plus system default only.
2. Set workspace default locale to `de` on the existing workspace settings surface, verify an inheriting user sees German admin shell, tenant shell, and tenant dashboard copy, then clear the workspace default and verify inheritance falls back to the system default.
3. Set a personal locale preference to `en` and verify the shell, dashboards, and representative governance pages switch back to English.
4. Apply and clear the explicit temporary override and verify it wins only while active.
5. Open representative Findings, Baseline Compare, and representative workspace or tenant management tables, then confirm headings, actions, empty states, and glossary terms follow the resolved locale.
6. Open representative monitoring, alert, and operations surfaces and confirm labels, notifications, and relative-time text follow the resolved locale without changing workflow semantics.
7. Open a customer-safe review or report viewer and confirm the viewer shell localizes while underlying artifact content and identifiers stay unchanged.
8. Trigger a representative validation error and a representative notification and confirm they render in the resolved locale.
9. Verify wrong-plane or non-member requests still resolve as 404 and member-but-no-capability requests still resolve as 403 after locale changes or overrides.
10. Verify exported or audited machine-readable values stay stable and non-localized.
## Reviewer Watchpoints
- One resolver owns locale precedence.
- The system panel is explicit-override plus system-default only in v1; it does not silently inherit workspace default or a second persisted preference model.
- Livewire requests preserve the already-resolved locale.
- Unsupported locale input falls back safely to English.
- Locale changes do not alter wrong-plane 404, non-member 404, member-but-no-capability 403, or global-search visibility.
- First-wave translation coverage stays bounded to the planned surface families.
- No raw translation keys appear on in-scope surfaces.
- Exports, audit entries, raw payloads, and IDs remain invariant.
- Locale resolution stays local to request, session, user, and workspace settings inputs with no extra remote lookups.

View File

@ -0,0 +1,51 @@
# Research: Platform Localization v1 (DE/EN)
## Decision 1: Keep v1 to exactly two locales
- **Decision**: Support exactly `en` and `de` in the initial slice.
- **Why**: The roadmap names DE/EN explicitly, the repo already defaults to English, and the main risk is establishing a trustworthy locale chain and translation ownership, not proving a broad language framework.
- **Rejected alternative**: A generic multi-locale system or plugin registry was rejected because it would import framework-level complexity before the first runtime locale foundation exists.
## Decision 2: Persist one user preference plus one workspace default
- **Decision**: Store the personal locale preference directly on the workspace-bound user-owned surface and store the workspace default locale through the existing workspace settings infrastructure.
- **Why**: The repo already has a strong workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`) but no generic user settings registry. A direct workspace-bound user preference field is the narrowest truthful shape.
- **Rejected alternative**: A new generic preferences table or user-settings registry was rejected because the first release needs only one personal locale field.
## Decision 3: Resolve locale in one shared request-time seam
- **Decision**: Add one `LocaleResolver` and one request-time application path that can run for normal web requests and Livewire requests across admin, tenant, and system panels.
- **Why**: Repo evidence shows three active panel providers, shared topbar partials, and existing Livewire-specific middleware. Locale must stay coherent across those paths.
- **Rejected alternative**: Per-panel or per-page locale logic was rejected because it would drift immediately and would not solve mixed-language notifications or viewer shells.
## Decision 4: Keep system-panel locale scope narrower than admin or tenant scope
- **Decision**: Admin and tenant panels use the full precedence chain, but the system panel uses `explicit override -> system default` only in v1.
- **Why**: Repo truth shows the system panel authenticates `PlatformUser`, which is a separate actor model from workspace-bound `User`. Adding a second persisted preference store for platform actors would widen the slice beyond the narrow runtime localization foundation.
- **Rejected alternative**: A new platform-user locale preference or implicit inheritance from workspace default was rejected because the system plane is not workspace-owned and should not silently reuse workspace preference semantics.
## Decision 5: Reuse current shell and settings surfaces instead of inventing new UI
- **Decision**: Use the shared context bar or existing shell-adjacent controls for the user-facing locale switch, and use the existing workspace settings page for the workspace default locale.
- **Why**: The repo already has a shared render-hook surface in `resources/views/filament/partials/context-bar.blade.php` and an existing `WorkspaceSettings` page. That is the narrowest native Filament path.
- **Rejected alternative**: A dedicated localization page or a second profile/settings shell was rejected because it would duplicate shell-level context choice.
## Decision 6: Translate first-wave high-signal surfaces only
- **Decision**: First-wave translation coverage includes shell/auth, the current dashboards, Findings, Baseline Compare, representative workspace and tenant management tables, monitoring or operational feedback labels, and customer-safe review or report viewer chrome.
- **Why**: Repo evidence already shows translation-related usage on Findings and Baseline Compare, while the shared context bar, dashboards, and several relationship tables still contain many raw English strings. These surfaces represent the most visible operator and customer-safe workflows.
- **Rejected alternative**: Translating every current page in one slice was rejected because it would broaden scope faster than the locale foundation can be validated.
## Decision 7: Keep machine-readable artifacts invariant
- **Decision**: Locale affects UI copy, validation text, and date or relative-time formatting on in-scope surfaces, but not raw JSON, CSV, audit entries, IDs, or stored machine-readable report artifacts.
- **Why**: The roadmap requires stable export and audit semantics, and the product already uses customer-safe viewers and operational evidence where raw truth must remain stable.
- **Rejected alternative**: Localizing stored or exported artifacts was rejected because it would blur audit truth and increase downstream compatibility cost.
## Repo Evidence Snapshot
- `config/app.php` currently uses English as both default and fallback locale.
- The repo currently has only two explicit language catalogs: `lang/en/findings.php` and `lang/en/baseline-compare.php`.
- Translation helpers (`__()`) are already used across multiple Filament resources, notifications, and Blade views, but many shell and management strings remain raw English.
- The shared context bar partial is a concrete shell anchor and currently contains multiple hard-coded English labels.
- The repo has no current locale resolver, no workspace locale setting, and no personal locale preference field on `User`.

View File

@ -0,0 +1,319 @@
# Feature Specification: Platform Localization v1 (DE/EN)
**Feature Branch**: `252-platform-localization-v1`
**Created**: 2026-04-28
**Status**: Draft
**Input**: User description: "Prepare the Spec Kit feature for Localization v1 as a narrow repo-grounded slice that introduces locale resolution and EN/DE translation coverage on core governance surfaces, reuses existing translation helpers and current admin/system panels, and stops before website localization or full documentation translation."
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot already contains scattered translation-aware code such as `__()` calls and two domain language files, but it still lacks one central locale resolution path, one supported locale policy, and one bounded definition of which operator-facing surfaces are translated first.
- **Today's failure**: Users cannot intentionally switch the platform language, many core surfaces still mix extracted translations with raw English strings, relative-time labels stay English-only, and customer-safe review/report flows cannot reliably align to the reader's language without risking raw keys or inconsistent terminology.
- **User-visible improvement**: Operators can use the product in English or German, new users inherit a workspace default language unless they set their own preference, core governance surfaces render consistent translated copy and locale-aware time labels, and exports, audit records, and machine-readable artifacts remain stable and non-localized.
- **Smallest enterprise-capable version**: Add one request-time locale foundation where admin and tenant panels use `explicit override -> user preference -> workspace default -> system default`, the system panel uses `explicit override -> system default`, support exactly `en` and `de`, persist only the workspace default plus personal user preference for workspace-bound users, translate the panel shell plus the highest-signal governance surfaces first, and enforce controlled English fallback with no raw translation keys in the UI.
- **Explicit non-goals**: No `apps/website` localization, no arbitrary locale/plugin system, no public docs translation pipeline, no CSV/JSON/audit artifact localization, no provider/API payload translation, no full outbound email/template program, and no search/sort behavior rewrite beyond verifying locale safety on the current core lists.
- **Permanent complexity imported**: One bounded locale precedence chain, one supported-locale allowlist, one workspace-owned default locale setting, one workspace-bound user-owned locale preference, additional `lang/en` and `lang/de` catalogs for the selected core surfaces, and focused regression tests for fallback, formatting, and invariant machine-readable outputs.
- **Why now**: `R1.9 Platform Localization v1 (DE/EN)` is explicitly unspecced in the roadmap, the repo already has partial translation scaffolding (`lang/en/findings.php`, `lang/en/baseline-compare.php`, many `__()` calls), and read-only/customer-safe review maturity now needs a trustworthy locale foundation before more outward-facing product work lands.
- **Why not local**: Locale choice must affect the same request across Filament shells, auth, Livewire requests, notifications, relative-time rendering, and core governance pages. Translating page by page without a shared resolver contract would hard-code inconsistent language sources immediately.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Foundation-sounding theme, cross-surface touchpoint, and one new shared resolver. Defense: the slice is strictly limited to two locales, one precedence chain, one workspace default, one personal preference, and a bounded first-wave translation set on already-real surfaces.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Review Outcome
- **Outcome class**: acceptable-special-case
- **Workflow outcome**: keep
- **Reason**: The slice is intentionally broad across visible runtime surfaces, but it stays bounded to one shared locale foundation, two supported locales, one user preference path, one workspace default path, and first-wave translation coverage only.
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- current `/admin` and `/system` panel shells, including auth entry surfaces such as `/admin/login`
- the existing workspace settings surface for a workspace-owned default locale
- the current self-service user locale preference entry point in the panel shell or profile/user-menu area
- existing high-signal governance surfaces under `/admin`, including dashboard, Findings, Baseline Compare, Alerts/Monitoring, Operations, and customer-safe review/report consumption surfaces
- **Data Ownership**: Personal locale preference is user-owned truth for the workspace-bound `User` actor and should persist on that user surface only. Workspace default locale is workspace-owned truth and should reuse the existing workspace settings infrastructure for admin/tenant-plane inheritance only. Explicit override is transient request/session state. System-panel `PlatformUser` actors do not get a separate persisted locale preference in v1. Exports, audit logs, stored report content, raw JSON, and machine-readable identifiers remain unchanged and non-localized.
- **RBAC**: Authenticated workspace-bound users may set or clear their own personal locale preference and temporary explicit override on admin/tenant surfaces. Workspace owners/managers may change the workspace default locale on the existing workspace settings surface. System-panel actors may use the explicit override only in v1 and otherwise inherit the system default. Existing workspace and tenant membership checks remain authoritative. Wrong-plane and non-member access stays 404, and missing capability on workspace settings stays 403.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Locale changes MUST NOT alter existing tenant-context defaults, current filters, query ownership, search scoping, or canonical list routing. The current tenant/workspace context remains authoritative and language selection only affects presentation.
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. Locale selection MUST NOT reveal inaccessible tenants, operations, findings, or global-search hints through translated labels, fallback strings, or locale-specific navigation branches.
## 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)**: navigation, auth copy, status messaging, action labels, notifications, validation/system texts, dashboard signals, evidence/report viewers, relative-time and date labels
- **Systems touched**: Laravel translator, current `lang/*` catalogs, Filament panel providers, existing auth page copy, workspace settings infrastructure, workspace-bound user model preference storage, system-panel actor handling, Livewire request handling, Filament notifications, and core governance/detail Blade and resource surfaces
- **Existing pattern(s) to extend**: current `__()` usage, existing domain language files, Filament vendor translation layer, existing workspace settings stack, and the current user/session context path
- **Shared contract / presenter / builder / renderer to reuse**: existing translation helpers and settings infrastructure remain canonical; this slice adds one bounded locale resolver and one supported-locale allowlist rather than a second presentation framework
- **Why the existing shared path is sufficient or insufficient**: The current translator and extracted keys are sufficient for rendering translated copy, but they are insufficient because the repo has no single locale resolution contract, no workspace default locale, no workspace-bound user preference path, and no guard against mixed raw English plus translated key usage on the same surfaces.
- **Allowed deviation and why**: none. No surface may invent a local locale source, inline hard-coded German strings, or page-local fallback behavior.
- **Consistency impact**: Canonical glossary terms such as Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Alert, and Run must stay semantically aligned across shell navigation, dashboard tiles, findings/detail views, compare surfaces, notifications, and customer-safe report viewers.
- **Review focus**: Reviewers must verify one shared locale resolver contract, the narrower system-panel source set, no mixed-language operator surface after translation extraction, no raw keys in the rendered UI, and unchanged tenant/workspace authorization semantics.
## 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`)*
N/A - no `OperationRun` start, completion, dedupe, or link semantics are changed by this slice. Existing run-related copy becomes locale-aware, but the run lifecycle contract remains unchanged.
## 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 is changed. Localization must translate platform vocabulary without importing provider-specific aliases into platform-core truth.
## 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 |
|---|---|---|---|---|---|---|
| Panel shell, auth, and locale controls | yes | Native Filament panels plus existing auth page | navigation, user-menu/profile actions, auth/system texts | shell, detail, URL-query/session | no | No new panel or shell type is introduced |
| Core governance surfaces | yes | Mixed native Filament resources/pages plus existing Blade views | status messaging, table labels, empty states, glossary terms | page, detail | no | First wave only: dashboard, Findings, Baseline Compare, high-signal tenant/workspace management tables |
| Monitoring and operational feedback surfaces | yes | Mixed native Filament pages/widgets and shared presenters | notifications, alerts, run/status labels, relative time | page, detail | no | Start/completion semantics stay unchanged; only copy/formatting is localized |
| Customer-safe review/report consumption surfaces | yes | Native Filament detail/report viewers | report titles, helper text, read-only evidence/report language | page, detail | no | Machine-readable report payloads and downloads remain invariant |
## 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 |
|---|---|---|---|---|---|---|---|
| Panel shell, auth, and locale controls | Primary Decision Surface | User decides which language the product should use for the current session or their persisted preference | Current language, available language choices, and whether workspace default applies | Workspace-default explanation and reset-to-default detail | Primary because this is the only place where language is intentionally changed | Keeps language choice inside the shell instead of hidden product-by-product | Removes manual browser-translation workarounds and support explanations |
| Core governance surfaces | Primary Decision Surface | Operator interprets findings, compare results, and tenant/workspace state | Localized headings, state text, actions, empty states, and glossary-consistent labels | Existing raw evidence and detailed diagnostics remain secondary | Primary because these are the actual governance decision surfaces that must be readable first | Preserves the current governance workflow while making the first read understandable in the chosen language | Reduces operator reconstruction of English-only labels and mixed terminology |
| Monitoring and operational feedback surfaces | Secondary Context Surface | Operator interprets toasts, alerts, validation errors, and relative-time context while working | Localized notification titles/bodies, validation/system copy, and relative-time labels | Existing run detail, technical diagnostics, and raw payloads remain secondary | Not primary because these surfaces support ongoing work rather than replace the main decision pages | Keeps supporting feedback aligned with the main locale choice | Avoids context switching between translated pages and English-only feedback |
| Customer-safe review/report consumption surfaces | Tertiary Evidence / Diagnostics Surface | Customer or operator reads existing review/report content | Localized viewer shell, labels, and helper text with invariant machine data | Raw evidence and exported artifacts remain non-localized or separately scoped | Not primary because these surfaces answer evidence questions rather than control product configuration | Preserves read-only review/report flows without creating a separate localization program | Avoids mixed-language read-only experiences during customer handoff |
## 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 |
|---|---|---|---|---|---|---|---|
| Panel shell, auth, and locale controls | operator-MSP, support-platform | Current language, override source, and clear change/reset actions | Workspace-default source and personal-preference explanation | No raw settings or session payload by default | `Switch language` or `Use workspace default` | Session mechanics and internal storage details stay hidden | The surface states one resolved language and one source instead of listing multiple conflicting candidates |
| Core governance surfaces | operator-MSP, support-platform | Localized titles, statuses, actions, and empty-state guidance | Existing diagnostics and technical detail stay secondary | Raw JSON/provider payloads remain hidden or capability-gated as today | Existing primary governance action remains primary | Translation-key details and raw glossary mappings stay hidden | Canonical glossary terms are translated once and reused across related surfaces |
| Monitoring and operational feedback surfaces | operator-MSP, support-platform | Localized toasts, validation errors, and relative time/context labels | Existing technical diagnostics on run/detail pages | Raw support payloads remain collapsed or gated | Existing remediation or navigation action remains primary | Internal fallback markers and untranslated key debugging stay hidden | The same message family is localized centrally rather than page-local per toast |
| Customer-safe review/report consumption surfaces | customer-read-only, operator-MSP | Localized shell copy and helper labels around existing review/report content | Existing provenance and review history remain secondary | Machine payloads and exported artifact data stay stable and non-localized | Existing `View` or `Download` action remains primary | Support/raw detail remains gated or omitted | Viewer shell text localizes without creating a second localized export format |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Panel shell, auth, and locale controls | Global context shell | Shell + preference control | Change the current language or reset to inherited default | Shell affordance plus existing settings/profile surface | forbidden | Existing user-menu or settings placement remains secondary to main navigation | none | `/admin` and `/system` shells | existing user/profile or workspace settings surface | Current panel, workspace context, and current locale source | Language / Locale | Current language and override source | Acceptable shell exception because locale is a true global context choice |
| Core governance surfaces | List / Detail / Dashboard | Native resource/page family | Continue the current governance action using translated labels | Existing row click, detail pages, and dashboard cards stay unchanged | existing | Existing More/detail header actions remain where they already live | unchanged | existing governance collection routes under `/admin` | existing governance detail routes under `/admin` | Active workspace and tenant context remain unchanged | Findings, Baselines, Alerts, Runs | Localized decision copy and canonical glossary terms | No new surface type introduced |
| Monitoring and operational feedback surfaces | Monitoring / Status / Feedback | Widget/page plus transient notification families | Act on a toast, alert, validation error, or monitoring label | Existing page/widget affordances stay unchanged | existing where already supported | Existing secondary navigation remains secondary | unchanged | existing monitoring and operations routes | existing run/alert detail routes | Active workspace, run, or alert context remains unchanged | Alerts / Operations / Notifications | Localized feedback copy and relative time labels | Feedback-only translation; no action hierarchy change |
| Customer-safe review/report consumption surfaces | Detail / Report viewer / Download | Read-only viewer family | Read or download the current review/report content | Existing read-only view/download surfaces | existing where already supported | Existing navigation remains secondary | none | existing review/report collections | existing review/report detail routes | Active workspace and tenant context remain unchanged | Review / Report / Evidence | Localized shell labels around stable artifact truth | No new localized export format is introduced |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Panel shell, auth, and locale controls | Any authenticated operator | Decide which language the product should render now | Global shell and settings detail | Which language should this product view use? | Current language, current source, available choices | Internal source precedence and fallback explanation | locale source, current locale | TenantPilot only | Switch language, clear override, save preference, save workspace default | none |
| Core governance surfaces | Workspace operator or platform reviewer | Interpret governance state and continue the current action in the chosen language | List/detail/dashboard family | What needs action right now, in a language I can reliably read? | Localized titles, labels, statuses, empty-state guidance, and action labels | Existing raw evidence and diagnostics | governance state, lifecycle/readiness, data completeness | none beyond existing actions | Existing primary governance actions | existing dangerous actions remain unchanged |
| Monitoring and operational feedback surfaces | Workspace operator or support operator | Understand transient system feedback and supporting context | Feedback/status family | What did the system just tell me, and what should I do next? | Localized notifications, validation messages, and relative-time context | Existing run/alert diagnostic detail | message intent, run status, alert state | none beyond existing actions | Existing follow-up navigation or retry actions | existing dangerous actions remain unchanged |
| Customer-safe review/report consumption surfaces | Customer-safe reader or workspace operator | Read review/report content with translated viewer chrome | Read-only viewer family | What does this review/report say in my chosen language without changing the underlying evidence? | Localized headings, section labels, helper text, and stable dates/times formatting | Existing provenance and support-only detail | report state, artifact availability | none | Existing view/download actions | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes - one resolved locale chain becomes current-release presentation truth, but it derives from existing config plus one user preference and one workspace default rather than a new generic preferences framework
- **New persisted entity/table/artifact?**: yes - one personal locale preference field and one workspace locale default setting
- **New abstraction?**: yes - one bounded locale resolver and one request-time application seam
- **New enum/state/reason family?**: yes - the supported locale set is explicitly bounded to `en` and `de`
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators and customer-safe readers currently encounter a partially translated product with no reliable way to select or inherit a language across shell, actions, notifications, and core governance views.
- **Existing structure is insufficient because**: Existing `__()` usage and two language files provide raw translation capability, but there is no central locale source, no persisted workspace default, no personal override, and no governed first-wave translation scope.
- **Narrowest correct implementation**: Keep the locale set at two languages, add one resolver where admin/tenant panels use the full precedence chain and the system panel uses explicit override plus system default, persist only one workspace-bound user preference and one workspace default, translate the highest-signal operator/report surfaces first, and leave exports/audit/machine artifacts untouched.
- **Ownership cost**: Ongoing maintenance of EN/DE catalogs, translation-key governance for the selected surface families, and regression tests for fallback and invariant machine outputs.
- **Alternative intentionally rejected**: A generic user-settings registry, multi-locale plugin system, or page-by-page translation without a resolver was rejected because the current release only needs one trustworthy locale chain and a bounded first-wave translation set.
- **Release truth**: current-release truth. The platform already has outward-facing review/report and governance workflows that need a consistent locale foundation now.
### 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 locale precedence, supported-locale validation, fallback behavior, and invariant machine-format decisions. Focused feature coverage proves request-time application across Filament and auth surfaces, workspace/user preference flows, translated core surfaces, localized notifications/validation, and unchanged export/audit semantics without requiring browser or heavy-governance lanes.
- **New or expanded test families**: one bounded locale resolver unit family plus focused localization feature coverage for preferences, core governance surfaces, notifications/validation, and fallback/invariant behavior
- **Fixture / helper cost impact**: low. Add only user, workspace, workspace membership, workspace setting, session override, and representative governance surface fixtures. Avoid browser harness growth and avoid a generic translation-seeding framework.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: global-context-shell, standard-native-filament, shared-detail-family
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for panel shell and settings surfaces. Global-context-shell coverage is required for locale precedence and request/application flow. Shared-detail-family coverage is required for localized review/report viewer chrome without altering machine-readable content.
- **Reviewer handoff**: Reviewers must confirm the precedence chain is deterministic, unsupported locales fail safely to English, Livewire requests preserve the resolved locale, critical governance surfaces stop mixing English raw strings with translated keys, relative times and validation messages localize correctly, and CSV/JSON/audit artifacts stay stable.
- **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/Localization/LocaleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/WorkspaceDefaultLocaleTest.php tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/TranslationFallbackGuardTest.php tests/Feature/Localization/MachineFormatInvarianceTest.php`
## Scope Boundaries *(required for this slice)*
### In Scope
- One shared locale resolver contract where admin and tenant panels use `explicit override -> user preference -> workspace default -> system default` and the system panel uses `explicit override -> system default`
- Exactly two supported locales in v1: `en` and `de`
- Workspace-owned default locale using the existing workspace settings infrastructure
- User-owned personal locale preference and a clear reset-to-inherited behavior
- Request-time locale application across current Filament admin, tenant, and system/auth flows, including Livewire requests, with the system panel limited to explicit override plus system default
- First-wave translation coverage for panel shell/navigation/auth plus the highest-signal governance surfaces already present in the repo
- Localized notifications, validation/system messages, and relative-time/date/number formatting for those in-scope surfaces
- Controlled English fallback with no raw translation keys rendered in the UI
- Explicit preservation of invariant exports, audit logs, JSON payloads, IDs, and machine-readable report artifacts
### Non-Goals
- `apps/website` localization or public documentation translation
- More than two locales in v1
- A generic user preferences framework or multi-tenant localization framework
- Provider/API payload translation or localized stored evidence/report artifacts
- Full outbound email template localization beyond the minimum auth/system texts already on the current panel flow
- Search ranking, sorting rules, or global-search behavior changes beyond verifying locale safety
- Pseudolocalization as a full product lane; at most one bounded smoke/guard check may support the initial implementation
- A separate persisted locale-preference store for `PlatformUser` system actors
## Assumptions
- `config('app.locale')` and `config('app.fallback_locale')` remain the system-default and fallback anchors, both currently English.
- English remains the controlled fallback whenever a German translation key is missing.
- Workspace default locale is a presentation preference only. It does not change authorization, tenant/workspace scope, or machine-readable data.
- System-panel actors in v1 use explicit override plus system default only; they do not inherit workspace default or a second persisted preference store.
- Relative time, date, and number formatting should follow the resolved locale on operator-facing surfaces, but stored timestamps, raw payloads, exports, and audit values remain unchanged.
- The first-wave translation scope is bounded to shell/auth/settings plus existing high-signal governance pages already present in the repo, not every product page.
## Risks
- Mixed inline `__('Raw English')` usage and keyed translation files can leave surfaces partially translated if extraction rules are not explicit.
- Livewire and panel-specific middleware may drift if locale application is only added to one request path.
- Localizing viewer chrome while keeping exports/audit invariant can be confused if teams try to translate machine-readable payloads later.
- The glossary can drift between `findings`, `baseline-compare`, and generic panel labels if key ownership is not explicit.
## Deferred Adjacent Candidates
- Full public website localization remains a separate website-track concern.
- Broad email/template localization, knowledge-base localization, and public documentation translation stay deferred until the operator-facing runtime foundation is stable.
- Additional locales beyond German and English stay deferred until the two-locale workflow, fallback behavior, and glossary governance are proven.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Resolve and persist one trustworthy locale per request (Priority: P1)
As an authenticated operator, I want the platform to resolve one language deterministically for the current request so I can work in my chosen language without manually reinterpreting each page.
**Why this priority**: Without one shared locale chain, every later translation task remains fragile and page-local.
**Independent Test**: Set a workspace default locale, optionally set a personal locale preference, optionally trigger an explicit override, and verify that admin/tenant requests use the full chain while system-panel requests use explicit override plus system default on both normal and Livewire-backed panel pages.
**Acceptance Scenarios**:
1. **Given** a user has no personal locale preference and no explicit override, **When** they open the panel inside a workspace with default locale `de`, **Then** the request resolves to `de` and the shell renders German copy.
2. **Given** the same workspace default is `de` but the user has personal locale preference `en`, **When** they open the panel, **Then** the request resolves to `en`.
3. **Given** the user currently resolves to `en`, **When** they set an explicit temporary override to `de`, **Then** the current request or session resolves to `de` until the override is cleared.
4. **Given** an unsupported locale value is supplied through the temporary override or persisted input, **When** the request resolves locale, **Then** the system safely falls back to English and never exposes raw keys.
5. **Given** a system-panel actor has no explicit override active, **When** they open the system panel, **Then** the request resolves from the system default and does not inherit workspace default or a persisted personal preference in v1.
---
### User Story 2 - Read core governance surfaces in the chosen language (Priority: P1)
As a workspace or platform operator, I want core governance surfaces to render consistent translated copy and glossary terms so I can make decisions without mixed-language UI fragments.
**Why this priority**: Dashboard, Findings, Baseline Compare, and the main shell are the highest-signal operating surfaces. If they remain mixed-language, the locale foundation is not credible.
**Independent Test**: Open the shell, dashboard, Findings, Baseline Compare, and one representative tenant/workspace management table in both `en` and `de`, and verify that headings, status labels, actions, empty states, and relative-time helper text match the resolved locale.
**Acceptance Scenarios**:
1. **Given** the resolved locale is `de`, **When** an operator opens the dashboard and Findings pages, **Then** navigation labels, headings, actions, and empty-state/helper text render in German using the canonical glossary.
2. **Given** the resolved locale is `en`, **When** the same operator opens Baseline Compare and the representative management tables, **Then** those surfaces render English labels and status text with unchanged workflow semantics.
3. **Given** a translation key is missing in German for an in-scope surface, **When** the page renders, **Then** the surface falls back to English instead of showing a raw translation key.
---
### User Story 3 - Keep notifications and machine-readable artifacts truthful at the same time (Priority: P1)
As an operator or customer-safe reader, I want notifications and viewer shell copy to follow my language while exports, audit entries, and machine-readable report content remain stable.
**Why this priority**: Supportive system feedback and read-only report consumption are part of the real product experience, but they must not compromise machine-format stability or audit truth.
**Independent Test**: Trigger representative notifications and validation errors in `de` and `en`, open a customer-safe review/report viewer, and verify that UI shell copy localizes while exported/report payloads and audit records stay invariant.
**Acceptance Scenarios**:
1. **Given** the resolved locale is `de`, **When** an operator triggers a representative notification or validation error on an in-scope surface, **Then** the message renders in German.
2. **Given** the resolved locale is `de`, **When** a customer-safe reader opens an existing review or report surface, **Then** the viewer chrome and helper labels render in German while underlying machine-readable content stays unchanged.
3. **Given** the same action produces an audit entry, CSV, JSON, or stored machine-readable artifact, **When** the localized UI renders around it, **Then** the artifact content and identifiers remain stable and non-localized.
### Edge Cases
- Unsupported locale input must never break request rendering or show raw translation keys.
- Livewire requests must preserve the already-resolved locale instead of silently reverting to the app default.
- Clearing a personal locale preference must return the user to workspace-default behavior, not leave a stale session override in place.
- A user outside any active workspace must still resolve locale safely from explicit override, personal preference, or system default.
- Global-search scope, filter semantics, and authorization outcomes must remain unchanged regardless of locale.
- Relative time labels must localize correctly without mutating stored timestamps or serialized API/export values.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes runtime presentation behavior and writes one workspace-bound user-owned preference plus one workspace-owned default, but it introduces no new Microsoft Graph path, no provider dispatch change, and no new queued workflow family.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new bounded resolver and two-locale state set because current operator workflows already need a deterministic language source. A narrower page-local translation effort would not solve current-release truth.
**Constitution alignment (XCUT-001):** All in-scope panels, notifications, and translated core surfaces must consume the same locale resolver contract. No page may invent a second locale source, and the system panel remains on the explicit-override plus system-default subset in v1.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** Localization must improve decision-first readability without exposing support/raw data or changing current disclosure boundaries.
**Constitution alignment (PROV-001):** Platform vocabulary remains platform-core. Localization translates the vocabulary but does not rename provider-specific identifiers or alter provider payload truth.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes with minimal fixtures and no browser-lane expansion.
**Constitution alignment (RBAC-UX):** Language selection MUST NOT alter workspace or tenant access checks, wrong-plane 404 handling, or membership/capability semantics.
**Constitution alignment (BADGE-001):** Existing status badges and state labels may be translated, but they must continue to use shared badge/state semantics rather than page-local language mappings.
**Constitution alignment (UI-FIL-001):** The slice extends existing Filament pages, resources, widgets, and the current auth surface. It must not create a custom localization panel or second shell.
**Constitution alignment (UI-NAMING-001):** Primary operator labels remain domain-specific and translate the same canonical nouns (`Finding`, `Baseline`, `Drift`, `Run`, `Alert`, `Workspace`, `Tenant`) consistently.
**Constitution alignment (DECIDE-001):** Locale choice is made once at the shell/settings level; core governance surfaces consume it without becoming configuration pages themselves.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Existing inspect/open models and action hierarchies remain unchanged. This slice changes copy and preference controls only.
**Constitution alignment (UI-SEM-001 / LAYER-001):** One small locale resolver and one supported-locale catalog are justified because current code lacks a single request-level language decision. No generic translation framework or theme layer is allowed.
### Functional Requirements
- **FR-252-001 Supported locales**: The system MUST support exactly two locales in v1: `en` and `de`.
- **FR-252-002 Deterministic precedence**: The effective locale for admin and tenant web requests MUST resolve in this order: explicit override, user preference, workspace default, system default. System-panel requests MUST resolve in this order in v1: explicit override, system default.
- **FR-252-003 Safe validation**: Unsupported or malformed locale values MUST be rejected or normalized safely and MUST fall back to English rather than rendering raw translation keys.
- **FR-252-004 User-owned preference**: The system MUST allow an authenticated workspace-bound user to save, change, or clear their own personal locale preference without affecting other users.
- **FR-252-005 Workspace-owned default**: The system MUST allow authorized workspace operators to set or clear a workspace default locale using the existing workspace settings infrastructure for admin and tenant panel inheritance only.
- **FR-252-006 Reset-to-inherited behavior**: Clearing a user preference MUST cause the locale chain to resume using workspace default or system default.
- **FR-252-007 Request-time application**: The resolved locale MUST apply consistently to normal web requests, auth flows, and Livewire requests for the in-scope panels, with the system panel using the v1 subset of explicit override plus system default only.
- **FR-252-008 Shell and auth coverage**: The system MUST localize the panel shell, navigation labels, auth/login copy, and the locale control affordance itself for the supported locales.
- **FR-252-009 Core surface coverage**: The first implementation slice MUST localize the selected high-signal governance surfaces: dashboard, Findings, Baseline Compare, representative workspace/tenant management tables, monitoring, alerts, operations support labels, and customer-safe review/report viewer shell text.
- **FR-252-010 Canonical glossary consistency**: The translated surface families MUST use one consistent glossary for core governance terms such as Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Alert, and Run.
- **FR-252-011 Notification and validation coverage**: In-scope Filament notifications, validation messages, and system helper text MUST render in the resolved locale.
- **FR-252-012 Locale-aware formatting**: In-scope date, time, number, and relative-time labels shown on operator-facing surfaces MUST respect the resolved locale.
- **FR-252-013 Controlled fallback**: Missing translations in German MUST fall back to English. Raw translation keys MUST NOT appear on the rendered UI for in-scope surfaces.
- **FR-252-014 Invariant machine formats**: CSV, JSON, audit logs, stored artifacts, machine-readable report data, IDs, and provider/raw evidence payloads MUST remain stable and non-localized.
- **FR-252-015 Authorization invariance**: Locale changes MUST NOT alter route access, wrong-plane or non-member 404 behavior, member-but-no-capability 403 behavior, global-search visibility, filter scope, tenant/workspace context, or any other RBAC outcome.
- **FR-252-016 No parallel locale sources**: In-scope pages, widgets, resources, notifications, and viewers MUST consume the shared resolved locale and MUST NOT implement page-local language sources.
- **FR-252-017 Bounded v1**: This slice MUST NOT add website localization, more than two locales, a generic preferences framework, or a broad translation program for every product surface.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| User locale control | existing panel shell or self-profile surface | none on collection | N/A | none | none | N/A | `Save language`, `Use inherited default`, `Reset override` | existing save/cancel pattern if profile/settings form is used | no | Global shell exception is intentional because locale is a true shell concern |
| Workspace default locale setting | existing workspace settings surface | existing settings navigation only | N/A | none | none | N/A | existing workspace settings save action includes locale field | existing save/cancel pattern stays authoritative | yes - through existing settings audit path | No destructive action is introduced |
| Core governance surfaces | existing dashboard, resources, and pages | unchanged | existing inspect affordances remain unchanged | unchanged | unchanged | unchanged | unchanged except translated labels and copy | N/A | no new audit requirement | Translation only; no action hierarchy change |
| Monitoring and customer-safe review/report surfaces | existing pages, resources, and viewers | unchanged | existing inspect affordances remain unchanged | unchanged | unchanged | unchanged | unchanged except translated labels and copy | N/A | no new audit requirement | Viewer chrome localizes; artifact content stays stable |
### Key Entities *(include if feature involves data)*
- **Resolved locale context**: Derived request-time value with the selected locale plus source (`explicit_override`, `user_preference`, `workspace_default`, `system_default`); system-panel requests only use the explicit-override or system-default subset in v1
- **User locale preference**: Workspace-bound user-owned persisted preference for `en` or `de`
- **Workspace default locale**: Workspace-owned default locale stored through the existing settings infrastructure for admin/tenant inheritance
- **Translation catalogs**: Bounded EN/DE language files covering the selected first-wave surface families

View File

@ -0,0 +1,187 @@
---
description: "Task list for feature implementation"
---
# Tasks: Platform Localization v1 (DE/EN)
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md`
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in focused `Unit` plus `Feature` lanes only, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
## Test Governance Notes
- Lane assignment: `fast-feedback` and `confidence` are the narrowest sufficient proof for locale precedence, request-time application, translated core surfaces, feedback localization, fallback safety, authorization invariance, and invariant machine-format behavior.
- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/`; do not widen this slice into browser or heavy-governance families.
- Reuse existing user, workspace, membership, workspace-setting, and representative governance surface fixtures. Any new helper must stay opt-in and cheap by default.
- If one late surface remains English-only for a bounded reason, record it as `document-in-feature` in close-out instead of widening scope into a broader localization program.
## Scope Control Notes
- Keep implementation inside one locale resolver contract, one workspace-bound user preference path, one workspace default path, current panel/auth/Livewire request application, first-wave translation coverage for shell plus high-signal governance surfaces, and invariant export/audit/raw payload behavior.
- Do not add website localization, more than two locales, a generic preferences framework, a second persisted preference store for system `PlatformUser` actors, public documentation translation, or broad email-template localization.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Lock the bounded slice, translation inventory, and validation plan before runtime edits begin.
- [x] T001 Review the bounded slice, explicit non-goals, and outcome class in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md`
- [x] T002 [P] Review locale precedence, persistence ownership, and invariance boundaries in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/contracts/locale-resolution-and-translation-governance.logical.openapi.yaml`
- [x] T003 [P] Confirm the focused Sail/Pest proof commands and manual smoke expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared locale primitives that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Add the supported-locale allowlist, precedence evaluation, and fallback behavior in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php`
- [x] T005 [P] Add the personal locale preference field and model support in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/database/migrations/*_add_preferred_locale_to_users_table.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Models/User.php`
- [x] T006 [P] Register the workspace default locale definition and persistence path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Settings/SettingsRegistry.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsResolver.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Settings/SettingsWriter.php`
- [x] T007 Thread locale application through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/app.php`, a new `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Middleware/ApplyResolvedLocale.php`, and the current panel providers in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/`, with explicit override plus system default only on the system panel
**Checkpoint**: Foundation ready. Locale precedence, persistence, and request-time application now exist without any page-local language logic.
---
## Phase 3: User Story 1 - Choose and inherit language predictably (Priority: P1) 🎯 MVP
**Goal**: Let users inherit workspace default language, override it personally, and apply a temporary explicit override from the current shell.
**Independent Test**: Set workspace default locale, set or clear a personal preference, apply or clear an explicit override, and verify the effective locale on normal, auth, system-panel, and Livewire-backed panel requests, with the system panel using explicit override plus system default only.
### Tests for User Story 1
- [x] T008 [P] [US1] Add unit and feature coverage for precedence ordering, unsupported-locale fallback, reset-to-inherited behavior, auth and system-panel rendering, wrong-plane or non-member 404, member-but-no-capability 403, system-panel explicit-override plus system-default behavior, and Livewire continuity in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/LocaleResolverTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php`
### Implementation for User Story 1
- [x] T009 [US1] Add the user-facing locale control to the existing shared shell surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/partials/context-bar.blade.php` and any required panel-provider wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Providers/Filament/`
- [x] T010 [US1] Add the workspace default locale field to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` using the current workspace settings save and audit path, including clear-to-inherit behavior back to the system default
- [x] T011 [US1] Localize shell and auth copy across `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Auth/Login.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/System/Pages/Auth/Login.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/partials/context-bar.blade.php`
**Checkpoint**: User Story 1 is independently functional when language choice is deterministic and visible on the existing shell and settings surfaces.
---
## Phase 4: User Story 2 - Read core governance surfaces in the chosen language (Priority: P1)
**Goal**: Translate the first-wave governance surfaces so operators can work in English or German without mixed-language UI fragments.
**Independent Test**: Open the shell, tenant dashboard, system dashboard, Findings, Baseline Compare, and representative workspace or tenant management tables in both locales and verify headings, actions, empty states, and glossary terms align with the resolved locale.
### Tests for User Story 2
- [x] T012 [P] [US2] Add feature coverage for translated shell, tenant dashboard, system dashboard, and first-wave governance surface rendering and fallback behavior in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php`
### Implementation for User Story 2
- [x] T013 [US2] Extract and add translation catalogs for shell, tenant and system dashboard surfaces, Findings, Baseline Compare, and representative management tables in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/`, and the touched page/resource/view files
- [x] T014 [US2] Localize relative-time, date, and number formatting on the in-scope dashboard and governance surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/`, and any related support presenters
- [x] T015 [US2] Keep search, filter, row-click, global-search, wrong-plane or non-member 404, member-but-no-capability 403, and scope semantics unchanged while translated labels render on the in-scope shell and governance surfaces
**Checkpoint**: User Story 2 is independently functional when the first-wave operator surfaces render coherent EN or DE copy without changing workflow semantics.
---
## Phase 5: User Story 3 - Localize feedback without changing machine truth (Priority: P1)
**Goal**: Make notifications, validation text, monitoring, alert, and operations feedback copy, plus customer-safe viewer chrome, locale-aware while exported and audited machine-readable content stays invariant.
**Independent Test**: Trigger representative notifications and validation messages, open a customer-safe review or report viewer, and confirm surrounding UI copy localizes while audit, export, and raw payload truth stays stable.
### Tests for User Story 3
- [x] T016 [P] [US3] Add feature coverage for localized notifications, validation and helper text, customer-safe viewer chrome, and machine-format invariance in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php`
### Implementation for User Story 3
- [x] T017 [US3] Localize in-scope notifications, validation/system messages, and monitoring, alert, or operations feedback copy in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Livewire/`, and related view files
- [x] T018 [US3] Localize customer-safe review or report viewer shell copy while preserving invariant exports, audit values, IDs, and raw payload truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, and related viewer or widget views
**Checkpoint**: User Story 3 is independently functional when localized feedback and viewer shells coexist with unchanged machine-readable artifact truth.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Run the narrow validation lanes, format touched files, and capture the feature-local close-out without widening scope.
- [x] T019 Run the targeted unit Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Unit/Localization/LocaleResolverTest.php`
- [x] T020 Run the targeted feature Sail/Pest commands from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/AuthAndSystemSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/WorkspaceDefaultLocaleTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/CoreGovernanceSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/TranslationFallbackGuardTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/MachineFormatInvarianceTest.php`
- [x] T021 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/quickstart.md`
- [x] T022 Record the final guardrail close-out, lane results, workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` note for temporarily untranslated surfaces in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/252-platform-localization-v1/checklists/requirements.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: starts 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 shared locale resolution and persistence exist.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: first shippable increment once Phase 2 is complete.
- **US2 (P1)**: independently testable after Phase 2 and should follow US1 because translated shell and precedence behavior must already be stable.
- **US3 (P1)**: independently testable after Phase 2 and should merge after US1 because notifications, alert and operations feedback, and viewer shells depend on the same locale foundation.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap before implementation.
- Complete the shared resolver or persistence seam before wiring multiple UI entry points that depend on it.
- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story.
---
## Parallel Opportunities
### Phase 1
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
### Phase 2
- T004, T005, and T006 can run in parallel.
- T007 should follow once resolver and persistence keys exist.
### User Story 1
- T008 can run in parallel with any final Phase 2 cleanup.
- T009 and T010 can proceed in parallel once the persistence shape is stable.
- T011 should follow after the locale-control surface exists.
### User Story 2
- T012 can run in parallel with final US1 validation once Phase 2 is complete.
- T013 and T014 can run in parallel after the first-wave inventory is fixed.
- T015 should follow to confirm semantics remain unchanged.
### User Story 3
- T016 can run in parallel with late US2 verification.
- T017 and T018 can run in parallel once the locale foundation is stable.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **Phase 2 + User Story 1 + User Story 2 + User Story 3**. This is the smallest slice that establishes deterministic locale truth, translates the declared first-wave governance and feedback surfaces, and preserves invariant machine-readable artifacts.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate deterministic locale choice plus inheritance.
3. Deliver US2 and validate translated shell plus first-wave governance surfaces.
4. Deliver US3 and validate localized feedback, alert and operations copy, customer-safe viewer chrome, and invariant machine-readable artifacts.
5. Finish with Phase 6 validation, formatting, and feature-local close-out recording.