Compare commits
10 Commits
dev
...
251-commer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606e9760dd | ||
|
|
4325e1ed8d | ||
|
|
4ae4c2ee95 | ||
|
|
32b6dcb937 | ||
|
|
f7bc4f2787 | ||
|
|
0739018ee5 | ||
|
|
9a02261f5c | ||
|
|
65ec1d5904 | ||
|
|
f05857c276 | ||
|
|
9f5d3293c5 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -264,8 +264,6 @@ ## Active Technologies
|
||||
- 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 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
|
||||
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -300,9 +298,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
|
||||
- 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
|
||||
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantpilotBackfillFindingLifecycle extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:findings:backfill-lifecycle
|
||||
{--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
|
||||
|
||||
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
|
||||
if ($tenantIdentifiers === []) {
|
||||
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||
|
||||
if ($tenants->isEmpty()) {
|
||||
$this->info('No tenants matched the provided identifiers.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$queued = 0;
|
||||
$skipped = 0;
|
||||
$nothingToDo = 0;
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$run = $runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: null,
|
||||
reason: null,
|
||||
source: 'cli',
|
||||
);
|
||||
} catch (OperationalControlBlockedException $e) {
|
||||
$this->error(sprintf(
|
||||
'Backfill paused for tenant %d: %s',
|
||||
(int) $tenant->getKey(),
|
||||
$e->getMessage(),
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
if (isset($errors['preflight.affected_count'])) {
|
||||
$nothingToDo++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->error(sprintf(
|
||||
'Backfill blocked for tenant %d: %s',
|
||||
(int) $tenant->getKey(),
|
||||
$e->getMessage(),
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$queued++;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
|
||||
$queued,
|
||||
$skipped,
|
||||
$nothingToDo,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return \Illuminate\Support\Collection<int, Tenant>
|
||||
*/
|
||||
private function resolveTenants(array $tenantIdentifiers)
|
||||
{
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()
|
||||
->forTenant($identifier)
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = array_values(array_unique($tenantIds));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->whereIn('id', $tenantIds)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantpilotRunDeployRunbooks extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:run-deploy-runbooks';
|
||||
|
||||
protected $description = 'Run deploy-time runbooks idempotently.';
|
||||
|
||||
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
|
||||
{
|
||||
try {
|
||||
$runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||
initiator: null,
|
||||
reason: new RunbookReason(
|
||||
reasonCode: RunbookReason::CODE_DATA_REPAIR,
|
||||
reasonText: 'Deploy hook automated runbooks',
|
||||
),
|
||||
source: 'deploy_hook',
|
||||
);
|
||||
|
||||
$this->info('Deploy runbooks started (if needed).');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (OperationalControlBlockedException $e) {
|
||||
$this->info('Deploy runbooks paused: '.$e->getMessage());
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
$skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']);
|
||||
|
||||
if ($skippable) {
|
||||
$this->info('Deploy runbooks skipped (nothing to do or already running).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('Deploy runbooks blocked by validation errors.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,4 @@
|
||||
class Login extends BaseLogin
|
||||
{
|
||||
protected string $view = 'filament.pages.auth.login';
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.auth.sign_in_microsoft');
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,21 +57,6 @@ class CustomerReviewWorkspace extends Page implements HasTable
|
||||
|
||||
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
|
||||
{
|
||||
$tenantIdentifier = filled($tenant->external_id)
|
||||
@ -99,7 +84,7 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
@ -120,9 +105,9 @@ public function table(Table $table): Table
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->columns([
|
||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
|
||||
TextColumn::make('name')->label('Tenant')->searchable()->sortable(),
|
||||
TextColumn::make('latest_review')
|
||||
->label(__('localization.review.latest_review'))
|
||||
->label('Latest review')
|
||||
->badge()
|
||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
||||
@ -131,25 +116,25 @@ public function table(Table $table): Table
|
||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
||||
->wrap(),
|
||||
TextColumn::make('finding_summary')
|
||||
->label(__('localization.review.key_findings'))
|
||||
->label('Key findings')
|
||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('accepted_risk_summary')
|
||||
->label(__('localization.review.accepted_risks'))
|
||||
->label('Accepted risks')
|
||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('published_at')
|
||||
->label(__('localization.review.published'))
|
||||
->label('Published')
|
||||
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('review_pack_state')
|
||||
->label(__('localization.review.review_pack'))
|
||||
->label('Review pack')
|
||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label(__('localization.review.tenant'))
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
@ -163,25 +148,25 @@ public function table(Table $table): Table
|
||||
])
|
||||
->actions([
|
||||
Action::make('open_latest_review')
|
||||
->label(__('localization.review.open_latest_review'))
|
||||
->label('Open latest review')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
|
||||
Action::make('download_review_pack')
|
||||
->label(__('localization.review.download_review_pack'))
|
||||
->label('Download review pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
|
||||
->emptyStateHeading('No entitled tenants match this view')
|
||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||
? __('localization.review.clear_filters_description')
|
||||
: __('localization.review.adjust_filters_description'))
|
||||
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.'
|
||||
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters_empty')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
@ -402,7 +387,7 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
||||
|
||||
private function latestReviewStateLabel(Tenant $tenant): string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
|
||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review';
|
||||
}
|
||||
|
||||
private function latestReviewStateColor(Tenant $tenant): string
|
||||
@ -425,7 +410,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
return 'No published review available yet';
|
||||
}
|
||||
|
||||
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
|
||||
@ -442,7 +427,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
||||
return $primaryReason;
|
||||
}
|
||||
|
||||
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
||||
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
||||
}
|
||||
|
||||
private function findingSummary(Tenant $tenant): string
|
||||
@ -450,7 +435,7 @@ private function findingSummary(Tenant $tenant): string
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
return 'No published review available yet';
|
||||
}
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
@ -459,17 +444,14 @@ private function findingSummary(Tenant $tenant): string
|
||||
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
||||
|
||||
if ($findingCount === 0) {
|
||||
return __('localization.review.no_findings_recorded');
|
||||
return 'No findings recorded in the published review.';
|
||||
}
|
||||
|
||||
if ($terminalOutcomes === null) {
|
||||
return __('localization.review.findings_count_summary', ['count' => $findingCount]);
|
||||
return sprintf('%d findings summarized in the published review.', $findingCount);
|
||||
}
|
||||
|
||||
return __('localization.review.findings_count_with_outcomes', [
|
||||
'count' => $findingCount,
|
||||
'outcomes' => $terminalOutcomes,
|
||||
]);
|
||||
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes);
|
||||
}
|
||||
|
||||
private function acceptedRiskSummary(Tenant $tenant): string
|
||||
@ -477,7 +459,7 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
return 'No published review available yet';
|
||||
}
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
@ -487,10 +469,10 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||
|
||||
return match (true) {
|
||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
||||
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
||||
$statusMarkedCount === 0 => 'No accepted risks recorded.',
|
||||
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount),
|
||||
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount),
|
||||
default => sprintf('%d accepted risks are on record.', $statusMarkedCount),
|
||||
};
|
||||
}
|
||||
|
||||
@ -499,17 +481,17 @@ private function reviewPackAvailability(Tenant $tenant): string
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.unavailable');
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.unavailable');
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.unavailable');
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
return __('localization.review.available');
|
||||
return 'Available';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,6 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -59,7 +58,6 @@ class WorkspaceSettings extends Page
|
||||
*/
|
||||
private const SETTING_FIELDS = [
|
||||
'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_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||
@ -155,22 +153,17 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('save')
|
||||
->label(__('localization.workspace.save'))
|
||||
->label('Save')
|
||||
->action(function (): void {
|
||||
$this->save();
|
||||
})
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->tooltip(fn (): ?string => $this->currentUserCanManage()
|
||||
? null
|
||||
: __('localization.workspace.no_manage_permission')),
|
||||
: 'You do not have permission to manage workspace settings.'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.workspace.title');
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
@ -215,18 +208,6 @@ public function content(Schema $schema): Schema
|
||||
return $schema
|
||||
->statePath('data')
|
||||
->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')
|
||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
||||
->columns(2)
|
||||
@ -526,7 +507,7 @@ public function save(): void
|
||||
$this->loadFormState();
|
||||
|
||||
Notification::make()
|
||||
->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged'))
|
||||
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
@ -545,7 +526,7 @@ public function resetSetting(string $field): void
|
||||
|
||||
if ($this->workspaceOverrideForField($field) === null) {
|
||||
Notification::make()
|
||||
->title(__('localization.notifications.setting_already_default'))
|
||||
->title('Setting already uses default')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
@ -562,7 +543,7 @@ public function resetSetting(string $field): void
|
||||
$this->loadFormState();
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.notifications.workspace_setting_reset'))
|
||||
->title('Workspace setting reset to default')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
@ -711,17 +692,18 @@ private function sectionDescription(string $domain, string $baseDescription): st
|
||||
/** @var Carbon $updatedAt */
|
||||
$updatedAt = $meta['updated_at'];
|
||||
|
||||
return __('localization.workspace.last_modified_by', [
|
||||
'description' => $baseDescription,
|
||||
'user' => $meta['user_name'],
|
||||
'time' => $updatedAt->diffForHumans(),
|
||||
]);
|
||||
return sprintf(
|
||||
'%s — Last modified by %s, %s.',
|
||||
$baseDescription,
|
||||
$meta['user_name'],
|
||||
$updatedAt->diffForHumans(),
|
||||
);
|
||||
}
|
||||
|
||||
private function makeResetAction(string $field): Action
|
||||
{
|
||||
return Action::make('reset_'.$field)
|
||||
->label(__('localization.workspace.reset'))
|
||||
->label('Reset')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function () use ($field): void {
|
||||
@ -736,15 +718,15 @@ private function makeResetAction(string $field): Action
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
|
||||
->tooltip(function () use ($field): ?string {
|
||||
if (! $this->currentUserCanManage()) {
|
||||
return __('localization.workspace.no_manage_permission');
|
||||
return 'You do not have permission to manage workspace settings.';
|
||||
}
|
||||
|
||||
if (! $this->canResetField($field)) {
|
||||
if ($this->isEntitlementOverrideValueField($field)) {
|
||||
return __('localization.workspace.no_workspace_override');
|
||||
return 'No workspace override to reset.';
|
||||
}
|
||||
|
||||
return __('localization.workspace.no_workspace_override');
|
||||
return 'No workspace override to reset.';
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -966,29 +948,6 @@ private function helperTextFor(string $field): string
|
||||
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
|
||||
{
|
||||
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
|
||||
@ -1394,9 +1353,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
|
||||
private function sourceLabel(string $source): string
|
||||
{
|
||||
return match ($source) {
|
||||
'workspace_override' => __('localization.source.workspace_override'),
|
||||
'workspace_override' => 'workspace override',
|
||||
'tenant_override' => 'tenant override',
|
||||
default => __('localization.source.system_default'),
|
||||
default => 'system default',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -42,11 +42,6 @@ class TenantDashboard extends Dashboard
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.dashboard.tenant_title');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
@ -95,38 +90,38 @@ public function authorizeTenantSupportRequest(): void
|
||||
private function requestSupportAction(): Action
|
||||
{
|
||||
$action = Action::make('requestSupport')
|
||||
->label(__('localization.dashboard.request_support'))
|
||||
->label('Request support')
|
||||
->icon('heroicon-o-paper-airplane')
|
||||
->color('gray')
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading(__('localization.dashboard.support_request_heading'))
|
||||
->modalDescription(__('localization.dashboard.support_request_description'))
|
||||
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
|
||||
->modalHeading('Request support')
|
||||
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.')
|
||||
->modalSubmitActionLabel('Submit request')
|
||||
->form([
|
||||
Placeholder::make('included_context')
|
||||
->label(__('localization.dashboard.included_context'))
|
||||
->label('Included context')
|
||||
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
||||
->columnSpanFull(),
|
||||
Select::make('severity')
|
||||
->label(__('localization.dashboard.severity'))
|
||||
->label('Severity')
|
||||
->options(SupportRequest::severityOptions())
|
||||
->default(SupportRequest::SEVERITY_NORMAL)
|
||||
->required()
|
||||
->native(false),
|
||||
TextInput::make('summary')
|
||||
->label(__('localization.dashboard.summary'))
|
||||
->label('Summary')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
Textarea::make('reproduction_notes')
|
||||
->label(__('localization.dashboard.reproduction_notes'))
|
||||
->label('Reproduction notes')
|
||||
->rows(4)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('contact_name')
|
||||
->label(__('localization.dashboard.contact_name'))
|
||||
->label('Contact name')
|
||||
->default(fn (): ?string => $this->resolveDashboardActor()->name),
|
||||
TextInput::make('contact_email')
|
||||
->label(__('localization.dashboard.contact_email'))
|
||||
->label('Contact email')
|
||||
->email()
|
||||
->default(fn (): ?string => $this->resolveDashboardActor()->email),
|
||||
])
|
||||
@ -137,7 +132,7 @@ private function requestSupportAction(): Action
|
||||
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.dashboard.support_request_submitted'))
|
||||
->title('Support request submitted')
|
||||
->body('Reference '.$supportRequest->internal_reference)
|
||||
->success()
|
||||
->send();
|
||||
@ -151,16 +146,16 @@ private function requestSupportAction(): Action
|
||||
private function openSupportDiagnosticsAction(): Action
|
||||
{
|
||||
$action = Action::make('openSupportDiagnostics')
|
||||
->label(__('localization.dashboard.open_support_diagnostics'))
|
||||
->label('Open support diagnostics')
|
||||
->icon('heroicon-o-lifebuoy')
|
||||
->color('gray')
|
||||
->modal()
|
||||
->slideOver()
|
||||
->stickyModalHeader()
|
||||
->modalHeading(__('localization.dashboard.support_diagnostics'))
|
||||
->modalDescription(__('localization.dashboard.support_diagnostics_description'))
|
||||
->modalHeading('Support diagnostics')
|
||||
->modalDescription('Redacted tenant context from existing records.')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
|
||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
|
||||
->mountUsing(function (): void {
|
||||
$this->auditTenantSupportDiagnosticsOpen();
|
||||
})
|
||||
|
||||
@ -75,6 +75,8 @@ class FindingResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Findings';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
@ -84,26 +86,6 @@ public static function shouldRegisterNavigation(): bool
|
||||
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
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
@ -10,8 +10,14 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions;
|
||||
@ -71,15 +77,15 @@ public function getTabs(): array
|
||||
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||
|
||||
return [
|
||||
'all' => Tab::make(__('localization.findings.all'))
|
||||
'all' => Tab::make('All')
|
||||
->icon('heroicon-m-list-bullet'),
|
||||
'needs_action' => Tab::make(__('localization.findings.needs_action'))
|
||||
'needs_action' => Tab::make('Needs action')
|
||||
->icon('heroicon-m-exclamation-triangle')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery()))
|
||||
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||
->badgeColor('warning'),
|
||||
'overdue' => Tab::make(__('localization.findings.overdue'))
|
||||
'overdue' => Tab::make('Overdue')
|
||||
->icon('heroicon-m-clock')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
@ -87,11 +93,11 @@ public function getTabs(): array
|
||||
->where('due_at', '<', now()))
|
||||
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||
->badgeColor('danger'),
|
||||
'risk_accepted' => Tab::make(__('localization.findings.risk_accepted'))
|
||||
'risk_accepted' => Tab::make('Risk accepted')
|
||||
->icon('heroicon-m-shield-check')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||
'resolved' => Tab::make(__('localization.findings.resolved'))
|
||||
'resolved' => Tab::make('Resolved')
|
||||
->icon('heroicon-m-archive-box')
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||
@ -102,6 +108,77 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$actions[] = UiEnforcement::forAction(
|
||||
Actions\Action::make('backfill_lifecycle')
|
||||
->label('Backfill findings lifecycle')
|
||||
->icon('heroicon-o-wrench-screwdriver')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Backfill findings lifecycle')
|
||||
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
||||
->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$opRun = $runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: $user,
|
||||
reason: null,
|
||||
source: 'tenant_ui',
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
throw new \Filament\Support\Exceptions\Halt;
|
||||
}
|
||||
|
||||
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
|
||||
$actions[] = UiEnforcement::forAction(
|
||||
Actions\Action::make('triage_all_matching')
|
||||
->label('Triage all matching')
|
||||
|
||||
@ -44,7 +44,7 @@ protected function getHeaderActions(): array
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
Actions\Action::make('open_approval_queue')
|
||||
->label(__('localization.findings.open_approval_queue'))
|
||||
->label('Open approval queue')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(function (): bool {
|
||||
@ -61,7 +61,7 @@ protected function getHeaderActions(): array
|
||||
: null;
|
||||
}),
|
||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||
->label(__('localization.findings.actions'))
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
]);
|
||||
|
||||
@ -85,26 +85,6 @@ public static function shouldRegisterNavigation(): bool
|
||||
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
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -173,7 +153,7 @@ public static function form(Schema $schema): Schema
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make(__('localization.review.outcome_summary'))
|
||||
Section::make('Outcome summary')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
@ -182,7 +162,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make(__('localization.review.review'))
|
||||
Section::make('Review')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
@ -191,23 +171,23 @@ public static function infolist(Schema $schema): Schema
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextEntry::make('completeness_state')
|
||||
->label(__('localization.review.completeness'))
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('tenant.name')->label(__('localization.review.tenant')),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label(__('localization.review.evidence_snapshot'))
|
||||
->label('Evidence snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('currentExportReviewPack.id')
|
||||
->label(__('localization.review.current_export'))
|
||||
->label('Current export')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
||||
@ -221,7 +201,7 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make(__('localization.review.executive_posture'))
|
||||
Section::make('Executive posture')
|
||||
->schema([
|
||||
ViewEntry::make('review_summary')
|
||||
->hiddenLabel()
|
||||
@ -230,21 +210,21 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make(__('localization.review.sections'))
|
||||
Section::make('Sections')
|
||||
->schema([
|
||||
RepeatableEntry::make('sections')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('title'),
|
||||
TextEntry::make('completeness_state')
|
||||
->label(__('localization.review.completeness'))
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
||||
Section::make(__('localization.review.details'))
|
||||
Section::make('Details')
|
||||
->schema([
|
||||
ViewEntry::make('section_payload')
|
||||
->hiddenLabel()
|
||||
@ -266,7 +246,7 @@ public static function table(Table $table): Table
|
||||
{
|
||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label(__('localization.review.export_executive_pack'))
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
@ -298,7 +278,7 @@ public static function table(Table $table): Table
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->label(__('localization.review.outcome'))
|
||||
->label('Outcome')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
|
||||
@ -309,10 +289,10 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||
->label(__('localization.review.export'))
|
||||
->label('Export')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('next_step')
|
||||
->label(__('localization.review.next_step'))
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
@ -326,18 +306,18 @@ public static function table(Table $table): Table
|
||||
->all()),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
$exportExecutivePackAction,
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
|
||||
->emptyStateDescription(__('localization.review.create_first_review_description'))
|
||||
->emptyStateHeading('No tenant reviews yet')
|
||||
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
||||
->emptyStateActions([
|
||||
static::makeCreateReviewAction(
|
||||
name: 'create_first_review',
|
||||
label: __('localization.review.create_first_review'),
|
||||
label: 'Create first review',
|
||||
icon: 'heroicon-o-plus',
|
||||
),
|
||||
]);
|
||||
@ -356,23 +336,19 @@ public static function makeCreateReviewAction(
|
||||
string $label = 'Create review',
|
||||
string $icon = 'heroicon-o-plus',
|
||||
): Actions\Action {
|
||||
$label = $label === 'Create review'
|
||||
? __('localization.review.create_review')
|
||||
: $label;
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label($label)
|
||||
->icon($icon)
|
||||
->form([
|
||||
Section::make(__('localization.review.evidence_basis'))
|
||||
Section::make('Evidence basis')
|
||||
->schema([
|
||||
Select::make('evidence_snapshot_id')
|
||||
->label(__('localization.review.evidence_snapshot'))
|
||||
->label('Evidence snapshot')
|
||||
->required()
|
||||
->options(fn (): array => static::evidenceSnapshotOptions())
|
||||
->searchable()
|
||||
->helperText(__('localization.review.evidence_basis_helper')),
|
||||
->helperText('Choose the anchored evidence snapshot for this review.'),
|
||||
]),
|
||||
])
|
||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||
@ -390,7 +366,7 @@ public static function executeCreateReview(array $data): void
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
|
||||
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -412,7 +388,7 @@ public static function executeCreateReview(array $data): void
|
||||
: null;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send();
|
||||
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -420,7 +396,7 @@ public static function executeCreateReview(array $data): void
|
||||
try {
|
||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
|
||||
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -430,11 +406,11 @@ public static function executeCreateReview(array $data): void
|
||||
if (! $review->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('localization.review.review_already_available'))
|
||||
->body(__('localization.review.review_already_available_body'))
|
||||
->title('Review already available')
|
||||
->body('A matching mutable review already exists for this evidence basis.')
|
||||
->actions([
|
||||
Actions\Action::make('view_review')
|
||||
->label(__('localization.review.view_review'))
|
||||
->label('View review')
|
||||
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -443,12 +419,12 @@ public static function executeCreateReview(array $data): void
|
||||
}
|
||||
|
||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
||||
->body(__('localization.review.review_composing_background'));
|
||||
->body('The review is being composed in the background.');
|
||||
|
||||
if ($review->operation_run_id) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label(__('localization.review.open_operation'))
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
||||
]);
|
||||
}
|
||||
@ -520,7 +496,7 @@ public static function executeExport(TenantReview $review): void
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
|
||||
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -537,7 +513,7 @@ public static function executeExport(TenantReview $review): void
|
||||
|
||||
if ($service->checkActiveRunForReview($review)) {
|
||||
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body(__('localization.review.export_already_queued_body'))
|
||||
->body('An executive pack export is already queued or running for this review.')
|
||||
->send();
|
||||
|
||||
return;
|
||||
@ -549,11 +525,11 @@ public static function executeExport(TenantReview $review): void
|
||||
'include_operations' => true,
|
||||
]);
|
||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
||||
Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
|
||||
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
|
||||
|
||||
return;
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send();
|
||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -564,11 +540,11 @@ public static function executeExport(TenantReview $review): void
|
||||
if (! $pack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(__('localization.review.executive_pack_already_available'))
|
||||
->body(__('localization.review.executive_pack_already_available_body'))
|
||||
->title('Executive pack already available')
|
||||
->body('A matching executive pack already exists for this review.')
|
||||
->actions([
|
||||
Actions\Action::make('view_pack')
|
||||
->label(__('localization.review.view_pack'))
|
||||
->label('View pack')
|
||||
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -577,7 +553,7 @@ public static function executeExport(TenantReview $review): void
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body(__('localization.review.executive_pack_generating_background'))
|
||||
->body('The executive pack is being generated in the background.')
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -617,7 +593,7 @@ private static function evidenceSnapshotOptions(): array
|
||||
'#%d · %s · %s',
|
||||
(int) $snapshot->getKey(),
|
||||
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending')
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||
),
|
||||
])
|
||||
->all();
|
||||
@ -641,7 +617,7 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||
|
||||
if ($findingOutcomeSummary !== null) {
|
||||
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
||||
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
|
||||
}
|
||||
|
||||
return [
|
||||
@ -653,12 +629,12 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
'metrics' => [
|
||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -672,37 +648,37 @@ private static function summaryContextLinks(TenantReview $record): array
|
||||
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.operation'),
|
||||
'label' => __('localization.review.open_operation'),
|
||||
'title' => 'Operation',
|
||||
'label' => 'Open operation',
|
||||
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||
'description' => __('localization.review.operation_description'),
|
||||
'description' => 'Inspect the latest review composition or refresh run.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->currentExportReviewPack && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.executive_pack'),
|
||||
'label' => __('localization.review.view_executive_pack'),
|
||||
'title' => 'Executive pack',
|
||||
'label' => 'View executive pack',
|
||||
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
|
||||
'description' => __('localization.review.executive_pack_description'),
|
||||
'description' => 'Open the current export that belongs to this review.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.customer_workspace'),
|
||||
'label' => __('localization.review.open_customer_workspace'),
|
||||
'title' => 'Customer workspace',
|
||||
'label' => 'Open customer workspace',
|
||||
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
||||
'description' => __('localization.review.customer_workspace_description'),
|
||||
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->evidenceSnapshot && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.evidence_snapshot'),
|
||||
'label' => __('localization.review.view_evidence_snapshot'),
|
||||
'title' => 'Evidence snapshot',
|
||||
'label' => 'View evidence snapshot',
|
||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||
'description' => __('localization.review.evidence_snapshot_description'),
|
||||
'description' => 'Return to the evidence basis behind this review.',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -28,11 +28,6 @@ class Dashboard extends BaseDashboard
|
||||
{
|
||||
public string $window = SystemConsoleWindow::LastDay;
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('localization.dashboard.system_title');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
@ -114,12 +109,12 @@ protected function getHeaderActions(): array
|
||||
|
||||
return [
|
||||
Action::make('set_window')
|
||||
->label(__('localization.dashboard.time_window'))
|
||||
->label('Time window')
|
||||
->icon('heroicon-o-clock')
|
||||
->color('gray')
|
||||
->form([
|
||||
Select::make('window')
|
||||
->label(__('localization.dashboard.window'))
|
||||
->label('Window')
|
||||
->options(SystemConsoleWindow::options())
|
||||
->default($this->window)
|
||||
->required(),
|
||||
@ -135,7 +130,7 @@ protected function getHeaderActions(): array
|
||||
}),
|
||||
|
||||
Action::make('enter_break_glass')
|
||||
->label(__('localization.dashboard.enter_break_glass'))
|
||||
->label('Enter break-glass mode')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
|
||||
->requiresConfirmation()
|
||||
@ -163,13 +158,13 @@ protected function getHeaderActions(): array
|
||||
$breakGlass->start($user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.dashboard.recovery_mode_enabled'))
|
||||
->title('Recovery mode enabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('exit_break_glass')
|
||||
->label(__('localization.dashboard.exit_break_glass'))
|
||||
->label('Exit break-glass')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
|
||||
->requiresConfirmation()
|
||||
@ -185,7 +180,7 @@ protected function getHeaderActions(): array
|
||||
$breakGlass->exit($user);
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.dashboard.recovery_mode_ended'))
|
||||
->title('Recovery mode ended')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
@ -4,9 +4,26 @@
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class Runbooks extends Page
|
||||
{
|
||||
@ -20,6 +37,53 @@ class Runbooks extends Page
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.runbooks';
|
||||
|
||||
public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
||||
|
||||
public ?int $findingsTenantId = null;
|
||||
|
||||
public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
/**
|
||||
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
||||
*/
|
||||
public ?array $findingsPreflight = null;
|
||||
|
||||
/**
|
||||
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
||||
*/
|
||||
public ?array $preflight = null;
|
||||
|
||||
public function findingsScopeLabel(): string
|
||||
{
|
||||
if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
|
||||
return 'All tenants';
|
||||
}
|
||||
|
||||
$tenantName = $this->selectedTenantName($this->findingsTenantId);
|
||||
|
||||
if ($tenantName !== null) {
|
||||
return "Single tenant ({$tenantName})";
|
||||
}
|
||||
|
||||
return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant';
|
||||
}
|
||||
|
||||
public function findingsLastRun(): ?OperationRun
|
||||
{
|
||||
return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
|
||||
}
|
||||
|
||||
public function selectedTenantName(?int $tenantId): ?string
|
||||
{
|
||||
if ($tenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tenant::query()->whereKey($tenantId)->value('name');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
@ -31,4 +95,231 @@ public static function canAccess(): bool
|
||||
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
||||
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('preflight')
|
||||
->label('Preflight')
|
||||
->color('gray')
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->form($this->findingsScopeForm())
|
||||
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
|
||||
|
||||
$this->findingsScopeMode = $scope->mode;
|
||||
$this->findingsTenantId = $scope->tenantId;
|
||||
$this->scopeMode = $scope->mode;
|
||||
$this->tenantId = $scope->tenantId;
|
||||
|
||||
$this->findingsPreflight = $runbookService->preflight($scope);
|
||||
$this->preflight = $this->findingsPreflight;
|
||||
|
||||
Notification::make()
|
||||
->title('Preflight complete')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('run')
|
||||
->label('Run…')
|
||||
->icon('heroicon-o-play')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Run: Rebuild Findings Lifecycle')
|
||||
->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.')
|
||||
->form($this->findingsRunForm())
|
||||
->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
|
||||
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'preflight' => 'Run preflight first.',
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
|
||||
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN)
|
||||
|| ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL)
|
||||
) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($scope->isAllTenants()) {
|
||||
$typedConfirmation = (string) ($data['typed_confirmation'] ?? '');
|
||||
|
||||
if ($typedConfirmation !== 'BACKFILL') {
|
||||
throw ValidationException::withMessages([
|
||||
'typed_confirmation' => 'Please type BACKFILL to confirm.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$reason = RunbookReason::fromNullableArray([
|
||||
'reason_code' => $data['reason_code'] ?? null,
|
||||
'reason_text' => $data['reason_text'] ?? null,
|
||||
]);
|
||||
|
||||
try {
|
||||
$run = $runbookService->start(
|
||||
scope: $scope,
|
||||
initiator: $user,
|
||||
reason: $reason,
|
||||
source: 'system_ui',
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
throw new \Filament\Support\Exceptions\Halt;
|
||||
}
|
||||
|
||||
$viewUrl = SystemOperationRunLinks::view($run);
|
||||
|
||||
$toast = $run->wasRecentlyCreated
|
||||
? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.')
|
||||
: OperationUxPresenter::alreadyQueuedToast((string) $run->type);
|
||||
|
||||
$toast
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($viewUrl),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function findingsScopeForm(): array
|
||||
{
|
||||
return [
|
||||
Radio::make('scope_mode')
|
||||
->label('Scope')
|
||||
->options([
|
||||
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
|
||||
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
|
||||
])
|
||||
->default($this->findingsScopeMode)
|
||||
->live()
|
||||
->required(),
|
||||
|
||||
Select::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->searchable()
|
||||
->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array {
|
||||
return $universe
|
||||
->query()
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orderBy('name')
|
||||
->limit(25)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
})
|
||||
->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $universe
|
||||
->query()
|
||||
->whereKey((int) $value)
|
||||
->value('name');
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function findingsRunForm(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('typed_confirmation')
|
||||
->label('Type BACKFILL to confirm')
|
||||
->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||
->required(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||
->in(['BACKFILL'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type BACKFILL to confirm.',
|
||||
]),
|
||||
|
||||
Select::make('reason_code')
|
||||
->label('Reason code')
|
||||
->options(RunbookReason::options())
|
||||
->required(function (BreakGlassSession $breakGlass): bool {
|
||||
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
||||
}),
|
||||
|
||||
Textarea::make('reason_text')
|
||||
->label('Reason')
|
||||
->rows(4)
|
||||
->maxLength(500)
|
||||
->required(function (BreakGlassSession $breakGlass): bool {
|
||||
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function lastRunForType(string $type): ?OperationRun
|
||||
{
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
if (! $platformTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $platformTenant->workspace_id)
|
||||
->where('type', $type)
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||
{
|
||||
$scope = FindingsLifecycleBackfillScope::fromArray([
|
||||
'mode' => $data['scope_mode'] ?? null,
|
||||
'tenant_id' => $data['tenant_id'] ?? null,
|
||||
]);
|
||||
|
||||
if (! $scope->isSingleTenant()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
|
||||
|
||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||
}
|
||||
|
||||
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
||||
{
|
||||
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
|
||||
return FindingsLifecycleBackfillScope::allTenants();
|
||||
}
|
||||
|
||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
|
||||
|
||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
398
apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
Normal file
398
apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
Normal file
@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class BackfillFindingLifecycleJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly int $workspaceId,
|
||||
public readonly ?int $initiatorUserId = null,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRuns,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $this->initiatorUserId !== null
|
||||
? User::query()->find($this->initiatorUserId)
|
||||
: null;
|
||||
|
||||
$operationRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'findings.lifecycle.backfill',
|
||||
identityInputs: [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'trigger' => 'backfill',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'initiator_user_id' => $this->initiatorUserId,
|
||||
],
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
if ($operationRun->status !== OperationRunStatus::Completed->value) {
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Blocked->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'findings.lifecycle.backfill.lock_busy',
|
||||
'message' => 'Another findings lifecycle backfill is already running for this tenant.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$runbookService->maybeFinalize($operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$total = (int) Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->count();
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'total' => $total,
|
||||
'processed' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$operationRun->refresh();
|
||||
|
||||
$backfillStartedAt = $operationRun->started_at !== null
|
||||
? CarbonImmutable::instance($operationRun->started_at)
|
||||
: CarbonImmutable::now('UTC');
|
||||
|
||||
Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->orderBy('id')
|
||||
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void {
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
$originalAttributes = $finding->getAttributes();
|
||||
|
||||
$this->backfillLifecycleFields($finding, $backfillStartedAt);
|
||||
$this->backfillLegacyAcknowledgedStatus($finding);
|
||||
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
|
||||
$this->backfillDriftRecurrenceKey($finding);
|
||||
|
||||
if ($finding->isDirty()) {
|
||||
$finding->save();
|
||||
$updated++;
|
||||
} else {
|
||||
$finding->setRawAttributes($originalAttributes, sync: true);
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$operationRuns->incrementSummaryCounts($operationRun, [
|
||||
'processed' => $processed,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
});
|
||||
|
||||
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
|
||||
|
||||
if ($consolidatedDuplicates > 0) {
|
||||
$operationRuns->incrementSummaryCounts($operationRun, [
|
||||
'updated' => $consolidatedDuplicates,
|
||||
]);
|
||||
}
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$runbookService->maybeFinalize($operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
|
||||
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [[
|
||||
'code' => 'findings.lifecycle.backfill.failed',
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.',
|
||||
]],
|
||||
);
|
||||
|
||||
$runbookService->maybeFinalize($operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
|
||||
{
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
|
||||
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at === null) {
|
||||
$finding->last_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
|
||||
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
|
||||
|
||||
if ($lastSeen->lessThan($firstSeen)) {
|
||||
$finding->last_seen_at = $firstSeen;
|
||||
}
|
||||
}
|
||||
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
|
||||
{
|
||||
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finding->status = Finding::STATUS_TRIAGED;
|
||||
|
||||
if ($finding->triaged_at === null) {
|
||||
if ($finding->acknowledged_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
|
||||
} elseif ($finding->created_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillSlaFields(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
CarbonImmutable $backfillStartedAt,
|
||||
): void {
|
||||
if (! Finding::isOpenStatus((string) $finding->status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->sla_days === null) {
|
||||
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
|
||||
}
|
||||
|
||||
if ($finding->due_at === null) {
|
||||
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillDriftRecurrenceKey(Finding $finding): void
|
||||
{
|
||||
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = (int) ($finding->tenant_id ?? 0);
|
||||
$scopeKey = (string) ($finding->scope_key ?? '');
|
||||
$subjectType = (string) ($finding->subject_type ?? '');
|
||||
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
|
||||
|
||||
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
$kind = Arr::get($evidence, 'summary.kind');
|
||||
$changeType = Arr::get($evidence, 'change_type');
|
||||
|
||||
$kind = is_string($kind) ? $kind : '';
|
||||
$changeType = is_string($changeType) ? $changeType : '';
|
||||
|
||||
if ($kind === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$dimension = $this->recurrenceDimension($kind, $changeType);
|
||||
|
||||
$finding->recurrence_key = hash('sha256', sprintf(
|
||||
'drift:%d:%s:%s:%s:%s',
|
||||
$tenantId,
|
||||
$scopeKey,
|
||||
$subjectType,
|
||||
$subjectExternalId,
|
||||
$dimension,
|
||||
));
|
||||
}
|
||||
|
||||
private function recurrenceDimension(string $kind, string $changeType): string
|
||||
{
|
||||
$kind = strtolower(trim($kind));
|
||||
$changeType = strtolower(trim($changeType));
|
||||
|
||||
return match ($kind) {
|
||||
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
|
||||
default => $kind,
|
||||
};
|
||||
}
|
||||
|
||||
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
|
||||
{
|
||||
$duplicateKeys = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->whereNotNull('recurrence_key')
|
||||
->select(['recurrence_key'])
|
||||
->groupBy('recurrence_key')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->pluck('recurrence_key')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->values();
|
||||
|
||||
if ($duplicateKeys->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$consolidated = 0;
|
||||
|
||||
foreach ($duplicateKeys as $recurrenceKey) {
|
||||
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('recurrence_key', $recurrenceKey)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
$consolidated++;
|
||||
}
|
||||
}
|
||||
|
||||
return $consolidated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Finding> $findings
|
||||
*/
|
||||
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
|
||||
{
|
||||
if ($findings->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
|
||||
|
||||
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
|
||||
|
||||
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
|
||||
|
||||
if ($alreadyCanonical instanceof Finding) {
|
||||
return $alreadyCanonical;
|
||||
}
|
||||
|
||||
/** @var Finding $sorted */
|
||||
$sorted = $candidates
|
||||
->sortByDesc(function (Finding $finding): array {
|
||||
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
|
||||
|
||||
return [
|
||||
max($lastSeen, $createdAt),
|
||||
(int) $finding->getKey(),
|
||||
];
|
||||
})
|
||||
->first();
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class BackfillFindingLifecycleTenantIntoWorkspaceRunJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $operationRunId,
|
||||
public readonly int $workspaceId,
|
||||
public readonly int $tenantId,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $tenant->workspace_id !== $this->workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $run->workspace_id !== $this->workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($run->tenant_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($run->status === 'queued') {
|
||||
$operationRunService->updateRun($run, status: 'running');
|
||||
}
|
||||
|
||||
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$operationRunService->appendFailures($run, [
|
||||
[
|
||||
'code' => 'findings.lifecycle.backfill.lock_busy',
|
||||
'message' => sprintf('Tenant %d is already running a findings lifecycle backfill.', $this->tenantId),
|
||||
],
|
||||
]);
|
||||
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'failed' => 1,
|
||||
'processed' => 1,
|
||||
]);
|
||||
|
||||
$operationRunService->maybeCompleteBulkRun($run);
|
||||
$runbookService->maybeFinalize($run);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backfillStartedAt = $run->started_at !== null
|
||||
? CarbonImmutable::instance($run->started_at)
|
||||
: CarbonImmutable::now('UTC');
|
||||
|
||||
Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->orderBy('id')
|
||||
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRunService, $run, $backfillStartedAt): void {
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalAttributes = $finding->getAttributes();
|
||||
|
||||
$this->backfillLifecycleFields($finding, $backfillStartedAt);
|
||||
$this->backfillLegacyAcknowledgedStatus($finding);
|
||||
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
|
||||
$this->backfillDriftRecurrenceKey($finding);
|
||||
|
||||
if ($finding->isDirty()) {
|
||||
$finding->save();
|
||||
$updated++;
|
||||
} else {
|
||||
$finding->setRawAttributes($originalAttributes, sync: true);
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated > 0 || $skipped > 0) {
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
|
||||
|
||||
if ($consolidatedDuplicates > 0) {
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'updated' => $consolidatedDuplicates,
|
||||
]);
|
||||
}
|
||||
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'processed' => 1,
|
||||
]);
|
||||
|
||||
$operationRunService->maybeCompleteBulkRun($run);
|
||||
$runbookService->maybeFinalize($run);
|
||||
} catch (Throwable $e) {
|
||||
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
|
||||
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
|
||||
|
||||
$operationRunService->appendFailures($run, [[
|
||||
'code' => 'findings.lifecycle.backfill.failed',
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message !== '' ? $message : sprintf('Tenant %d findings lifecycle backfill failed.', $this->tenantId),
|
||||
]]);
|
||||
|
||||
$operationRunService->incrementSummaryCounts($run, [
|
||||
'failed' => 1,
|
||||
'processed' => 1,
|
||||
]);
|
||||
|
||||
$operationRunService->maybeCompleteBulkRun($run);
|
||||
$runbookService->maybeFinalize($run);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
|
||||
{
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
|
||||
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at === null) {
|
||||
$finding->last_seen_at = $createdAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
|
||||
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
|
||||
|
||||
if ($lastSeen->lessThan($firstSeen)) {
|
||||
$finding->last_seen_at = $firstSeen;
|
||||
}
|
||||
}
|
||||
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
|
||||
{
|
||||
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$finding->status = Finding::STATUS_TRIAGED;
|
||||
|
||||
if ($finding->triaged_at === null) {
|
||||
if ($finding->acknowledged_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
|
||||
} elseif ($finding->created_at !== null) {
|
||||
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillSlaFields(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
CarbonImmutable $backfillStartedAt,
|
||||
): void {
|
||||
if (! Finding::isOpenStatus((string) $finding->status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->sla_days === null) {
|
||||
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
|
||||
}
|
||||
|
||||
if ($finding->due_at === null) {
|
||||
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillDriftRecurrenceKey(Finding $finding): void
|
||||
{
|
||||
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = (int) ($finding->tenant_id ?? 0);
|
||||
$scopeKey = (string) ($finding->scope_key ?? '');
|
||||
$subjectType = (string) ($finding->subject_type ?? '');
|
||||
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
|
||||
|
||||
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
$kind = Arr::get($evidence, 'summary.kind');
|
||||
$changeType = Arr::get($evidence, 'change_type');
|
||||
|
||||
$kind = is_string($kind) ? $kind : '';
|
||||
$changeType = is_string($changeType) ? $changeType : '';
|
||||
|
||||
if ($kind === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$dimension = $this->recurrenceDimension($kind, $changeType);
|
||||
|
||||
$finding->recurrence_key = hash('sha256', sprintf(
|
||||
'drift:%d:%s:%s:%s:%s',
|
||||
$tenantId,
|
||||
$scopeKey,
|
||||
$subjectType,
|
||||
$subjectExternalId,
|
||||
$dimension,
|
||||
));
|
||||
}
|
||||
|
||||
private function recurrenceDimension(string $kind, string $changeType): string
|
||||
{
|
||||
$kind = strtolower(trim($kind));
|
||||
$changeType = strtolower(trim($changeType));
|
||||
|
||||
return match ($kind) {
|
||||
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
|
||||
default => $kind,
|
||||
};
|
||||
}
|
||||
|
||||
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
|
||||
{
|
||||
$duplicateKeys = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->whereNotNull('recurrence_key')
|
||||
->select(['recurrence_key'])
|
||||
->groupBy('recurrence_key')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->pluck('recurrence_key')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->values();
|
||||
|
||||
if ($duplicateKeys->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$consolidated = 0;
|
||||
|
||||
foreach ($duplicateKeys as $recurrenceKey) {
|
||||
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('recurrence_key', $recurrenceKey)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
$consolidated++;
|
||||
}
|
||||
}
|
||||
|
||||
return $consolidated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Finding> $findings
|
||||
*/
|
||||
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
|
||||
{
|
||||
if ($findings->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
|
||||
|
||||
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
|
||||
|
||||
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
|
||||
|
||||
if ($alreadyCanonical instanceof Finding) {
|
||||
return $alreadyCanonical;
|
||||
}
|
||||
|
||||
/** @var Finding $sorted */
|
||||
$sorted = $candidates
|
||||
->sortByDesc(function (Finding $finding): array {
|
||||
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
|
||||
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
|
||||
|
||||
return [
|
||||
max($lastSeen, $createdAt),
|
||||
(int) $finding->getKey(),
|
||||
];
|
||||
})
|
||||
->first();
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BackfillFindingLifecycleWorkspaceJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $operationRunId,
|
||||
public readonly int $workspaceId,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
AllowedTenantUniverse $allowedTenantUniverse,
|
||||
FindingsLifecycleBackfillRunbookService $runbookService,
|
||||
): void {
|
||||
$run = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $run->workspace_id !== $this->workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($run->tenant_id !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantIds = $allowedTenantUniverse
|
||||
->query()
|
||||
->where('workspace_id', $this->workspaceId)
|
||||
->orderBy('id')
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
$tenantCount = count($tenantIds);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'tenants' => $tenantCount,
|
||||
'total' => $tenantCount,
|
||||
'processed' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
if ($tenantCount === 0) {
|
||||
$operationRunService->updateRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
);
|
||||
|
||||
$runbookService->maybeFinalize($run);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
BackfillFindingLifecycleTenantIntoWorkspaceRunJob::dispatch(
|
||||
operationRunId: (int) $run->getKey(),
|
||||
workspaceId: $this->workspaceId,
|
||||
tenantId: $tenantId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,6 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
|
||||
'password',
|
||||
'entra_tenant_id',
|
||||
'entra_object_id',
|
||||
'preferred_locale',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -79,16 +79,16 @@ public function panel(Panel $panel): Panel
|
||||
])
|
||||
->navigationItems([
|
||||
WorkspaceOverview::navigationItem(),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
|
||||
NavigationItem::make('Integrations')
|
||||
->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
|
||||
->icon('heroicon-o-link')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group('Settings')
|
||||
->sort(15)
|
||||
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.settings'))
|
||||
NavigationItem::make('Settings')
|
||||
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group('Settings')
|
||||
->sort(20)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
@ -115,12 +115,12 @@ public function panel(Panel $panel): Panel
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
|
||||
}),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
|
||||
NavigationItem::make('Manage workspaces')
|
||||
->url(function (): string {
|
||||
return route('filament.admin.resources.workspaces.index');
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group(fn (): string => __('localization.navigation.settings'))
|
||||
->group('Settings')
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
@ -136,15 +136,15 @@ public function panel(Panel $panel): Panel
|
||||
->whereIn('role', $roles)
|
||||
->exists();
|
||||
}),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
|
||||
NavigationItem::make('Operations')
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
@ -210,7 +210,6 @@ public function panel(Panel $panel): Panel
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->middleware(['apply-resolved-locale:admin'], isPersistent: true)
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
|
||||
@ -42,14 +42,6 @@ public function panel(Panel $panel): Panel
|
||||
PanelsRenderHook::BODY_START,
|
||||
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')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
@ -67,7 +59,6 @@ public function panel(Panel $panel): Panel
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->middleware(['apply-resolved-locale:system'], isPersistent: true)
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
|
||||
@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Indigo,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
|
||||
NavigationItem::make(OperationRunLinks::collectionLabel())
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.alerts'))
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => url('/admin/alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group(fn (): string => __('localization.navigation.monitoring'))
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
@ -111,7 +111,6 @@ public function panel(Panel $panel): Panel
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->middleware(['apply-resolved-locale:tenant'], isPersistent: true)
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
|
||||
@ -1,215 +0,0 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,739 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Runbooks;
|
||||
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Jobs\BackfillFindingLifecycleWorkspaceJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Services\Audit\AuditRecorder;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class FindingsLifecycleBackfillRunbookService
|
||||
{
|
||||
public const string RUNBOOK_KEY = 'findings.lifecycle.backfill';
|
||||
|
||||
public function __construct(
|
||||
private readonly AllowedTenantUniverse $allowedTenantUniverse,
|
||||
private readonly BreakGlassSession $breakGlassSession,
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly AlertDispatchService $alertDispatchService,
|
||||
private readonly OperationalControlEvaluator $operationalControls,
|
||||
private readonly AuditRecorder $auditRecorder,
|
||||
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{affected_count: int, total_count: int, estimated_tenants?: int|null}
|
||||
*/
|
||||
public function preflight(FindingsLifecycleBackfillScope $scope): array
|
||||
{
|
||||
$result = $this->computePreflight($scope);
|
||||
|
||||
$this->auditSafely(
|
||||
action: 'platform.ops.runbooks.preflight',
|
||||
scope: $scope,
|
||||
operationRunId: null,
|
||||
initiator: null,
|
||||
context: [
|
||||
'preflight' => $result,
|
||||
],
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function start(
|
||||
FindingsLifecycleBackfillScope $scope,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
string $source,
|
||||
): OperationRun {
|
||||
$source = trim($source);
|
||||
|
||||
if ($source === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'source' => 'A run source is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
$isBreakGlassActive = $this->breakGlassSession->isActive();
|
||||
|
||||
if ($scope->isAllTenants() || $isBreakGlassActive) {
|
||||
if (! $reason instanceof RunbookReason) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => 'A reason is required for this run.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$preflight = $this->computePreflight($scope);
|
||||
|
||||
if (($preflight['affected_count'] ?? 0) <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'preflight.affected_count' => 'Nothing to do for this scope.',
|
||||
]);
|
||||
}
|
||||
|
||||
$workspace = null;
|
||||
$tenant = null;
|
||||
|
||||
if ($scope->isSingleTenant()) {
|
||||
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
|
||||
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
||||
|
||||
$workspace = $tenant->workspace;
|
||||
} else {
|
||||
$platformTenant = $this->platformTenant();
|
||||
$workspace = $platformTenant->workspace;
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new \RuntimeException('Platform tenant is missing its workspace.');
|
||||
}
|
||||
|
||||
$decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace);
|
||||
|
||||
if ($decision->isPaused()) {
|
||||
$this->auditBlockedStart(
|
||||
decision: $decision,
|
||||
scope: $scope,
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
initiator: $initiator,
|
||||
source: $source,
|
||||
);
|
||||
|
||||
throw OperationalControlBlockedException::forDecision(
|
||||
decision: $decision,
|
||||
actionLabel: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||
);
|
||||
}
|
||||
|
||||
if ($scope->isAllTenants()) {
|
||||
$lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey());
|
||||
$lock = Cache::lock($lockKey, 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope' => 'Another run is already in progress for this scope.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->startAllTenants(
|
||||
workspace: $workspace,
|
||||
initiator: $initiator,
|
||||
reason: $reason,
|
||||
preflight: $preflight,
|
||||
source: $source,
|
||||
isBreakGlassActive: $isBreakGlassActive,
|
||||
);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->startSingleTenant(
|
||||
tenant: $tenant,
|
||||
initiator: $initiator,
|
||||
reason: $reason,
|
||||
preflight: $preflight,
|
||||
source: $source,
|
||||
isBreakGlassActive: $isBreakGlassActive,
|
||||
);
|
||||
}
|
||||
|
||||
public function maybeFinalize(OperationRun $run): void
|
||||
{
|
||||
$run->refresh();
|
||||
|
||||
if ($run->status !== OperationRunStatus::Completed->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
if ((string) data_get($context, 'runbook.key') !== self::RUNBOOK_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lockKey = sprintf('tenantpilot:runbooks:%s:finalize:%d', self::RUNBOOK_KEY, (int) $run->getKey());
|
||||
$lock = Cache::lock($lockKey, 86400);
|
||||
|
||||
if (! $lock->get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->auditSafely(
|
||||
action: $run->outcome === OperationRunOutcome::Failed->value
|
||||
? 'platform.ops.runbooks.failed'
|
||||
: 'platform.ops.runbooks.completed',
|
||||
scope: $this->scopeFromRunContext($context),
|
||||
operationRunId: (int) $run->getKey(),
|
||||
context: [
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'is_break_glass' => (bool) data_get($context, 'platform_initiator.is_break_glass', false),
|
||||
'reason_code' => data_get($context, 'reason.reason_code'),
|
||||
'reason_text' => data_get($context, 'reason.reason_text'),
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyInitiatorSafely($run);
|
||||
|
||||
if ($run->outcome === OperationRunOutcome::Failed->value) {
|
||||
$this->dispatchFailureAlertSafely($run);
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{affected_count: int, total_count: int, estimated_tenants?: int|null}
|
||||
*/
|
||||
private function computePreflight(FindingsLifecycleBackfillScope $scope): array
|
||||
{
|
||||
if ($scope->isSingleTenant()) {
|
||||
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
|
||||
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
||||
|
||||
return $this->computeTenantPreflight($tenant);
|
||||
}
|
||||
|
||||
$platformTenant = $this->platformTenant();
|
||||
$workspaceId = (int) ($platformTenant->workspace_id ?? 0);
|
||||
|
||||
$tenants = $this->allowedTenantUniverse
|
||||
->query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$affected = 0;
|
||||
$total = 0;
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts = $this->computeTenantPreflight($tenant);
|
||||
|
||||
$affected += (int) ($counts['affected_count'] ?? 0);
|
||||
$total += (int) ($counts['total_count'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'affected_count' => $affected,
|
||||
'total_count' => $total,
|
||||
'estimated_tenants' => $tenants->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{affected_count: int, total_count: int}
|
||||
*/
|
||||
private function computeTenantPreflight(Tenant $tenant): array
|
||||
{
|
||||
$query = Finding::query()->where('tenant_id', (int) $tenant->getKey());
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
|
||||
$affected = 0;
|
||||
|
||||
(clone $query)
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($findings) use (&$affected): void {
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->findingNeedsBackfill($finding)) {
|
||||
$affected++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$affected += $this->countDriftDuplicateConsolidations($tenant);
|
||||
|
||||
return [
|
||||
'affected_count' => $affected,
|
||||
'total_count' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
private function findingNeedsBackfill(Finding $finding): bool
|
||||
{
|
||||
if ($finding->first_seen_at === null || $finding->last_seen_at === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
|
||||
if ($finding->last_seen_at->lt($finding->first_seen_at)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ($timesSeen < 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($finding->status === Finding::STATUS_ACKNOWLEDGED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Finding::isOpenStatus((string) $finding->status)) {
|
||||
if ($finding->sla_days === null || $finding->due_at === null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($finding->finding_type === Finding::FINDING_TYPE_DRIFT) {
|
||||
$recurrenceKey = $finding->recurrence_key !== null ? trim((string) $finding->recurrence_key) : '';
|
||||
|
||||
if ($recurrenceKey === '') {
|
||||
$scopeKey = trim((string) ($finding->scope_key ?? ''));
|
||||
$subjectType = trim((string) ($finding->subject_type ?? ''));
|
||||
$subjectExternalId = trim((string) ($finding->subject_external_id ?? ''));
|
||||
|
||||
if ($scopeKey !== '' && $subjectType !== '' && $subjectExternalId !== '') {
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
$kind = data_get($evidence, 'summary.kind');
|
||||
|
||||
if (is_string($kind) && trim($kind) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function countDriftDuplicateConsolidations(Tenant $tenant): int
|
||||
{
|
||||
$rows = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->whereNotNull('recurrence_key')
|
||||
->select(['recurrence_key', DB::raw('COUNT(*) as count')])
|
||||
->groupBy('recurrence_key')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->get();
|
||||
|
||||
$duplicates = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$count = is_numeric($row->count ?? null) ? (int) $row->count : 0;
|
||||
|
||||
if ($count > 1) {
|
||||
$duplicates += ($count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return $duplicates;
|
||||
}
|
||||
|
||||
private function startAllTenants(
|
||||
Workspace $workspace,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
array $preflight,
|
||||
string $source,
|
||||
bool $isBreakGlassActive,
|
||||
): OperationRun {
|
||||
$run = $this->operationRunService->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: self::RUNBOOK_KEY,
|
||||
identityInputs: [
|
||||
'runbook' => self::RUNBOOK_KEY,
|
||||
'scope' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
|
||||
],
|
||||
context: $this->buildRunContext(
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||
initiator: $initiator,
|
||||
reason: $reason,
|
||||
preflight: $preflight,
|
||||
source: $source,
|
||||
isBreakGlassActive: $isBreakGlassActive,
|
||||
),
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||
$run->update(['initiator_name' => $initiator->name ?: $initiator->email]);
|
||||
$run->refresh();
|
||||
}
|
||||
|
||||
$this->auditSafely(
|
||||
action: 'platform.ops.runbooks.start',
|
||||
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||
operationRunId: (int) $run->getKey(),
|
||||
initiator: $initiator,
|
||||
context: [
|
||||
'preflight' => $preflight,
|
||||
'is_break_glass' => $isBreakGlassActive,
|
||||
] + ($reason instanceof RunbookReason ? $reason->toArray() : []),
|
||||
);
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
$this->operationRunService->dispatchOrFail($run, function () use ($run, $workspace): void {
|
||||
BackfillFindingLifecycleWorkspaceJob::dispatch(
|
||||
operationRunId: (int) $run->getKey(),
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function startSingleTenant(
|
||||
?Tenant $tenant,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
array $preflight,
|
||||
string $source,
|
||||
bool $isBreakGlassActive,
|
||||
): OperationRun {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new \RuntimeException('Target tenant is required for single-tenant runs.');
|
||||
}
|
||||
|
||||
$run = $this->operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: self::RUNBOOK_KEY,
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'trigger' => 'backfill',
|
||||
],
|
||||
context: $this->buildRunContext(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: $initiator,
|
||||
reason: $reason,
|
||||
preflight: $preflight,
|
||||
source: $source,
|
||||
isBreakGlassActive: $isBreakGlassActive,
|
||||
),
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||
$run->update(['initiator_name' => $initiator->name ?: $initiator->email]);
|
||||
$run->refresh();
|
||||
}
|
||||
|
||||
$this->auditSafely(
|
||||
action: 'platform.ops.runbooks.start',
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
operationRunId: (int) $run->getKey(),
|
||||
initiator: $initiator,
|
||||
context: [
|
||||
'preflight' => $preflight,
|
||||
'is_break_glass' => $isBreakGlassActive,
|
||||
] + ($reason instanceof RunbookReason ? $reason->toArray() : []),
|
||||
);
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
$this->operationRunService->dispatchOrFail($run, function () use ($tenant): void {
|
||||
BackfillFindingLifecycleJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: null,
|
||||
);
|
||||
});
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function platformTenant(): Tenant
|
||||
{
|
||||
$tenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new \RuntimeException('Platform tenant is missing.');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildRunContext(
|
||||
int $workspaceId,
|
||||
FindingsLifecycleBackfillScope $scope,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
array $preflight,
|
||||
string $source,
|
||||
bool $isBreakGlassActive,
|
||||
): array {
|
||||
$context = [
|
||||
'workspace_id' => $workspaceId,
|
||||
'runbook' => [
|
||||
'key' => self::RUNBOOK_KEY,
|
||||
'scope' => $scope->mode,
|
||||
'target_tenant_id' => $scope->tenantId,
|
||||
'source' => $source,
|
||||
],
|
||||
'preflight' => [
|
||||
'affected_count' => (int) ($preflight['affected_count'] ?? 0),
|
||||
'total_count' => (int) ($preflight['total_count'] ?? 0),
|
||||
'estimated_tenants' => $preflight['estimated_tenants'] ?? null,
|
||||
],
|
||||
];
|
||||
|
||||
if ($reason instanceof RunbookReason) {
|
||||
$context['reason'] = $reason->toArray();
|
||||
}
|
||||
|
||||
if ($initiator instanceof PlatformUser) {
|
||||
$context['platform_initiator'] = [
|
||||
'platform_user_id' => (int) $initiator->getKey(),
|
||||
'email' => (string) $initiator->email,
|
||||
'name' => (string) $initiator->name,
|
||||
'is_break_glass' => $isBreakGlassActive,
|
||||
];
|
||||
} elseif ($initiator instanceof User) {
|
||||
$context['tenant_initiator'] = [
|
||||
'user_id' => (int) $initiator->getKey(),
|
||||
'email' => (string) $initiator->email,
|
||||
'name' => (string) $initiator->name,
|
||||
];
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
private function scopeFromRunContext(array $context): FindingsLifecycleBackfillScope
|
||||
{
|
||||
$scope = data_get($context, 'runbook.scope');
|
||||
$tenantId = data_get($context, 'runbook.target_tenant_id');
|
||||
|
||||
if ($scope === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT && is_numeric($tenantId)) {
|
||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenantId);
|
||||
}
|
||||
|
||||
return FindingsLifecycleBackfillScope::allTenants();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function auditSafely(
|
||||
string $action,
|
||||
FindingsLifecycleBackfillScope $scope,
|
||||
?int $operationRunId,
|
||||
User|PlatformUser|null $initiator,
|
||||
array $context = [],
|
||||
): void {
|
||||
try {
|
||||
$metadata = [
|
||||
'runbook_key' => self::RUNBOOK_KEY,
|
||||
'scope' => $scope->mode,
|
||||
'target_tenant_id' => $scope->tenantId,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
];
|
||||
|
||||
if ($initiator instanceof User && $scope->isSingleTenant()) {
|
||||
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
|
||||
] + $context,
|
||||
actorId: (int) $initiator->getKey(),
|
||||
actorEmail: (string) $initiator->email,
|
||||
actorName: (string) $initiator->name,
|
||||
status: 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$platformTenant = $this->platformTenant();
|
||||
$platformActor = $initiator instanceof PlatformUser
|
||||
? $initiator
|
||||
: auth('platform')->user();
|
||||
|
||||
$actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null;
|
||||
$actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null;
|
||||
$actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null;
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $platformTenant,
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
|
||||
] + $context,
|
||||
actorId: $actorId,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
status: 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// Audit is fail-safe (must not crash runbooks).
|
||||
}
|
||||
}
|
||||
|
||||
private function auditBlockedStart(
|
||||
\App\Support\OperationalControls\OperationalControlDecision $decision,
|
||||
FindingsLifecycleBackfillScope $scope,
|
||||
Workspace $workspace,
|
||||
?Tenant $tenant,
|
||||
User|PlatformUser|null $initiator,
|
||||
string $source,
|
||||
): void {
|
||||
try {
|
||||
$metadata = array_filter([
|
||||
'control_key' => $decision->controlKey,
|
||||
'scope_type' => $decision->matchedScopeType,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'reason_text' => $decision->reasonText,
|
||||
'expires_at' => $decision->expiresAt?->toIso8601String(),
|
||||
'actor_id' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null,
|
||||
'requested_scope' => $scope->mode,
|
||||
'target_tenant_id' => $scope->tenantId,
|
||||
'source' => $source,
|
||||
'runbook_key' => self::RUNBOOK_KEY,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
$summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY));
|
||||
|
||||
if ($scope->isAllTenants()) {
|
||||
$this->auditRecorder->record(
|
||||
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||
context: ['metadata' => $metadata],
|
||||
actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null,
|
||||
target: new AuditTargetSnapshot(
|
||||
type: 'operational_control',
|
||||
id: $decision->sourceActivationId,
|
||||
label: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||
),
|
||||
outcome: 'blocked',
|
||||
summary: $summary,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->workspaceAuditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||
context: ['metadata' => $metadata],
|
||||
actor: $initiator,
|
||||
status: 'blocked',
|
||||
resourceType: 'operational_control',
|
||||
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||
targetLabel: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||
summary: $summary,
|
||||
tenant: $tenant,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// Audit is fail-safe (must not crash runbooks).
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyInitiatorSafely(OperationRun $run): void
|
||||
{
|
||||
try {
|
||||
$platformUserId = data_get($run->context, 'platform_initiator.platform_user_id');
|
||||
|
||||
if (! is_numeric($platformUserId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$platformUser = PlatformUser::query()->whereKey((int) $platformUserId)->first();
|
||||
|
||||
if (! $platformUser instanceof PlatformUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
$platformUser->notify(new OperationRunCompleted($run));
|
||||
} catch (Throwable) {
|
||||
// Notifications must not crash the runbook.
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchFailureAlertSafely(OperationRun $run): void
|
||||
{
|
||||
try {
|
||||
$platformTenant = $this->platformTenant();
|
||||
$workspace = $platformTenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->alertDispatchService->dispatchEvent($workspace, [
|
||||
'tenant_id' => (int) $platformTenant->getKey(),
|
||||
'event_type' => 'operations.run.failed',
|
||||
'severity' => 'high',
|
||||
'title' => 'Operation failed: Findings lifecycle backfill',
|
||||
'body' => 'A findings lifecycle backfill run failed.',
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'operation_type' => $run->canonicalOperationType(),
|
||||
'scope' => (string) data_get($run->context, 'runbook.scope', ''),
|
||||
'view_run_url' => SystemOperationRunLinks::view($run),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
// Alerts must not crash the runbook.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Runbooks;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final readonly class FindingsLifecycleBackfillScope
|
||||
{
|
||||
public const string MODE_ALL_TENANTS = 'all_tenants';
|
||||
|
||||
public const string MODE_SINGLE_TENANT = 'single_tenant';
|
||||
|
||||
private function __construct(
|
||||
public string $mode,
|
||||
public ?int $tenantId,
|
||||
) {}
|
||||
|
||||
public static function allTenants(): self
|
||||
{
|
||||
return new self(
|
||||
mode: self::MODE_ALL_TENANTS,
|
||||
tenantId: null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function singleTenant(int $tenantId): self
|
||||
{
|
||||
$tenantId = (int) $tenantId;
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope.tenant_id' => 'Select a valid tenant.',
|
||||
]);
|
||||
}
|
||||
|
||||
return new self(
|
||||
mode: self::MODE_SINGLE_TENANT,
|
||||
tenantId: $tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$mode = trim((string) ($data['mode'] ?? ''));
|
||||
|
||||
if ($mode === '' || $mode === self::MODE_ALL_TENANTS) {
|
||||
return self::allTenants();
|
||||
}
|
||||
|
||||
if ($mode !== self::MODE_SINGLE_TENANT) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope.mode' => 'Select a valid scope mode.',
|
||||
]);
|
||||
}
|
||||
|
||||
$tenantId = $data['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
throw ValidationException::withMessages([
|
||||
'scope.tenant_id' => 'Select a tenant.',
|
||||
]);
|
||||
}
|
||||
|
||||
return self::singleTenant((int) $tenantId);
|
||||
}
|
||||
|
||||
public function isAllTenants(): bool
|
||||
{
|
||||
return $this->mode === self::MODE_ALL_TENANTS;
|
||||
}
|
||||
|
||||
public function isSingleTenant(): bool
|
||||
{
|
||||
return $this->mode === self::MODE_SINGLE_TENANT;
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ final class OperationRunTriageService
|
||||
'inventory.sync',
|
||||
'policy.sync',
|
||||
'directory.groups.sync',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
@ -27,6 +28,7 @@ final class OperationRunTriageService
|
||||
'inventory.sync',
|
||||
'policy.sync',
|
||||
'directory.groups.sync',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
|
||||
@ -30,6 +30,8 @@ class PlatformCapabilities
|
||||
|
||||
public const RUNBOOKS_RUN = 'platform.runbooks.run';
|
||||
|
||||
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
|
||||
|
||||
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
|
||||
|
||||
/**
|
||||
|
||||
@ -12,6 +12,8 @@ final class TrustedStatePolicy
|
||||
|
||||
public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions';
|
||||
|
||||
public const SYSTEM_RUNBOOKS = 'system_runbooks';
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* name: string,
|
||||
@ -327,6 +329,92 @@ public function firstSlice(): array
|
||||
'scopedTenant',
|
||||
],
|
||||
],
|
||||
self::SYSTEM_RUNBOOKS => [
|
||||
'component_name' => 'System runbooks',
|
||||
'plane' => 'system_platform',
|
||||
'route_anchor' => null,
|
||||
'authority_sources' => [
|
||||
'allowed_tenant_universe',
|
||||
'explicit_scoped_query',
|
||||
],
|
||||
'locked_identities' => [],
|
||||
'locked_identity_fields' => [],
|
||||
'mutable_selectors' => [
|
||||
'findingsTenantId',
|
||||
'tenantId',
|
||||
'findingsScopeMode',
|
||||
'scopeMode',
|
||||
],
|
||||
'mutable_selector_fields' => [
|
||||
$this->field(
|
||||
name: 'findingsTenantId',
|
||||
stateClass: TrustedStateClass::Presentation,
|
||||
phpType: '?int',
|
||||
sourceOfTruth: 'allowed_tenant_universe',
|
||||
usedForProtectedAction: true,
|
||||
revalidationRequired: true,
|
||||
implementationMarkers: [
|
||||
'public ?int $findingsTenantId = null;',
|
||||
'resolveAllowedOrFail($this->findingsTenantId)',
|
||||
],
|
||||
notes: 'Single-tenant runbook proposal only; it must be validated against the operator allowed-tenant universe.',
|
||||
),
|
||||
$this->field(
|
||||
name: 'tenantId',
|
||||
stateClass: TrustedStateClass::Presentation,
|
||||
phpType: '?int',
|
||||
sourceOfTruth: 'allowed_tenant_universe',
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
'public ?int $tenantId = null;',
|
||||
],
|
||||
notes: 'Mirrored display state for the last trusted preflight result.',
|
||||
),
|
||||
$this->field(
|
||||
name: 'findingsScopeMode',
|
||||
stateClass: TrustedStateClass::Presentation,
|
||||
phpType: 'string',
|
||||
sourceOfTruth: 'presentation_only',
|
||||
usedForProtectedAction: true,
|
||||
revalidationRequired: true,
|
||||
implementationMarkers: [
|
||||
'public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
|
||||
'trustedFindingsScopeFromState(',
|
||||
],
|
||||
notes: 'Scope mode remains mutable UI state but protected actions re-normalize it into a trusted scope DTO.',
|
||||
),
|
||||
$this->field(
|
||||
name: 'scopeMode',
|
||||
stateClass: TrustedStateClass::Presentation,
|
||||
phpType: 'string',
|
||||
sourceOfTruth: 'presentation_only',
|
||||
usedForProtectedAction: false,
|
||||
revalidationRequired: false,
|
||||
implementationMarkers: [
|
||||
'public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
|
||||
],
|
||||
notes: 'Mirrored display state for the last trusted preflight result.',
|
||||
),
|
||||
],
|
||||
'server_derived_authority_fields' => [
|
||||
$this->field(
|
||||
name: 'findingsScope',
|
||||
stateClass: TrustedStateClass::ServerDerivedAuthority,
|
||||
phpType: 'FindingsLifecycleBackfillScope',
|
||||
sourceOfTruth: 'allowed_tenant_universe',
|
||||
usedForProtectedAction: true,
|
||||
revalidationRequired: true,
|
||||
implementationMarkers: [
|
||||
'trustedFindingsScopeFromFormData(',
|
||||
'trustedFindingsScopeFromState(',
|
||||
'resolveAllowedOrFail(',
|
||||
],
|
||||
notes: 'Protected actions must convert mutable selector state into a trusted scope DTO via AllowedTenantUniverse.',
|
||||
),
|
||||
],
|
||||
'forbidden_public_authority_fields' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -278,6 +278,7 @@ private static function canonicalDefinitions(): array
|
||||
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
|
||||
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
|
||||
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
|
||||
'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300),
|
||||
];
|
||||
}
|
||||
|
||||
@ -330,6 +331,7 @@ private static function operationAliases(): array
|
||||
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
|
||||
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
|
||||
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
|
||||
new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,9 +4,8 @@
|
||||
|
||||
namespace App\Support\Settings;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
use App\Support\Ai\AiPolicyMode;
|
||||
use App\Models\Finding;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||
|
||||
@ -30,25 +29,6 @@ public function __construct()
|
||||
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(
|
||||
domain: 'backup',
|
||||
key: 'retention_keep_last_default',
|
||||
|
||||
@ -640,18 +640,23 @@ public static function spec195ResidualSurfaceInventory(): array
|
||||
'discoveryState' => 'outside_primary_discovery',
|
||||
'closureDecision' => 'separately_governed',
|
||||
'reasonCategory' => 'workflow_specific_governance',
|
||||
'explicitReason' => 'Runbooks remains a system utility shell outside the declaration-backed record or table surface; it currently exposes no supported launch action after lifecycle-backfill removal.',
|
||||
'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.',
|
||||
'evidence' => [
|
||||
[
|
||||
'kind' => 'feature_livewire_test',
|
||||
'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php',
|
||||
'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.',
|
||||
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php',
|
||||
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.',
|
||||
],
|
||||
[
|
||||
'kind' => 'authorization_test',
|
||||
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
|
||||
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
|
||||
],
|
||||
[
|
||||
'kind' => 'guard_test',
|
||||
'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php',
|
||||
'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.',
|
||||
],
|
||||
],
|
||||
'followUpAction' => 'add_guard_only',
|
||||
'mustRemainBaselineExempt' => false,
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
use App\Http\Middleware\ApplyResolvedLocale;
|
||||
use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
|
||||
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
|
||||
|
||||
@ -25,12 +24,7 @@
|
||||
UseSystemSessionCookieForLivewireRequests::class,
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
ApplyResolvedLocale::class,
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
'apply-resolved-locale' => ApplyResolvedLocale::class,
|
||||
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
|
||||
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
|
||||
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -41,6 +41,7 @@ public function run(): void
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
<?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, nächster 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 über Grund, Governed Subject, Subjektklasse, Ergebnis, nächste Aktion und Subject Key.',
|
||||
'evidence_gap_bucket_help_ambiguous_match' => 'Mehrere Inventory-Datensätze passten zum gleichen Policy-Subjekt. Prüfen Sie das Mapping.',
|
||||
'evidence_gap_bucket_help_policy_record_missing' => 'Der erwartete Policy-Datensatz wurde im Baseline-Snapshot nicht gefunden. Prüfen Sie, ob die Policy im Tenant noch existiert.',
|
||||
'evidence_gap_bucket_help_inventory_record_missing' => 'Für diese Subjekte konnte kein Inventory-Datensatz gefunden werden. Prüfen 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. Prüfen Sie, ob eine Policy erstellt werden sollte.',
|
||||
'evidence_gap_bucket_help_capture_failed' => 'Evidence Capture ist für diese Subjekte fehlgeschlagen. Wiederholen Sie den Vergleich oder prüfen Sie die Graph-Konnektivität.',
|
||||
'evidence_gap_bucket_help_default' => 'Diese Subjekte wurden beim Vergleich markiert. Prüfen 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' => 'Temporär: :count',
|
||||
'evidence_gap_bucket_structural' => ':count strukturell',
|
||||
'evidence_gap_bucket_operational' => ':count operativ',
|
||||
'evidence_gap_bucket_transient' => ':count temporär',
|
||||
'evidence_gap_missing_details_title' => 'Für diesen Run wurden keine Detailzeilen aufgezeichnet',
|
||||
'evidence_gap_missing_details_body' => 'Evidence Gaps wurden für diesen Compare Run gezählt, aber Details auf Subjektebene wurden nicht gespeichert. Prüfen Sie Rohdiagnosen oder wiederholen Sie den Vergleich.',
|
||||
'evidence_gap_missing_reason_body' => ':count betroffene Subjekte wurden für diesen Grund gezählt, 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 für Support und tiefere Fehlersuche nach Operator-Zusammenfassung und Detailansicht verfügbar.',
|
||||
'evidence_gap_policy_type' => 'Governed Subject',
|
||||
'evidence_gap_subject_class' => 'Subjektklasse',
|
||||
'evidence_gap_outcome' => 'Ergebnis',
|
||||
'evidence_gap_next_action' => 'Nächste 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 prüfen.',
|
||||
'comparing_indicator' => 'Vergleich läuft...',
|
||||
'no_findings_all_clear' => 'Kein bestätigter 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 müssen geprüft 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 Sicherheitsgründen unterdrückt.',
|
||||
'coverage_incomplete_body' => 'Findings wurden für :count Policy :types wegen unvollständiger Coverage übersprungen.',
|
||||
'coverage_uncovered_label' => 'Nicht abgedeckt: :list',
|
||||
'failed_title' => 'Vergleich fehlgeschlagen',
|
||||
'failed_body_default' => 'Der letzte Baseline-Vergleich ist fehlgeschlagen. Prüfen 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 ausgewählt',
|
||||
'empty_no_assignment' => 'Keine Baseline zugewiesen',
|
||||
'empty_no_snapshot' => 'Kein Snapshot verfügbar',
|
||||
'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' => 'Unverändert',
|
||||
'rbac_summary_modified' => 'Geändert',
|
||||
'rbac_summary_missing' => 'Fehlend',
|
||||
'rbac_summary_unexpected' => 'Unerwartet',
|
||||
'no_drift_title' => 'Kein Drift erkannt',
|
||||
'no_drift_body' => 'Der letzte Vergleich hat keinen bestätigten Drift für das zugewiesene Baseline-Profil aufgezeichnet.',
|
||||
'coverage_warnings_title' => 'Coverage-Warnungen',
|
||||
'coverage_warnings_body' => 'Der letzte Vergleich wurde mit Warnungen abgeschlossen und erzeugte keine bestätigten 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 prüfen',
|
||||
];
|
||||
@ -1,31 +0,0 @@
|
||||
<?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 unterstützt.',
|
||||
'metadata_only' => 'Nur Metadaten geändert',
|
||||
'permission_change' => 'Berechtigung geändert',
|
||||
'missing' => 'Im aktuellen Tenant fehlend',
|
||||
'unexpected' => 'Unerwartet im aktuellen Tenant',
|
||||
'changed_fields' => 'Geänderte Felder',
|
||||
'baseline' => 'Baseline',
|
||||
'current' => 'Aktuell',
|
||||
'absent' => 'Nicht vorhanden',
|
||||
'role_source' => 'Rollenquelle',
|
||||
'permission_blocks' => 'Berechtigungsblöcke',
|
||||
'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 unterstützt.',
|
||||
],
|
||||
];
|
||||
@ -1,230 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'locales' => [
|
||||
'en' => 'Englisch',
|
||||
'de' => 'Deutsch',
|
||||
],
|
||||
'source' => [
|
||||
'explicit_override' => 'Sitzungsüberschreibung',
|
||||
'user_preference' => 'persönliche Einstellung',
|
||||
'workspace_default' => 'Workspace-Standard',
|
||||
'workspace_override' => 'Workspace-Überschreibung',
|
||||
'system_default' => 'Systemstandard',
|
||||
],
|
||||
'shell' => [
|
||||
'language' => 'Sprache',
|
||||
'current_language' => 'Aktuelle Sprache',
|
||||
'language_source' => 'Quelle: :source',
|
||||
'temporary_override' => 'Temporäre Überschreibung',
|
||||
'switch_language' => 'Sprache wechseln',
|
||||
'clear_override' => 'Geerbte Sprache verwenden',
|
||||
'personal_preference' => 'Persönliche Einstellung',
|
||||
'save_preference' => 'Einstellung speichern',
|
||||
'inherit_workspace' => 'Workspace-Standard verwenden',
|
||||
'workspace' => 'Workspace',
|
||||
'choose_workspace' => 'Workspace auswählen',
|
||||
'switch_workspace' => 'Workspace wechseln',
|
||||
'workspace_home' => 'Workspace-Start',
|
||||
'tenant_scope' => 'Tenant-Kontext',
|
||||
'select_tenant' => 'Tenant auswählen',
|
||||
'selected_tenant' => 'Ausgewählter Tenant',
|
||||
'no_tenant_selected' => 'Kein Tenant ausgewählt',
|
||||
'switch_tenant' => 'Tenant wechseln',
|
||||
'clear_tenant_scope' => 'Tenant-Kontext löschen',
|
||||
'context_unavailable' => 'Kontext nicht verfügbar',
|
||||
'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.',
|
||||
'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.',
|
||||
'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.',
|
||||
'view_managed_tenants' => 'Managed Tenants anzeigen',
|
||||
'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.',
|
||||
'search_tenants' => 'Tenants suchen...',
|
||||
'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.',
|
||||
],
|
||||
'workspace' => [
|
||||
'title' => 'Workspace-Einstellungen',
|
||||
'save' => 'Speichern',
|
||||
'reset' => 'Zurücksetzen',
|
||||
'no_manage_permission' => 'Sie haben keine Berechtigung zum Verwalten der Workspace-Einstellungen.',
|
||||
'no_workspace_override' => 'Keine Workspace-Überschreibung zum Zurücksetzen vorhanden.',
|
||||
'last_modified_by' => ':description - Zuletzt geändert von :user, :time.',
|
||||
'section' => 'Lokalisierung',
|
||||
'section_description' => 'Workspace-Standard für Benutzer ohne persönliche 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 fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen 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 öffnen',
|
||||
'support_diagnostics' => 'Supportdiagnosen',
|
||||
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',
|
||||
'close' => 'Schließen',
|
||||
'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' => 'Prüfen Sie den zuletzt veröffentlichten kundensicheren Status für jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.',
|
||||
'customer_workspace_canonical_note' => 'Eine Zeile öffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfähige Nachweise auf ihren kanonischen tenantbezogenen Oberflächen bleiben.',
|
||||
'reviews' => 'Reviews',
|
||||
'clear_filters' => 'Filter löschen',
|
||||
'tenant' => 'Tenant',
|
||||
'latest_review' => 'Letztes Review',
|
||||
'key_findings' => 'Wichtige Findings',
|
||||
'accepted_risks' => 'Akzeptierte Risiken',
|
||||
'published' => 'Veröffentlicht',
|
||||
'review_pack' => 'Review-Pack',
|
||||
'open_latest_review' => 'Letztes Review öffnen',
|
||||
'download_review_pack' => 'Review-Pack herunterladen',
|
||||
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
|
||||
'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
||||
'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
||||
'no_published_review' => 'Kein veröffentlichtes Review',
|
||||
'no_published_review_available' => 'Noch kein veröffentlichtes Review verfügbar',
|
||||
'no_findings_recorded' => 'Im veröffentlichten Review sind keine Findings erfasst.',
|
||||
'findings_count_summary' => ':count Findings im veröffentlichten 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 benötigen Governance-Nacharbeit (:total gesamt).',
|
||||
'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.',
|
||||
'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.',
|
||||
'unavailable' => 'Nicht verfügbar',
|
||||
'available' => 'Verfügbar',
|
||||
'outcome_summary' => 'Ergebniszusammenfassung',
|
||||
'review' => 'Review',
|
||||
'review_date' => 'Review-Datum',
|
||||
'completeness' => 'Vollständigkeit',
|
||||
'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' => 'Nächster 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 für diesen Tenant zu starten.',
|
||||
'create_first_review' => 'Erstes Review erstellen',
|
||||
'create_review' => 'Review erstellen',
|
||||
'evidence_basis' => 'Evidence-Basis',
|
||||
'evidence_basis_helper' => 'Wählen Sie den verankerten Evidence-Snapshot für dieses Review.',
|
||||
'unable_create_missing_context' => 'Review kann nicht erstellt werden - Kontext fehlt.',
|
||||
'select_valid_evidence_snapshot' => 'Wählen Sie einen gültigen Evidence-Snapshot aus.',
|
||||
'unable_create_review' => 'Review kann nicht erstellt werden',
|
||||
'review_already_available' => 'Review bereits verfügbar',
|
||||
'review_already_available_body' => 'Ein passendes veränderbares Review ist für diese Evidence-Basis bereits vorhanden.',
|
||||
'view_review' => 'Review anzeigen',
|
||||
'open_operation' => 'Operation öffnen',
|
||||
'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 für dieses Review bereits eingereiht oder läuft.',
|
||||
'executive_pack_export_unavailable' => 'Executive-Pack-Export nicht verfügbar',
|
||||
'unable_export_executive_pack' => 'Executive-Pack kann nicht exportiert werden',
|
||||
'executive_pack_already_available' => 'Executive-Pack bereits verfügbar',
|
||||
'executive_pack_already_available_body' => 'Ein passendes Executive-Pack ist für dieses Review bereits vorhanden.',
|
||||
'view_pack' => 'Pack anzeigen',
|
||||
'executive_pack_generating_background' => 'Das Executive-Pack wird im Hintergrund erstellt.',
|
||||
'review_explanation' => 'Review-Erklärung',
|
||||
'reason_owner' => 'Reason Owner',
|
||||
'platform_core' => 'Platform Core',
|
||||
'platform_reason_family' => 'Platform-Reason-Familie',
|
||||
'compatibility' => 'Kompatibilität',
|
||||
'highlights' => 'Highlights',
|
||||
'next_actions' => 'Nächste Aktionen',
|
||||
'related_context' => 'Verwandter Kontext',
|
||||
'publication_readiness' => 'Veröffentlichungsreife',
|
||||
'ready_for_publication' => 'Dieses Review ist bereit für Veröffentlichung und Executive-Pack-Export.',
|
||||
'internal_only' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.',
|
||||
'needs_follow_up' => 'Dieses Review benötigt vor der Veröffentlichung noch Nacharbeit.',
|
||||
'key_entries' => 'Wichtige Einträge',
|
||||
'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' => 'Prüfen Sie die letzte Review-Zusammenstellung oder den Aktualisierungslauf.',
|
||||
'executive_pack' => 'Executive-Pack',
|
||||
'view_executive_pack' => 'Executive-Pack anzeigen',
|
||||
'executive_pack_description' => 'Öffnet den aktuellen Export, der zu diesem Review gehört.',
|
||||
'customer_workspace' => 'Kunden-Workspace',
|
||||
'open_customer_workspace' => 'Kunden-Workspace öffnen',
|
||||
'customer_workspace_description' => 'Öffnet den kundensicheren Review-Workspace mit Filter auf diesen Tenant.',
|
||||
'view_evidence_snapshot' => 'Evidence-Snapshot anzeigen',
|
||||
'evidence_snapshot_description' => 'Zur Evidence-Basis hinter diesem Review zurückkehren.',
|
||||
],
|
||||
'findings' => [
|
||||
'all' => 'Alle',
|
||||
'needs_action' => 'Handlungsbedarf',
|
||||
'overdue' => 'Überfällig',
|
||||
'risk_accepted' => 'Risiko akzeptiert',
|
||||
'resolved' => 'Gelöst',
|
||||
'actions' => 'Aktionen',
|
||||
'open_approval_queue' => 'Freigabewarteschlange öffnen',
|
||||
],
|
||||
'notifications' => [
|
||||
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
|
||||
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',
|
||||
'user_preference_saved' => 'Spracheinstellung gespeichert.',
|
||||
'user_preference_cleared' => 'Spracheinstellung gelöscht.',
|
||||
'workspace_settings_saved' => 'Workspace-Einstellungen gespeichert',
|
||||
'workspace_settings_unchanged' => 'Keine Einstellungsänderungen zu speichern',
|
||||
'workspace_setting_reset' => 'Workspace-Einstellung auf Standard zurückgesetzt',
|
||||
'setting_already_default' => 'Einstellung verwendet bereits den Standard',
|
||||
],
|
||||
'validation' => [
|
||||
'unsupported_locale' => 'Wählen Sie eine unterstützte Sprache.',
|
||||
],
|
||||
];
|
||||
@ -1,230 +0,0 @@
|
||||
<?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.',
|
||||
],
|
||||
];
|
||||
@ -37,7 +37,7 @@
|
||||
$compressedOutcome['primaryLabel'] ?? null,
|
||||
$state['primaryLabel'] ?? null,
|
||||
$operatorExplanation['headline'] ?? null,
|
||||
__('localization.review.artifact_truth'),
|
||||
'Artifact truth',
|
||||
]);
|
||||
$primaryReason = $firstArtifactTruthText([
|
||||
$compressedOutcome['primaryReason'] ?? null,
|
||||
@ -49,7 +49,7 @@
|
||||
$compressedOutcome['nextActionText'] ?? null,
|
||||
data_get($operatorExplanation, 'nextAction.text'),
|
||||
$state['nextActionLabel'] ?? null,
|
||||
__('localization.review.no_action_needed'),
|
||||
'No action needed',
|
||||
]);
|
||||
$diagnosticsSummary = $firstArtifactTruthText([
|
||||
$compressedOutcome['diagnosticsSummary'] ?? null,
|
||||
@ -81,7 +81,7 @@
|
||||
|
||||
if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') {
|
||||
$summaryFacts->push([
|
||||
'label' => __('localization.review.result_meaning'),
|
||||
'label' => 'Result meaning',
|
||||
'value' => $evaluationSpec->label,
|
||||
'badge' => BadgeCatalog::summaryData($evaluationSpec),
|
||||
]);
|
||||
@ -89,7 +89,7 @@
|
||||
|
||||
if ($trustSpec && $trustSpec->label !== 'Unknown') {
|
||||
$summaryFacts->push([
|
||||
'label' => __('localization.review.result_trust'),
|
||||
'label' => 'Result trust',
|
||||
'value' => $trustSpec->label,
|
||||
'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="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.diagnostics') }}
|
||||
Diagnostics
|
||||
</div>
|
||||
|
||||
<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="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $count['label'] ?? __('localization.review.count') }}
|
||||
{{ $count['label'] ?? 'Count' }}
|
||||
</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ (int) ($count['value'] ?? 0) }}
|
||||
@ -211,7 +211,7 @@
|
||||
|
||||
<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">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_step') }}</dt>
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $nextActionText }}
|
||||
</dd>
|
||||
@ -237,7 +237,7 @@
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.guidance') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($nextSteps as $step)
|
||||
@continue(! is_string($step) || trim($step) === '')
|
||||
|
||||
@ -42,14 +42,14 @@
|
||||
|
||||
@if ($entries !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.key_entries') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Key entries</div>
|
||||
<div class="space-y-2">
|
||||
@foreach ($entries as $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="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
|
||||
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }}
|
||||
</div>
|
||||
|
||||
@php
|
||||
@ -82,7 +82,7 @@
|
||||
|
||||
@if ($nextActions !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.follow_up') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Follow-up</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($nextActions as $action)
|
||||
@continue(! is_string($action) || trim($action) === '')
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
@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="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $operatorExplanation['headline'] ?? __('localization.review.review_explanation') }}
|
||||
{{ $operatorExplanation['headline'] ?? 'Review explanation' }}
|
||||
</div>
|
||||
|
||||
@if (filled($operatorExplanation['reliabilityStatement'] ?? null))
|
||||
@ -45,13 +45,13 @@
|
||||
@if ($reasonSemantics !== [])
|
||||
<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">
|
||||
<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'] ?? __('localization.review.platform_core') }}</dd>
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd>
|
||||
</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">
|
||||
<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'] ?? __('localization.review.compatibility') }}</dd>
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@endif
|
||||
@ -74,7 +74,7 @@
|
||||
|
||||
@if ($highlights !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.highlights') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Highlights</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($highlights as $highlight)
|
||||
@continue(! is_string($highlight) || trim($highlight) === '')
|
||||
@ -87,7 +87,7 @@
|
||||
|
||||
@if ($nextActions !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_actions') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next actions</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($nextActions as $action)
|
||||
@continue(! is_string($action) || trim($action) === '')
|
||||
@ -100,7 +100,7 @@
|
||||
|
||||
@if ($contextLinks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.related_context') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
@foreach ($contextLinks as $link)
|
||||
@php
|
||||
@ -130,11 +130,11 @@
|
||||
@endif
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.publication_readiness') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
||||
|
||||
@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">
|
||||
{{ __('localization.review.ready_for_publication') }}
|
||||
This review is ready for publication and executive-pack export.
|
||||
</div>
|
||||
@elseif ($publishBlockers !== [])
|
||||
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
@ -146,7 +146,7 @@
|
||||
</ul>
|
||||
@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>{{ __('localization.review.internal_only') }}</div>
|
||||
<div>This review is currently safe for internal use only.</div>
|
||||
|
||||
@if ($publicationNextAction !== null)
|
||||
<div class="mt-1">{{ $publicationNextAction }}</div>
|
||||
@ -154,7 +154,7 @@
|
||||
</div>
|
||||
@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">
|
||||
{{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }}
|
||||
{{ $publicationNextAction ?? $publicationReason ?? 'This review still needs follow-up before publication.' }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
@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">
|
||||
{{ __('localization.auth.microsoft_not_configured') }}
|
||||
Microsoft sign-in is not configured.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -25,11 +25,11 @@
|
||||
:disabled="! $isConfigured"
|
||||
color="primary"
|
||||
>
|
||||
{{ __('localization.auth.sign_in_microsoft') }}
|
||||
Sign in with Microsoft
|
||||
</x-filament::button>
|
||||
|
||||
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.auth.tenant_admin_membership_required') }}
|
||||
Tenant Admin access requires a tenant membership.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,18 +2,18 @@
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ __('localization.review.customer_safe_review_workspace') }}
|
||||
Customer-safe review workspace
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ __('localization.review.customer_workspace_intro') }}
|
||||
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ __('localization.review.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.
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
</x-filament-panels::page>
|
||||
@ -31,8 +31,8 @@
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected');
|
||||
$workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
|
||||
$tenantLabel = $currentTenantName ?? 'No tenant selected';
|
||||
$workspaceLabel = $workspace?->name ?? 'Choose workspace';
|
||||
$hasActiveTenant = $currentTenantName !== null;
|
||||
$managedTenantsUrl = $workspace
|
||||
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
|
||||
@ -40,8 +40,7 @@
|
||||
$workspaceUrl = $workspace
|
||||
? route('admin.home')
|
||||
: ChooseWorkspace::getUrl(panel: 'admin');
|
||||
$tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace');
|
||||
$localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin';
|
||||
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace';
|
||||
@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">
|
||||
@ -64,7 +63,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">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="{{ $workspace ? __('localization.shell.tenant_scope') : __('localization.shell.select_tenant') }}"
|
||||
aria-label="{{ $workspace ? 'Tenant scope' : '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"
|
||||
>
|
||||
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
@ -79,12 +78,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: '' }">
|
||||
@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="font-semibold">{{ __('localization.shell.context_unavailable') }}</div>
|
||||
<div class="font-semibold">Context unavailable</div>
|
||||
|
||||
@if ($workspace)
|
||||
<div>{{ __('localization.shell.context_unavailable_workspace') }}</div>
|
||||
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div>
|
||||
@else
|
||||
<div>{{ __('localization.shell.context_unavailable_no_workspace') }}</div>
|
||||
<div>Choose a workspace to continue with a valid admin context.</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@ -92,7 +91,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
|
||||
{{-- Workspace section --}}
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{{ __('localization.shell.workspace') }}
|
||||
Workspace
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
|
||||
@ -105,7 +104,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' }}"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ __('localization.shell.switch_workspace') }}
|
||||
Switch workspace
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -114,7 +113,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"
|
||||
>
|
||||
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
{{ __('localization.shell.workspace_home') }}
|
||||
Workspace Home
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -125,7 +124,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="flex items-center justify-between">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{{ __('localization.shell.selected_tenant') }}
|
||||
Selected tenant
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -138,7 +137,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
||||
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"
|
||||
>
|
||||
{{ __('localization.shell.switch_tenant') }}
|
||||
Switch tenant
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -147,7 +146,7 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
|
||||
@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">
|
||||
{{ __('localization.shell.clear_tenant_scope') }}
|
||||
Clear tenant scope
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@ -155,23 +154,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
|
||||
@else
|
||||
@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>{{ __('localization.shell.no_active_tenants') }}</div>
|
||||
<div>No active tenants are available for the standard operating context in this workspace.</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">
|
||||
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
|
||||
{{ __('localization.shell.view_managed_tenants') }}
|
||||
View managed tenants
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
@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">
|
||||
{{ __('localization.shell.workspace_wide_available') }}
|
||||
No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="fi-input fi-text-input w-full"
|
||||
placeholder="{{ __('localization.shell.search_tenants') }}"
|
||||
placeholder="Search tenants…"
|
||||
x-model="query"
|
||||
/>
|
||||
|
||||
@ -208,7 +207,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
|
||||
@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">
|
||||
{{ __('localization.shell.clear_tenant_scope') }}
|
||||
Clear tenant scope
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@ -217,12 +216,10 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
|
||||
</div>
|
||||
@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">
|
||||
{{ __('localization.shell.choose_workspace_first') }}
|
||||
Choose a workspace first.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
|
||||
@include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
|
||||
</div>
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
@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>
|
||||
<x-filament::input.wrapper class="w-full">
|
||||
<x-filament::input.select
|
||||
id="tenantpilot-locale-override-{{ $plane }}"
|
||||
name="locale"
|
||||
>
|
||||
@foreach ($localeOptions as $locale => $label)
|
||||
<option value="{{ $locale }}" @selected($currentLocale === $locale)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</x-filament::input.select>
|
||||
</x-filament::input.wrapper>
|
||||
<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>
|
||||
<x-filament::input.wrapper class="w-full">
|
||||
<x-filament::input.select
|
||||
id="tenantpilot-locale-preference-{{ $plane }}"
|
||||
name="preferred_locale"
|
||||
>
|
||||
<option value="" @selected($preferredLocale === null)>{{ __('localization.shell.inherit_workspace') }}</option>
|
||||
@foreach ($localeOptions as $locale => $label)
|
||||
<option value="{{ $locale }}" @selected($preferredLocale === $locale)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</x-filament::input.select>
|
||||
</x-filament::input.wrapper>
|
||||
<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>
|
||||
@ -1,3 +1,13 @@
|
||||
@php
|
||||
$findingsLastRun = $this->findingsLastRun();
|
||||
$findingsLastRunStatusSpec = $findingsLastRun
|
||||
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status)
|
||||
: null;
|
||||
$findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed'
|
||||
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome)
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
@ -7,7 +17,7 @@
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.
|
||||
Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -15,17 +25,100 @@
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
No supported runbooks
|
||||
Rebuild Findings Lifecycle
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Supported platform runbooks will appear here when they are part of current product truth.
|
||||
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
|
||||
</x-slot>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
||||
There are no operator-run repair runbooks exposed on this surface.
|
||||
<x-slot name="afterHeader">
|
||||
<x-filament::badge color="info" size="sm">
|
||||
{{ $this->findingsScopeLabel() }}
|
||||
</x-filament::badge>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
@if ($findingsLastRun)
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
|
||||
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }}
|
||||
</span>
|
||||
|
||||
@if ($findingsLastRunStatusSpec)
|
||||
<x-filament::badge
|
||||
:color="$findingsLastRunStatusSpec->color"
|
||||
:icon="$findingsLastRunStatusSpec->icon"
|
||||
size="sm"
|
||||
>
|
||||
{{ $findingsLastRunStatusSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($findingsLastRunOutcomeSpec)
|
||||
<x-filament::badge
|
||||
:color="$findingsLastRunOutcomeSpec->color"
|
||||
:icon="$findingsLastRunOutcomeSpec->icon"
|
||||
size="sm"
|
||||
>
|
||||
{{ $findingsLastRunOutcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($findingsLastRun->initiator_name)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
by {{ $findingsLastRun->initiator_name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (is_array($this->findingsPreflight))
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
|
||||
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||
{{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
@if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
||||
Nothing to do for the current scope.
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
|
||||
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
use App\Http\Controllers\AdminConsentCallbackController;
|
||||
use App\Http\Controllers\Auth\EntraController;
|
||||
use App\Http\Controllers\ClearTenantContextController;
|
||||
use App\Http\Controllers\LocalizationController;
|
||||
use App\Http\Controllers\OpenFindingExceptionsQueueController;
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Http\Controllers\ReviewPackDownloadController;
|
||||
@ -68,21 +67,6 @@
|
||||
->middleware('throttle: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(
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_NAME,
|
||||
SuppressDebugbarForSmokeRequests::COOKIE_VALUE,
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not register findings lifecycle backfill or deploy-runbook commands', function (): void {
|
||||
$commands = array_keys(Artisan::all());
|
||||
|
||||
expect($commands)
|
||||
->not->toContain('tenantpilot:findings:backfill-lifecycle')
|
||||
->not->toContain('tenantpilot:run-deploy-runbooks');
|
||||
|
||||
expect((string) file_get_contents(base_path('routes/console.php')))
|
||||
->not->toContain('tenantpilot:findings:backfill-lifecycle')
|
||||
->not->toContain('tenantpilot:run-deploy-runbooks');
|
||||
});
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('delegates deploy-time runbooks to the shared runbook service', function () {
|
||||
$run = OperationRun::factory()->create();
|
||||
|
||||
$this->mock(FindingsLifecycleBackfillRunbookService::class, function ($mock) use ($run): void {
|
||||
$mock->shouldReceive('start')
|
||||
->once()
|
||||
->withArgs(function ($scope, $initiator, $reason, $source): bool {
|
||||
return $scope instanceof FindingsLifecycleBackfillScope
|
||||
&& $scope->isAllTenants()
|
||||
&& $initiator === null
|
||||
&& $reason instanceof RunbookReason
|
||||
&& $source === 'deploy_hook';
|
||||
})
|
||||
->andReturn($run);
|
||||
});
|
||||
|
||||
$this->artisan('tenantpilot:run-deploy-runbooks')
|
||||
->assertExitCode(0);
|
||||
});
|
||||
@ -1,16 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('exposes the findings lifecycle backfill action for entitled tenant operators', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertActionExists('backfill_lifecycle')
|
||||
->assertActionEnabled('backfill_lifecycle');
|
||||
});
|
||||
136
apps/platform/tests/Feature/Findings/FindingBackfillTest.php
Normal file
136
apps/platform/tests/Feature/Findings/FindingBackfillTest.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('backfills legacy findings (ack → triaged, lifecycle fields, due_at from backfill time)', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||
'acknowledged_by_user_id' => (int) $user->getKey(),
|
||||
'first_seen_at' => null,
|
||||
'last_seen_at' => null,
|
||||
'times_seen' => null,
|
||||
'sla_days' => null,
|
||||
'due_at' => null,
|
||||
'triaged_at' => null,
|
||||
'created_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
|
||||
'updated_at' => CarbonImmutable::parse('2026-02-10T00:00:00Z'),
|
||||
]);
|
||||
|
||||
BackfillFindingLifecycleJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_TRIAGED)
|
||||
->and($finding->triaged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
|
||||
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
|
||||
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-10T00:00:00+00:00')
|
||||
->and($finding->times_seen)->toBe(1)
|
||||
->and($finding->sla_days)->toBe(14)
|
||||
->and($finding->due_at?->toIso8601String())->toBe('2026-03-10T10:00:00+00:00')
|
||||
->and($finding->acknowledged_at?->toIso8601String())->toBe('2026-02-20T00:00:00+00:00')
|
||||
->and((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('computes drift recurrence keys and consolidates drift duplicates', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-drift-backfill-duplicate');
|
||||
|
||||
$evidence = [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => ['policy_id' => 'policy-dupe'],
|
||||
'current' => ['policy_id' => 'policy-dupe'],
|
||||
];
|
||||
|
||||
$open = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-dupe',
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'recurrence_key' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'first_seen_at' => null,
|
||||
'last_seen_at' => null,
|
||||
'times_seen' => null,
|
||||
'sla_days' => null,
|
||||
'due_at' => null,
|
||||
'created_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||
'updated_at' => CarbonImmutable::parse('2026-02-20T00:00:00Z'),
|
||||
]);
|
||||
|
||||
$duplicate = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-dupe',
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'recurrence_key' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'first_seen_at' => null,
|
||||
'last_seen_at' => null,
|
||||
'times_seen' => null,
|
||||
'created_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
|
||||
'updated_at' => CarbonImmutable::parse('2026-02-18T00:00:00Z'),
|
||||
]);
|
||||
|
||||
BackfillFindingLifecycleJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$expectedRecurrenceKey = hash(
|
||||
'sha256',
|
||||
sprintf('drift:%d:%s:policy:%s:policy_snapshot:modified', $tenantId, $scopeKey, 'policy-dupe'),
|
||||
);
|
||||
|
||||
expect(Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('recurrence_key', $expectedRecurrenceKey)
|
||||
->count())->toBe(1);
|
||||
|
||||
$open->refresh();
|
||||
$duplicate->refresh();
|
||||
|
||||
expect($open->recurrence_key)->toBe($expectedRecurrenceKey)
|
||||
->and($open->status)->toBe(Finding::STATUS_NEW);
|
||||
|
||||
expect($duplicate->recurrence_key)->toBeNull()
|
||||
->and($duplicate->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($duplicate->resolved_reason)->toBeNull()
|
||||
->and($duplicate->resolved_at)->toBeNull()
|
||||
->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
|
||||
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps canonical findings workflow behavior after removing the backfill runtime surfaces', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW, [
|
||||
'owner_user_id' => null,
|
||||
'assignee_user_id' => null,
|
||||
'sla_days' => 14,
|
||||
'due_at' => now()->addDays(14),
|
||||
]);
|
||||
|
||||
$triaged = $service->triage($finding, $tenant, $owner);
|
||||
$assigned = $service->assign(
|
||||
finding: $triaged,
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
assigneeUserId: (int) $assignee->getKey(),
|
||||
ownerUserId: (int) $owner->getKey(),
|
||||
);
|
||||
$inProgress = $service->startProgress($assigned, $tenant, $owner);
|
||||
$resolved = $service->resolve($inProgress, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$riskAccepted = $service->riskAccept(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$owner,
|
||||
Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
);
|
||||
|
||||
expect($triaged->status)->toBe(Finding::STATUS_TRIAGED)
|
||||
->and($triaged->triaged_at)->not->toBeNull()
|
||||
->and((int) $assigned->owner_user_id)->toBe((int) $owner->getKey())
|
||||
->and((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey())
|
||||
->and($assigned->sla_days)->toBe(14)
|
||||
->and($assigned->due_at)->not->toBeNull()
|
||||
->and($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS)
|
||||
->and($inProgress->in_progress_at)->not->toBeNull()
|
||||
->and($resolved->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($resolved->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
|
||||
->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK);
|
||||
|
||||
expect($this->latestFindingAudit($triaged, AuditActionId::FindingTriaged))->not->toBeNull()
|
||||
->and($this->latestFindingAudit($assigned, AuditActionId::FindingAssigned))->not->toBeNull()
|
||||
->and($this->latestFindingAudit($inProgress, AuditActionId::FindingInProgress))->not->toBeNull()
|
||||
->and($this->latestFindingAudit($resolved, AuditActionId::FindingResolved))->not->toBeNull()
|
||||
->and($this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted))->not->toBeNull();
|
||||
});
|
||||
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationalControlActivation;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps the findings backfill action visible but blocks execution when a control is active', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
OperationalControlActivation::factory()->workspaceScoped()->create([
|
||||
'control_key' => 'findings.lifecycle.backfill',
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'reason_text' => 'Workspace-specific pause.',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertActionExists('backfill_lifecycle')
|
||||
->assertActionEnabled('backfill_lifecycle')
|
||||
->callAction('backfill_lifecycle')
|
||||
->assertNotified('Findings lifecycle backfill paused');
|
||||
|
||||
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
||||
->and($audit?->status)->toBe('blocked')
|
||||
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
|
||||
->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id);
|
||||
});
|
||||
|
||||
it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner');
|
||||
[$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $allowedTenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
OperationalControlActivation::factory()->workspaceScoped()->create([
|
||||
'control_key' => 'findings.lifecycle.backfill',
|
||||
'workspace_id' => (int) $blockedTenant->workspace_id,
|
||||
'reason_text' => 'Paused only for the blocked workspace.',
|
||||
]);
|
||||
|
||||
$this->actingAs($allowedUser);
|
||||
Filament::setTenant($allowedTenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertActionExists('backfill_lifecycle')
|
||||
->assertActionEnabled('backfill_lifecycle')
|
||||
->callAction('backfill_lifecycle');
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('type', 'findings.lifecycle.backfill')
|
||||
->where('tenant_id', (int) $allowedTenant->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
|
||||
Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool {
|
||||
return $job->tenantId === (int) $allowedTenant->getKey()
|
||||
&& $job->workspaceId === (int) $allowedTenant->workspace_id;
|
||||
});
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
|
||||
->where('tenant_id', (int) $allowedTenant->getKey())
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('removes the tenant findings lifecycle backfill header action without removing canonical workflow actions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertActionDoesNotExist('backfill_lifecycle')
|
||||
->assertActionExists('triage_all_matching')
|
||||
->assertTableActionVisible('triage', $finding)
|
||||
->assertTableActionVisible('assign', $finding)
|
||||
->assertTableActionVisible('resolve', $finding)
|
||||
->assertTableActionVisible('request_exception', $finding);
|
||||
|
||||
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->exists())->toBeFalse();
|
||||
});
|
||||
@ -12,6 +12,7 @@ function livewireTrustedStateFirstSliceFixtures(): array
|
||||
return [
|
||||
TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
|
||||
TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php',
|
||||
TrustedStatePolicy::SYSTEM_RUNBOOKS => 'app/Filament/System/Pages/Ops/Runbooks.php',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
<?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);
|
||||
});
|
||||
@ -1,87 +0,0 @@
|
||||
<?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();
|
||||
});
|
||||
@ -1,47 +0,0 @@
|
||||
<?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', 'Sprachüberschreibung angewendet.');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
LocaleResolver::SESSION_OVERRIDE_KEY => 'en',
|
||||
])
|
||||
->delete(route('localization.override.clear'))
|
||||
->assertRedirect()
|
||||
->assertSessionHas('status', 'Sprachüberschreibung gelöscht.');
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,23 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@ -1,50 +0,0 @@
|
||||
<?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);
|
||||
});
|
||||
@ -10,12 +10,12 @@
|
||||
$checks = [
|
||||
[
|
||||
'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php',
|
||||
'required' => [],
|
||||
'forbidden' => [
|
||||
'required' => [
|
||||
'FindingsLifecycleBackfillRunbookService',
|
||||
'FindingsLifecycleBackfillScope',
|
||||
'Backfill findings lifecycle',
|
||||
'backfill_lifecycle',
|
||||
'OperationalControlBlockedException',
|
||||
'FindingsLifecycleBackfillScope::singleTenant(',
|
||||
],
|
||||
'forbidden' => [
|
||||
"config('tenantpilot.allow_admin_maintenance_actions'",
|
||||
'allow_admin_maintenance_actions',
|
||||
'OperationalControlActivation::',
|
||||
@ -23,12 +23,12 @@
|
||||
],
|
||||
[
|
||||
'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php',
|
||||
'required' => [],
|
||||
'forbidden' => [
|
||||
'required' => [
|
||||
'FindingsLifecycleBackfillRunbookService',
|
||||
'FindingsLifecycleBackfillScope',
|
||||
'findings.lifecycle.backfill',
|
||||
'Rebuild Findings Lifecycle',
|
||||
'OperationalControlBlockedException',
|
||||
'$runbookService->start(',
|
||||
],
|
||||
'forbidden' => [
|
||||
'OperationalControlActivation::',
|
||||
"config('tenantpilot.allow_admin_maintenance_actions'",
|
||||
],
|
||||
@ -66,16 +66,4 @@
|
||||
expect($source)->not->toContain($needle);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
$root.'/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php',
|
||||
$root.'/app/Console/Commands/TenantpilotRunDeployRunbooks.php',
|
||||
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php',
|
||||
$root.'/app/Services/Runbooks/FindingsLifecycleBackfillScope.php',
|
||||
$root.'/app/Jobs/BackfillFindingLifecycleJob.php',
|
||||
$root.'/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php',
|
||||
$root.'/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php',
|
||||
] as $removedPath) {
|
||||
expect(file_exists($removedPath))->toBeFalse("Removed findings lifecycle backfill artifact still exists: {$removedPath}");
|
||||
}
|
||||
})->group('surface-guard');
|
||||
})->group('surface-guard');
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Controls;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationalControls\OperationalControlCatalog;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
});
|
||||
|
||||
it('does not expose findings lifecycle backfill in operational controls or the control catalog', function (): void {
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
$this->get(Controls::getUrl(panel: 'system'))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Findings lifecycle backfill')
|
||||
->assertDontSee("mountAction('pause_findings_lifecycle_backfill')", escape: false)
|
||||
->assertDontSee("mountAction('resume_findings_lifecycle_backfill')", escape: false)
|
||||
->assertDontSee("mountAction('view_history_findings_lifecycle_backfill')", escape: false);
|
||||
|
||||
$catalog = app(OperationalControlCatalog::class);
|
||||
|
||||
expect($catalog->keys())->not->toContain('findings.lifecycle.backfill')
|
||||
->and(fn (): array => $catalog->definition('findings.lifecycle.backfill'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
'name' => 'Platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not crash when audit logging fails and still finalizes a failed run', function () {
|
||||
$this->mock(AuditLogger::class, function ($mock): void {
|
||||
$mock->shouldReceive('log')->andThrow(new RuntimeException('audit unavailable'));
|
||||
});
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
$runbook = app(FindingsLifecycleBackfillRunbookService::class);
|
||||
|
||||
$run = $runbook->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: $user,
|
||||
reason: null,
|
||||
source: 'system_ui',
|
||||
);
|
||||
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$runs->updateRun(
|
||||
$run,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
failures: [
|
||||
[
|
||||
'code' => 'test.failed',
|
||||
'message' => 'Forced failure for audit fail-safe test.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$runbook->maybeFinalize($run);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('failed');
|
||||
});
|
||||
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Dashboard;
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
'name' => 'Platform',
|
||||
]);
|
||||
|
||||
config()->set('tenantpilot.break_glass.enabled', true);
|
||||
config()->set('tenantpilot.break_glass.ttl_minutes', 15);
|
||||
});
|
||||
|
||||
it('requires a reason when break-glass is active and records break-glass on the run + audit', function () {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$customerTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $customerTenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
PlatformCapabilities::USE_BREAK_GLASS,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->callAction('enter_break_glass', data: [
|
||||
'reason' => 'Recovery test',
|
||||
])
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
|
||||
'tenant_id' => (int) $customerTenant->getKey(),
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [])
|
||||
->assertHasActionErrors(['reason_code', 'reason_text']);
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
|
||||
'tenant_id' => (int) $customerTenant->getKey(),
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [
|
||||
'reason_code' => 'INCIDENT',
|
||||
'reason_text' => 'Break-glass backfill required',
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified();
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('type', 'findings.lifecycle.backfill')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect((int) $run?->tenant_id)->toBe((int) $customerTenant->getKey());
|
||||
expect(data_get($run?->context, 'platform_initiator.is_break_glass'))->toBeTrue();
|
||||
expect(data_get($run?->context, 'reason.reason_code'))->toBe('INCIDENT');
|
||||
expect(data_get($run?->context, 'reason.reason_text'))->toBe('Break-glass backfill required');
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', 'platform.ops.runbooks.start')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
expect($audit?->metadata['is_break_glass'] ?? null)->toBe(true);
|
||||
expect($audit?->metadata['reason_code'] ?? null)->toBe('INCIDENT');
|
||||
expect($audit?->metadata['reason_text'] ?? null)->toBe('Break-glass backfill required');
|
||||
});
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
'name' => 'Platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is idempotent: after a successful run, preflight reports nothing to do', function () {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$runbook = app(FindingsLifecycleBackfillRunbookService::class);
|
||||
|
||||
$initial = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()));
|
||||
|
||||
expect($initial['affected_count'])->toBe(1);
|
||||
|
||||
$runbook->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: null,
|
||||
reason: null,
|
||||
source: 'system_ui',
|
||||
);
|
||||
|
||||
$job = new BackfillFindingLifecycleJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: null,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
app(OperationRunService::class),
|
||||
app(\App\Services\Findings\FindingSlaPolicy::class),
|
||||
$runbook,
|
||||
);
|
||||
|
||||
$after = $runbook->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()));
|
||||
|
||||
expect($after['affected_count'])->toBe(0);
|
||||
|
||||
expect(fn () => $runbook->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: null,
|
||||
reason: null,
|
||||
source: 'system_ui',
|
||||
))->toThrow(ValidationException::class);
|
||||
});
|
||||
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
'name' => 'Platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('computes single-tenant preflight counts', function () {
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$service = app(FindingsLifecycleBackfillRunbookService::class);
|
||||
|
||||
$result = $service->preflight(FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()));
|
||||
|
||||
expect($result['total_count'])->toBe(2);
|
||||
expect($result['affected_count'])->toBe(1);
|
||||
});
|
||||
|
||||
it('computes all-tenants preflight counts scoped to the platform workspace', function () {
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenantA = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'sla_days' => null,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $otherTenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(FindingsLifecycleBackfillRunbookService::class);
|
||||
|
||||
$result = $service->preflight(FindingsLifecycleBackfillScope::allTenants());
|
||||
|
||||
expect($result['estimated_tenants'])->toBe(2);
|
||||
expect($result['total_count'])->toBe(2);
|
||||
expect($result['affected_count'])->toBe(2);
|
||||
});
|
||||
|
||||
it('accepts an allowed single-tenant selection during preflight', function () {
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->assertSet('findingsTenantId', (int) $tenant->getKey())
|
||||
->assertSet('preflight.affected_count', 1);
|
||||
});
|
||||
|
||||
it('rejects platform tenant selection during preflight', function () {
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
assertScopedSelectorRejected(
|
||||
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class),
|
||||
'preflight',
|
||||
[
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
|
||||
'tenant_id' => (int) $platformTenant->getKey(),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('resets to an all-tenant trusted scope even when stale single-tenant selector state remains on the page', function () {
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenantA = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
'name' => 'Scope Tenant A',
|
||||
]);
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
'name' => 'Scope Tenant B',
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class)
|
||||
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->set('findingsTenantId', (int) $tenantA->getKey())
|
||||
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->set('tenantId', (int) $tenantA->getKey())
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->assertSet('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||
->assertSet('findingsTenantId', null)
|
||||
->assertSet('scopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
||||
->assertSet('tenantId', null)
|
||||
->assertSet('preflight.estimated_tenants', 2)
|
||||
->assertSet('preflight.affected_count', 2);
|
||||
});
|
||||
@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
'name' => 'Platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables running when preflight indicates nothing to do', function () {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
|
||||
])
|
||||
->assertSet('preflight.affected_count', 0)
|
||||
->assertActionDisabled('run');
|
||||
});
|
||||
|
||||
it('requires typed confirmation and a reason for all-tenants runs', function () {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [])
|
||||
->assertHasActionErrors([
|
||||
'typed_confirmation',
|
||||
'reason_code',
|
||||
'reason_text',
|
||||
]);
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [
|
||||
'typed_confirmation' => 'backfill',
|
||||
'reason_code' => 'DATA_REPAIR',
|
||||
'reason_text' => 'Test run',
|
||||
])
|
||||
->assertHasActionErrors(['typed_confirmation']);
|
||||
});
|
||||
|
||||
it('rejects forged single-tenant selector state on run and records no run or start audit', function () {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$allowedTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $allowedTenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
|
||||
'tenant_id' => (int) $allowedTenant->getKey(),
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->set('findingsTenantId', (int) $platformTenant->getKey())
|
||||
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->set('tenantId', (int) $platformTenant->getKey())
|
||||
->callAction('run', data: [])
|
||||
->assertHasActionErrors();
|
||||
|
||||
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
|
||||
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('records a start audit with the canonical single-tenant scope when an allowed run is queued', function () {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [])
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified('Findings lifecycle backfill queued');
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('type', 'findings.lifecycle.backfill')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', 'platform.ops.runbooks.start')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->resource_id)->toBe((string) $run?->getKey())
|
||||
->and($audit?->metadata['scope'] ?? null)->toBe(FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
||||
->and($audit?->metadata['target_tenant_id'] ?? null)->toBe((int) $tenant->getKey())
|
||||
->and($audit?->metadata['operation_run_id'] ?? null)->toBe((int) $run?->getKey());
|
||||
});
|
||||
|
||||
it('returns 403 for runbook execution when the platform user is in scope but lacks run capability', function () {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [])
|
||||
->assertForbidden();
|
||||
|
||||
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
|
||||
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
|
||||
});
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationalControlActivation;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
'name' => 'Platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks all-tenant findings lifecycle runbooks when the control is globally paused', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
OperationalControlActivation::factory()->forGlobalScope()->create([
|
||||
'control_key' => 'findings.lifecycle.backfill',
|
||||
'reason_text' => 'Paused during incident response.',
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => 'all_tenants',
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [
|
||||
'typed_confirmation' => 'BACKFILL',
|
||||
'reason_code' => 'DATA_REPAIR',
|
||||
'reason_text' => 'Attempt blocked by control',
|
||||
])
|
||||
->assertNotified('Findings lifecycle backfill paused');
|
||||
|
||||
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->workspace_id)->toBeNull()
|
||||
->and($audit?->tenant_id)->toBeNull()
|
||||
->and($audit?->status)->toBe('blocked')
|
||||
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
|
||||
->and($audit?->metadata['requested_scope'] ?? null)->toBe('all_tenants');
|
||||
});
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
'name' => 'Platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses an intent-only toast with a working view-run link and does not emit queued database notifications', function () {
|
||||
Queue::fake();
|
||||
NotificationFacade::fake();
|
||||
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $platformTenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => null,
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->callAction('preflight', data: [
|
||||
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
|
||||
])
|
||||
->assertSet('preflight.affected_count', 1)
|
||||
->callAction('run', data: [
|
||||
'typed_confirmation' => 'BACKFILL',
|
||||
'reason_code' => 'DATA_REPAIR',
|
||||
'reason_text' => 'Operator test',
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->assertNotified('Findings lifecycle backfill queued');
|
||||
|
||||
NotificationFacade::assertNothingSent();
|
||||
expect(DatabaseNotification::query()->count())->toBe(0);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('type', 'findings.lifecycle.backfill')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
|
||||
$viewUrl = SystemOperationRunLinks::view($run);
|
||||
|
||||
$this->get($viewUrl)
|
||||
->assertSuccessful()
|
||||
->assertSee('Operation #'.(int) $run?->getKey());
|
||||
});
|
||||
@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runbooks;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
});
|
||||
|
||||
it('keeps the system runbooks page accessible without findings lifecycle backfill launch surfaces', function (): void {
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform');
|
||||
|
||||
$this->get(Runbooks::getUrl(panel: 'system'))
|
||||
->assertSuccessful()
|
||||
->assertSee('No supported runbooks')
|
||||
->assertDontSee('Rebuild Findings Lifecycle')
|
||||
->assertDontSee('Backfills legacy findings lifecycle fields')
|
||||
->assertDontSee('Preflight')
|
||||
->assertDontSee('preflight')
|
||||
->assertDontSee('Run: Rebuild Findings Lifecycle')
|
||||
->assertDontSee('BACKFILL');
|
||||
|
||||
Livewire::test(Runbooks::class)
|
||||
->assertActionDoesNotExist('preflight')
|
||||
->assertActionDoesNotExist('run');
|
||||
});
|
||||
|
||||
it('preserves runbooks view authorization semantics after removing the backfill runbook', function (): void {
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'platform')
|
||||
->get(Runbooks::getUrl(panel: 'system'))
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -164,25 +164,6 @@ 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
|
||||
{
|
||||
$configuredRoot = env('TENANTATLAS_REPO_ROOT');
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
|
||||
it('removes the platform capability and seeder grant for findings lifecycle backfill', function (): void {
|
||||
expect(defined(PlatformCapabilities::class.'::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL'))->toBeFalse()
|
||||
->and(PlatformCapabilities::all())->not->toContain('platform.runbooks.findings.lifecycle_backfill');
|
||||
|
||||
expect((string) file_get_contents(database_path('seeders/PlatformUserSeeder.php')))
|
||||
->not->toContain('RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL')
|
||||
->not->toContain('platform.runbooks.findings.lifecycle_backfill');
|
||||
});
|
||||
@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
|
||||
it('removes findings lifecycle backfill from the supported operation catalog', function (): void {
|
||||
expect(OperationCatalog::canonicalInventory())->not->toHaveKey('findings.lifecycle.backfill')
|
||||
->and(OperationCatalog::aliasInventory())->not->toHaveKey('findings.lifecycle.backfill')
|
||||
->and(OperationCatalog::rawValuesForCanonical('findings.lifecycle.backfill'))->toBe([])
|
||||
->and(OperationCatalog::label('findings.lifecycle.backfill'))->toBe('Unknown operation');
|
||||
});
|
||||
@ -473,6 +473,7 @@ ### Deployment: Dokploy (staging → production)
|
||||
|
||||
### Platform runbooks
|
||||
|
||||
- `FindingsLifecycleBackfillRunbookService` ([app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php](app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php)) — safe backfill of findings lifecycle fields
|
||||
- Accessible at `/system/ops/runbooks` with platform capabilities
|
||||
|
||||
---
|
||||
|
||||
@ -15,7 +15,7 @@ ## Purpose
|
||||
|
||||
## 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. 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.
|
||||
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.
|
||||
|
||||
## 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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
|
||||
|
||||
- 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; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten.
|
||||
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer.
|
||||
- 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.
|
||||
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
|
||||
@ -179,9 +179,6 @@ ## 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@ -194,9 +191,6 @@ ## 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 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 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.
|
||||
|
||||
@ -3,7 +3,7 @@ # Spec Candidates
|
||||
> 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.
|
||||
|
||||
> **Last reviewed**: 2026-04-28
|
||||
> **Last reviewed**: 2026-04-27
|
||||
> **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
|
||||
|
||||
---
|
||||
@ -138,94 +138,6 @@ ### Localization v1
|
||||
- locale-aware formatting does not affect audit or export truth
|
||||
- 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
|
||||
|
||||
### Commercial Entitlements and Billing-State Maturity
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
@ -1,177 +0,0 @@
|
||||
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'
|
||||
@ -1,65 +0,0 @@
|
||||
# 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
|
||||
@ -1,287 +0,0 @@
|
||||
# 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.
|
||||
@ -1,39 +0,0 @@
|
||||
# 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.
|
||||
@ -1,51 +0,0 @@
|
||||
# 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`.
|
||||
@ -1,319 +0,0 @@
|
||||
# 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
|
||||
@ -1,187 +0,0 @@
|
||||
---
|
||||
|
||||
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.
|
||||
@ -1,48 +0,0 @@
|
||||
# Specification Quality Checklist: Remove Findings Lifecycle Backfill Runtime Surfaces
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-28
|
||||
**Feature**: specs/253-remove-findings-backfill-runtime-surfaces/spec.md
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No language/framework/API design leakage; concrete repo surfaces, commands, and labels are named only because this cleanup deletes those exact shipped traces.
|
||||
- [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 unintended implementation design leakage remains beyond the explicit cleanup special-case for named repo-visible traces
|
||||
|
||||
## Test Governance Review
|
||||
|
||||
- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, plus one retained `heavy-governance` guard in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` so operational-control bypass residue cannot survive the cleanup silently.
|
||||
- [x] No new browser or heavy-governance family is introduced; the retained guard stays explicit, bounded, and tied to operational-control source-trace removal only.
|
||||
- [x] Suite-cost outcome is net-negative: backfill-only tests, lane traces, and helper residue are removed in the same slice instead of widening shared defaults.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [x] Review outcome class: `acceptable-special-case`
|
||||
- [x] Workflow outcome: `keep`
|
||||
- [x] Review-note location is explicit: the heavy-governance retention note lives in `spec.md`, `plan.md`, `tasks.md`, and the final preparation report.
|
||||
|
||||
## Notes
|
||||
|
||||
- The spec intentionally names concrete routes, commands, labels, and catalog keys because the product value of this slice is the removal of those specific repo-visible runtime surfaces.
|
||||
- The slice stays small by deleting visible repair tooling only; acknowledged-status cleanup and creation-time invariant hardening remain explicit follow-up candidates.
|
||||
- Validation pass complete: no clarification markers remain, LEAN-001 cleanup posture is explicit, and tenant-owned findings continue to treat `workspace_id` plus `tenant_id` as required anchors.
|
||||
@ -1,108 +0,0 @@
|
||||
version: 1
|
||||
kind: findings-backfill-runtime-surface-removal
|
||||
|
||||
scope:
|
||||
goal: remove findings lifecycle backfill runtime and product surfaces only
|
||||
non_goals:
|
||||
- acknowledged-status semantics cleanup
|
||||
- creation-time finding invariant hardening
|
||||
- replacement repair surface
|
||||
- historical data migration
|
||||
- compatibility alias or no-op command preservation
|
||||
|
||||
removed_entry_points:
|
||||
system_runbooks:
|
||||
route: /system/ops/runbooks
|
||||
removed_labels:
|
||||
- Rebuild Findings Lifecycle
|
||||
- Preflight
|
||||
- Run…
|
||||
owner_files:
|
||||
- apps/platform/app/Filament/System/Pages/Ops/Runbooks.php
|
||||
- apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php
|
||||
tenant_findings:
|
||||
route: /admin/t/{tenant}/findings
|
||||
removed_labels:
|
||||
- Backfill findings lifecycle
|
||||
- Open operation
|
||||
owner_files:
|
||||
- apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php
|
||||
- apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php
|
||||
console:
|
||||
removed_commands:
|
||||
- tenantpilot:findings:backfill-lifecycle
|
||||
- tenantpilot:run-deploy-runbooks
|
||||
owner_files:
|
||||
- apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php
|
||||
- apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php
|
||||
deploy_runtime:
|
||||
requirement:
|
||||
- no deploy hook, runtime hook, schedule, or bootstrap path may queue or start findings lifecycle backfill after cleanup
|
||||
|
||||
removed_runtime_cluster:
|
||||
service:
|
||||
- apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php
|
||||
jobs:
|
||||
- apps/platform/app/Jobs/BackfillFindingLifecycleJob.php
|
||||
- apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php
|
||||
- apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php
|
||||
operation_type:
|
||||
- findings.lifecycle.backfill
|
||||
|
||||
removed_registry_traces:
|
||||
capabilities:
|
||||
- platform.runbooks.findings.lifecycle_backfill
|
||||
seeders:
|
||||
- apps/platform/database/seeders/PlatformUserSeeder.php
|
||||
operation_catalog:
|
||||
canonical_types:
|
||||
- findings.lifecycle.backfill
|
||||
aliases:
|
||||
- findings.lifecycle.backfill
|
||||
system_console_triage:
|
||||
retryable_types:
|
||||
- findings.lifecycle.backfill
|
||||
cancelable_types:
|
||||
- findings.lifecycle.backfill
|
||||
operational_controls:
|
||||
notes:
|
||||
- the active operational-control catalog already rejects findings.lifecycle.backfill
|
||||
- remaining blocked-start branches and tests for that key must be removed rather than normalized
|
||||
|
||||
retained_behavior:
|
||||
findings_workflow_actions:
|
||||
- triage
|
||||
- start_progress
|
||||
- assign
|
||||
- resolve
|
||||
- close
|
||||
- request_exception
|
||||
- reopen
|
||||
guarantees:
|
||||
- findings workflow status, ownership, SLA, due-date, and reviewable behavior remain unchanged
|
||||
- no replacement repair or maintenance surface is introduced
|
||||
|
||||
legacy_data_posture:
|
||||
operation_runs:
|
||||
historical rows may remain stored without new canonical alias, retry, cancel, or operator-UX guarantee
|
||||
audit_logs:
|
||||
historical start, blocked, completed, or failed rows may remain stored without migration or compatibility layer
|
||||
|
||||
validation_expectations:
|
||||
no_new_side_effects:
|
||||
- no supported surface may create a new OperationRun of type findings.lifecycle.backfill
|
||||
- no supported surface may dispatch the deleted backfill jobs
|
||||
absence_proof:
|
||||
- system runbooks page exposes no findings lifecycle backfill card or action
|
||||
- tenant findings page exposes no findings lifecycle backfill header action
|
||||
- supported command catalog exposes no findings lifecycle backfill command entry
|
||||
- operational-control and operation-label surfaces expose no live findings lifecycle backfill trace
|
||||
regression_proof:
|
||||
- representative canonical findings workflow actions still succeed unchanged
|
||||
lane_classification:
|
||||
required:
|
||||
- fast-feedback
|
||||
- confidence
|
||||
- heavy-governance
|
||||
excluded:
|
||||
- browser
|
||||
@ -1,121 +0,0 @@
|
||||
# Data Model — Remove Findings Lifecycle Backfill Runtime Surfaces
|
||||
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
This feature is subtractive. It introduces no new persisted truth and no migration. The data-model impact is the removal of one obsolete runtime family and the reaffirmation of the canonical findings workflow as the only supported path.
|
||||
|
||||
## Existing Canonical Entities Reused
|
||||
|
||||
### Finding (`findings`)
|
||||
|
||||
**Purpose**: Tenant-owned findings workflow truth.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `status`
|
||||
- `triaged_at`
|
||||
- `first_seen_at`
|
||||
- `last_seen_at`
|
||||
- `times_seen`
|
||||
- `sla_days`
|
||||
- `due_at`
|
||||
|
||||
**Feature use**:
|
||||
- Remains the canonical workflow truth for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable behavior.
|
||||
- Continues to require both `workspace_id` and `tenant_id` as non-null ownership anchors.
|
||||
- Is in scope only for regression protection, not for lifecycle redesign.
|
||||
|
||||
### OperationRun (`operation_runs`)
|
||||
|
||||
**Purpose**: Existing canonical execution truth for supported long-running operations.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `context`
|
||||
|
||||
**Feature use**:
|
||||
- After cleanup, no supported system, tenant, CLI, or deploy/runtime path may create a new `OperationRun` with `type = findings.lifecycle.backfill`.
|
||||
- Historical rows may remain stored as legacy data, but the feature does not preserve special retry, cancel, label, or alias handling for them.
|
||||
|
||||
### AuditLog (`audit_logs`)
|
||||
|
||||
**Purpose**: Existing audit truth for prior lifecycle-backfill starts, blocked starts, and completions.
|
||||
|
||||
**Feature use**:
|
||||
- No new audit action family is introduced.
|
||||
- Historical rows may remain stored without new cleanup migration or compatibility layer.
|
||||
- Canonical findings workflow audit behavior remains unchanged and is protected through regression testing.
|
||||
|
||||
### OperationalControlActivation (`operational_control_activations`)
|
||||
|
||||
**Purpose**: Existing runtime-safety truth for live operational controls.
|
||||
|
||||
**Feature use**:
|
||||
- The cleanup should not add or preserve a `findings.lifecycle.backfill` control key.
|
||||
- Existing backfill-specific blocked-start branches and tests should be removed because the active control catalog already rejects the key.
|
||||
|
||||
## Removed Runtime Families
|
||||
|
||||
### FindingsLifecycleBackfillSurface (derived, non-persisted)
|
||||
|
||||
**Purpose**: Describes each currently productized entry point that must disappear in the cleanup.
|
||||
|
||||
**Runtime fields**:
|
||||
- `surface_id` — unique identifier such as `system.ops.runbooks`, `tenant.findings.list`, `console.tenantpilot.findings.backfill-lifecycle`, or `console.tenantpilot.run-deploy-runbooks`
|
||||
- `entry_type` — `runbook`, `header_action`, `command`, `deploy_hook`, `operation_label`, `capability_trace`, or `test_trace`
|
||||
- `operator_label` — current visible product label such as `Rebuild Findings Lifecycle` or `Backfill findings lifecycle`
|
||||
- `owner_path` — current source file that makes the surface real
|
||||
- `start_seam` — shared service or registry seam that currently powers the entry point
|
||||
|
||||
**Feature use**:
|
||||
- Drives removal planning so the cleanup deletes the source of truth for each surface instead of only hiding one page affordance.
|
||||
|
||||
### FindingsLifecycleBackfillExecutionCluster (derived, non-persisted)
|
||||
|
||||
**Purpose**: The dedicated runtime chain that currently starts, queues, and finalizes lifecycle backfill.
|
||||
|
||||
**Current members**:
|
||||
- `FindingsLifecycleBackfillRunbookService`
|
||||
- `TenantpilotBackfillFindingLifecycle`
|
||||
- `TenantpilotRunDeployRunbooks`
|
||||
- `BackfillFindingLifecycleJob`
|
||||
- `BackfillFindingLifecycleWorkspaceJob`
|
||||
- `BackfillFindingLifecycleTenantIntoWorkspaceRunJob`
|
||||
|
||||
**Lifecycle rule**:
|
||||
- The cluster is deleted in the same slice. No dormant flag, replacement command, or service shim is retained.
|
||||
|
||||
### FindingsLifecycleBackfillTrace (derived, non-persisted)
|
||||
|
||||
**Purpose**: Registry, catalog, seed, test, and doc references that still advertise lifecycle backfill as supported behavior.
|
||||
|
||||
**Trace fields**:
|
||||
- `trace_type` — `capability`, `seeder`, `operation_type`, `operation_alias`, `triage_support`, `control_branch`, `test`, `guard`, or `doc`
|
||||
- `identifier` — exact key such as `platform.runbooks.findings.lifecycle_backfill` or `findings.lifecycle.backfill`
|
||||
- `owner_path` — file that currently carries the trace
|
||||
- `removal_reason` — why the trace must disappear with the runtime surface
|
||||
|
||||
**Feature use**:
|
||||
- Ensures cleanup removes registry and test ballast in the same slice instead of leaving the repo to advertise deleted behavior indirectly.
|
||||
|
||||
## Data Ownership Notes
|
||||
|
||||
- No new tables, settings, or persisted aliases are introduced.
|
||||
- No migration, historical data rewrite, or archival compatibility layer is planned.
|
||||
- Historical `OperationRun` and `AuditLog` rows are tolerated legacy data and do not justify preserving the removed runtime path.
|
||||
- Findings remain tenant-owned and continue to require both `workspace_id` and `tenant_id` as canonical ownership anchors.
|
||||
- Operational-control truth remains bounded to currently supported controls only; this slice should not keep a removed backfill control key alive through hidden test fixtures or service branches.
|
||||
|
||||
## Removal Invariants
|
||||
|
||||
- No supported path may create a new `OperationRun` with `type = findings.lifecycle.backfill`.
|
||||
- No supported page, command catalog, or deploy/runtime hook may advertise lifecycle backfill as an available operator action.
|
||||
- No compatibility shim, no-op command shell, or fallback alias may remain for the removed path.
|
||||
- Canonical findings workflow behavior remains unchanged and continues to operate on the existing `Finding` truth.
|
||||
@ -1,237 +0,0 @@
|
||||
# Implementation Plan: Remove Findings Lifecycle Backfill Runtime Surfaces
|
||||
|
||||
**Branch**: `253-remove-findings-backfill-runtime-surfaces` | **Date**: 2026-04-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
- Remove all shipped findings lifecycle backfill runtime and product surfaces by deleting the owning backfill service, jobs, commands, and registry traces instead of hiding labels locally.
|
||||
- Preserve the normal findings workflow exactly as-is for triage, assignment, progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable finding behavior, with regression proof kept explicit.
|
||||
- Keep the slice narrow: acknowledged-status cleanup and creation-time lifecycle invariant hardening remain separate follow-up specs, and OperationRun semantics are touched only by removing one obsolete runbook path rather than inventing a new run UX abstraction.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
|
||||
**Storage**: PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned
|
||||
**Testing**: Pest feature tests plus narrow unit and guard coverage
|
||||
**Validation Lanes**: fast-feedback, confidence, heavy-governance
|
||||
**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` surfaces, platform `/system` surfaces, and Artisan command/runtime entry points
|
||||
**Project Type**: web
|
||||
**Performance Goals**: after cleanup, no supported surface may enqueue or start `findings.lifecycle.backfill`; surviving findings workflows retain their current performance profile; the slice should reduce runtime and test surface rather than add new overhead
|
||||
**Constraints**: LEAN-001 replacement over compatibility shims; no replacement repair surface; no findings semantics redesign; no data migration; preserve current `404` vs `403` isolation semantics; no new Filament panel/provider work; no new assets or `filament:assets` deploy changes
|
||||
**Scale/Scope**: 1 cleanup slice touching 3 operator-facing surfaces, 2 console entry points, 1 shared runbook service cluster, 3 dedicated jobs, capability/operation/triage traces, and the backfill-specific test and docs footprint
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament plus existing shared action and run UX primitives
|
||||
- **Shared-family relevance**: header actions, runbook launch cards, operation labeling, operational-control and paused-state messaging, command/runtime entry points
|
||||
- **State layers in scope**: page, action/modal, detail/read-model
|
||||
- **Audience modes in scope**: operator-MSP, support-platform
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first / diagnostics-second / support-raw-third
|
||||
- **Raw/support gating plan**: existing capability-gated diagnostics only; no new raw or support surface is introduced
|
||||
- **One-primary-action / duplicate-truth control**: remove the maintenance CTA so tenant findings pages keep only canonical findings workflow actions and system pages keep only supported runbooks and supported controls
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none; source-trace removal is mandatory wherever shared registries or helper paths still emit findings backfill semantics
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `App\Filament\System\Pages\Ops\Runbooks`, `App\Filament\Resources\FindingResource\Pages\ListFindings`, `App\Console\Commands\TenantpilotBackfillFindingLifecycle`, `App\Console\Commands\TenantpilotRunDeployRunbooks`, `App\Services\Runbooks\FindingsLifecycleBackfillRunbookService`, `App\Services\Runbooks\FindingsLifecycleBackfillScope`, `App\Jobs\BackfillFindingLifecycleJob`, `App\Jobs\BackfillFindingLifecycleWorkspaceJob`, `App\Jobs\BackfillFindingLifecycleTenantIntoWorkspaceRunJob`, `App\Support\OperationCatalog`, `App\Support\Auth\PlatformCapabilities`, `App\Services\SystemConsole\OperationRunTriageService`, `App\Support\Livewire\TrustedState\TrustedStatePolicy`, `App\Support\Ui\ActionSurface\ActionSurfaceExemptions`, `Database\Seeders\PlatformUserSeeder`, and the related findings/runbook/console/control tests
|
||||
- **Shared abstractions reused**: `UiEnforcement`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `OperationRunLinks`, `SystemOperationRunLinks`, `OperationRunService`, `OperationCatalog`, `AuditRecorder`, and `WorkspaceAuditLogger`
|
||||
- **New abstraction introduced? why?**: none; this is a subtractive cleanup that removes one bounded operation family and its traces
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the existing shared abstractions are sufficient for surviving findings workflows and surviving operations. The cleanup must converge those shared families back to supported product truth instead of layering a replacement backfill path or compatibility wrapper on top.
|
||||
- **Bounded deviation / spread control**: none; where a shared catalog, capability registry, triage list, or test helper still names `findings.lifecycle.backfill`, the source trace itself must be removed instead of locally masked
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Central contract reused**: existing shared OperationRun UX layer for surviving operation types only
|
||||
- **Delegated UX behaviors**: `N/A` for the removed findings lifecycle backfill path after cleanup; queued toast, `View run` or `Open operation` links, dedupe messaging, browser events, and terminal notifications remain unchanged for every other operation type
|
||||
- **Surface-owned behavior kept local**: none for the removed path
|
||||
- **Queued DB-notification policy**: `N/A` for the removed path
|
||||
- **Terminal notification path**: existing central lifecycle mechanism for surviving operations remains unchanged
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: `N/A`
|
||||
- **Platform-core seams**: `N/A`
|
||||
- **Neutral platform terms / contracts preserved**: `N/A`
|
||||
- **Retained provider-specific semantics and why**: none
|
||||
- **Bounded extraction or follow-up path**: `N/A`
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Read/write separation: PASS - the slice removes shipped write entry points and introduces no new mutating path
|
||||
- Inventory-first / snapshots-second: PASS - inventory, backups, and snapshots are untouched
|
||||
- Graph contract path: PASS - no Microsoft Graph surface changes are involved
|
||||
- Deterministic capabilities: PASS - one platform capability constant and its seeder grant are removed rather than expanded; no new capability namespace is introduced
|
||||
- RBAC-UX: PASS - `/system` remains platform-only, `/admin/t/{tenant}` remains tenant-scoped, non-members stay `404`, in-scope users missing capability on surviving actions stay `403`, and cleanup must not widen or hide unrelated authorization semantics
|
||||
- Workspace isolation / tenant isolation: PASS - findings remain tenant-owned with required `workspace_id` and `tenant_id` anchors; no new cross-tenant surface or leakage path is introduced
|
||||
- OperationRun observability / Ops-UX: PASS - the feature removes one `OperationRun` start surface only; no new run type, no new feedback surface, and no local start UX dialect are introduced
|
||||
- OperationRun lifecycle ownership: PASS - no new lifecycle transition path is introduced; surviving operations remain service-owned
|
||||
- Automation / locking: PASS - queued backfill runtime paths are deleted rather than extended
|
||||
- Data minimization: PASS - no new persisted data or audit payload family is introduced
|
||||
- Test governance (`TEST-GOV-001`): PASS - proof remains in narrow feature plus unit lanes, with one retained heavy-governance source-scanning guard kept explicit because operational-control bypass residue must still be blocked after the control key and runbook service are removed
|
||||
- Proportionality (`PROP-001`) and no premature abstraction (`ABSTR-001`): PASS - the feature removes an obsolete runtime family and adds no new abstraction layer
|
||||
- Persisted truth (`PERSIST-001`): PASS - no new table, entity, artifact, or alias layer is introduced
|
||||
- Behavioral state (`STATE-001`): PASS - no new status or reason family is added; acknowledged cleanup remains a separate follow-up
|
||||
- UI semantics (`UI-SEM-001`): PASS - no new presentation taxonomy is added; labels disappear together with the runtime path
|
||||
- Shared pattern first (`XCUT-001`): PASS - the cleanup converges shared runbook, action, audit, and operation-label families by deletion instead of creating a parallel exception path
|
||||
- Provider boundary (`PROV-001`): PASS - no provider/platform seam changes are introduced
|
||||
- V1 explicitness / few layers (`V1-EXP-001`, `LAYER-001`): PASS - the narrowest solution is direct replacement and deletion, not shims or wrappers
|
||||
- Bloat check (`SPEC-DISC-001`, `BLOAT-001`): PASS - the slice is explicitly subtractive and keeps broader semantics work separate
|
||||
- Filament-native UI (`UI-FIL-001`): PASS - touched surfaces stay native Filament pages and actions; no custom UI framework or asset registration is needed
|
||||
- Global search rule: PASS - no new searchable resource is added, and this cleanup only removes a header action from the existing findings resource rather than changing its search contract
|
||||
- Panel/provider registration: PASS - Filament v5 remains on Livewire v4, and no panel or provider registration change is planned; Laravel 12 provider registration remains in `bootstrap/providers.php` if ever needed later
|
||||
- Asset strategy: PASS - no new panel or shared assets are planned, so no additional `filament:assets` deploy work is introduced
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature for system runbook removal, tenant findings action removal, console-entry removal, and findings workflow regression; Unit or guard coverage for operation catalog, capability, triage-list, and source-scanning trace cleanup
|
||||
- **Affected validation lanes**: fast-feedback, confidence, heavy-governance
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the business truth is server-side surface removal plus unchanged canonical findings workflow behavior. Fast-feedback and confidence cover the runtime behavior directly. One retained heavy-governance source-scanning guard is still needed because the cleanup removes an operational-control key and runbook-service entry point that the existing guard already protects from ad-hoc bypass drift.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: remove or collapse backfill-only fixtures, control activations, and command test setup; keep findings workflow fixtures opt-in and local to regression tests
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; expected net-negative because a dedicated backfill family and its source-scanning guard expectations should shrink
|
||||
- **Heavy-family additions, promotions, or visibility changes**: no new heavy family is added, but one existing heavy-governance guard remains explicit in the plan because the cleanup still depends on `tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament and monitoring-state-page relief are sufficient; assert absence and no side effects rather than browser-only choreography
|
||||
- **Closing validation and reviewer handoff**: reviewers should rerun the targeted commands, including the retained heavy-governance bypass guard, verify that no UI, CLI, deploy, control, catalog, capability, triage, trusted-state, or action-surface trace remains for `findings.lifecycle.backfill`, and confirm representative triage, assignment, progress, resolve, risk acceptance, ownership, SLA, and due-date findings flows still behave unchanged
|
||||
- **Budget / baseline / trend follow-up**: expected net decrease in focused feature and guard surface
|
||||
- **Review-stop questions**: did implementation leave a no-op compatibility shell, keep a hidden operation alias, preserve a dead blocked-state branch after the control key was already removed, or widen into acknowledged-status cleanup or lifecycle invariant redesign?
|
||||
- **Escalation path**: reject-or-split
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: the cleanup is a bounded subtractive slice; deeper findings semantics and creation-time invariant work already have explicit follow-up candidates instead of hidden spillover
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/253-remove-findings-backfill-runtime-surfaces/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── contracts/
|
||||
│ └── findings-backfill-runtime-surface-removal.contract.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Console/Commands/
|
||||
│ │ ├── TenantpilotBackfillFindingLifecycle.php
|
||||
│ │ └── TenantpilotRunDeployRunbooks.php
|
||||
│ ├── Filament/
|
||||
│ │ ├── Resources/FindingResource/Pages/ListFindings.php
|
||||
│ │ └── System/Pages/Ops/Runbooks.php
|
||||
│ ├── Jobs/
|
||||
│ │ ├── BackfillFindingLifecycleJob.php
|
||||
│ │ ├── BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php
|
||||
│ │ └── BackfillFindingLifecycleWorkspaceJob.php
|
||||
│ ├── Services/
|
||||
│ │ ├── Runbooks/FindingsLifecycleBackfillRunbookService.php
|
||||
│ │ ├── Runbooks/FindingsLifecycleBackfillScope.php
|
||||
│ │ └── SystemConsole/OperationRunTriageService.php
|
||||
│ └── Support/
|
||||
│ ├── Auth/PlatformCapabilities.php
|
||||
│ ├── Livewire/TrustedState/TrustedStatePolicy.php
|
||||
│ ├── OperationCatalog.php
|
||||
│ └── Ui/ActionSurface/ActionSurfaceExemptions.php
|
||||
├── database/seeders/PlatformUserSeeder.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Console/Spec113/DeployRunbooksCommandTest.php
|
||||
│ ├── Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php
|
||||
│ ├── Findings/OperationalControlFindingsBackfillGateTest.php
|
||||
│ ├── OperationalControls/NoAdHocOperationalControlBypassTest.php
|
||||
│ ├── System/OpsControls/OperationalControlManagementTest.php
|
||||
│ └── System/OpsRunbooks/
|
||||
│ ├── FindingsLifecycleBackfillPreflightTest.php
|
||||
│ ├── FindingsLifecycleBackfillStartTest.php
|
||||
│ ├── OpsUxStartSurfaceContractTest.php
|
||||
│ └── OperationalControlRunbookGateTest.php
|
||||
└── Unit/Support/OperationalControls/OperationalControlCatalogTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. The implementation slice is subtractive and should stay inside the existing system page, tenant findings page, console command, shared runbook service, registry, and test directories instead of creating a new namespace or framework.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are expected. This feature should reduce permanent complexity by deleting a productized repair path, its queue jobs, and its trace surface.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: the repo still productizes a findings lifecycle repair path through runbooks, tenant findings actions, commands, operation labels, and tests even though current finding generators already write the required lifecycle fields directly
|
||||
- **Existing structure is insufficient because**: a local hide or feature flag would leave the service, jobs, commands, operation labels, triage support, and backfill-only tests alive, so the product would keep advertising deleted behavior through other surfaces
|
||||
- **Narrowest correct implementation**: delete the owning backfill service and job cluster, remove the UI and command entry points, remove capability and operation traces, and keep canonical findings workflows unchanged with targeted regression proof
|
||||
- **Ownership cost created**: negative; maintenance burden, suite cost, and operator confusion should all decrease
|
||||
- **Alternative intentionally rejected**: compatibility shims, no-op deploy command shells, historical alias preservation, or folding acknowledged-status cleanup and lifecycle invariant hardening into the same slice
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/research.md`
|
||||
|
||||
Goals:
|
||||
- Confirm the narrowest deletion boundary across system UI, tenant UI, CLI, deploy/runtime hooks, jobs, registry traces, and test artifacts.
|
||||
- Confirm that LEAN-001 requires removal over compatibility shims for the backfill service, commands, operation aliases, and historical run UX support.
|
||||
- Record the partial operational-control cleanup already present in the repo so implementation removes remaining dead branches instead of reintroducing the control key.
|
||||
- Keep acknowledged-status cleanup and creation-time lifecycle invariants explicitly deferred while documenting the regression contract for normal findings workflows.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/contracts/findings-backfill-runtime-surface-removal.contract.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`
|
||||
|
||||
Design focus:
|
||||
- Remove the `Rebuild Findings Lifecycle` system runbook card, preflight, run modal, and related `OperationRun` launch UX from `Runbooks.php`.
|
||||
- Remove the tenant findings header action `Backfill findings lifecycle` and its queued, paused, and `Open operation` messaging from `ListFindings.php` while leaving canonical findings workflow actions untouched.
|
||||
- Delete `TenantpilotBackfillFindingLifecycle`, and delete `TenantpilotRunDeployRunbooks` if its only shipped responsibility remains lifecycle backfill.
|
||||
- Delete `FindingsLifecycleBackfillRunbookService` and the dedicated workspace plus tenant jobs that exist only to support the removed runtime path.
|
||||
- Remove `findings.lifecycle.backfill` traces from `PlatformCapabilities`, `PlatformUserSeeder`, `OperationCatalog`, `OperationRunTriageService`, test guards, docs, and backfill-specific feature tests.
|
||||
- Remove `FindingsLifecycleBackfillScope.php`, the backfill-specific trusted-state markers in `TrustedStatePolicy.php`, and the backfill-specific action-surface evidence in `ActionSurfaceExemptions.php` so no hidden surface or helper residue still implies support.
|
||||
- Treat current operational-control residue as cleanup input: the control catalog already rejects `findings.lifecycle.backfill`, so remaining blocked-start branches and tests should be removed rather than normalized.
|
||||
- Keep historical `OperationRun` and `AuditLog` rows as tolerated legacy data without adding alias layers, migrations, or new UI promises.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
After Phase 1 artifacts are generated, update Copilot context from the plan:
|
||||
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
- Remove the system runbook entry points and the tenant findings header action for lifecycle backfill.
|
||||
- Delete the dedicated CLI and deploy/runtime command entry points for lifecycle backfill.
|
||||
- Delete the shared runbook service and the dedicated workspace or tenant backfill jobs.
|
||||
- Remove capability, seeder, operation-catalog, and system-console triage traces for `findings.lifecycle.backfill`.
|
||||
- Rewrite or delete backfill-only tests and docs, then add narrow absence plus regression coverage that proves the path stays gone while canonical findings workflows still work.
|
||||
- Verify no compatibility shim, no-op command shell, or replacement repair path survives the cleanup.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check target: PASS. The post-design shape must still be net-negative complexity, contain no new persistence or abstraction, preserve Livewire v4 plus Filament v5 conventions, leave provider registration unchanged in `bootstrap/providers.php`, keep global search behavior unchanged, and keep the validation burden inside fast-feedback and confidence plus one explicit retained heavy-governance bypass guard only.
|
||||
@ -1,36 +0,0 @@
|
||||
# Quickstart — Remove Findings Lifecycle Backfill Runtime Surfaces
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker running
|
||||
- Laravel Sail dependencies installed
|
||||
- Existing platform-operator and tenant-user factories available for targeted tests
|
||||
- Existing findings workflow fixtures available for regression coverage
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- No schema change is expected, but use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction`
|
||||
- Run targeted tests after implementation:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php`
|
||||
- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Manual smoke after implementation
|
||||
|
||||
1. Sign in to `/system` as a platform operator and confirm `/system/ops/runbooks` no longer shows `Rebuild Findings Lifecycle`, its preflight action, or its run modal.
|
||||
2. Sign in to `/admin/t/{tenant}/findings` as an entitled tenant operator and confirm there is no `Backfill findings lifecycle` header action while canonical findings workflow actions still render according to current capability rules.
|
||||
3. Open `/system/ops/controls` and confirm there is no findings lifecycle backfill control row, action, or history affordance.
|
||||
4. Check the supported Artisan command catalog and confirm `tenantpilot:findings:backfill-lifecycle` is gone, and `tenantpilot:run-deploy-runbooks` is also gone if backfill was its only remaining shipped responsibility.
|
||||
5. Exercise representative findings actions such as `Triage`, `Start progress`, `Assign`, `Resolve`, and `Risk accept` and confirm the existing workflow behavior is unchanged.
|
||||
6. Open Monitoring or Operations and confirm no supported surface can create a new `findings.lifecycle.backfill` run; historical rows, if any remain in local data, must not receive new special retry or cancel affordances.
|
||||
|
||||
## Notes
|
||||
|
||||
- Filament v5 remains on Livewire v4.0+ in this repo; the cleanup stays inside existing native Filament pages and actions.
|
||||
- No panel or provider registration changes are planned; `bootstrap/providers.php` remains the authoritative location if any provider work is ever needed later.
|
||||
- No new global-search resource, searchable surface, or global-search contract change is involved.
|
||||
- No new asset pipeline work is expected, so there is no added `filament:assets` deployment step.
|
||||
- LEAN-001 applies directly: the cleanup should delete obsolete runtime surfaces rather than keeping aliases, no-op command shells, or compatibility branches for historical data.
|
||||
@ -1,153 +0,0 @@
|
||||
# Research — Remove Findings Lifecycle Backfill Runtime Surfaces
|
||||
|
||||
**Date**: 2026-04-28
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
This document records the repo-grounded planning decisions for the findings lifecycle backfill cleanup slice. All decisions assume the current pre-production LEAN-001 posture.
|
||||
|
||||
## Decision 1 — Remove source traces, not only visible buttons
|
||||
|
||||
**Decision**: Delete the owning runtime sources for findings lifecycle backfill wherever the repo still starts, labels, or advertises the path. Do not treat the work as a page-local hide of the runbook card or the tenant findings header action.
|
||||
|
||||
**Rationale**:
|
||||
- The same path is currently sourced from `Runbooks.php`, `ListFindings.php`, `TenantpilotBackfillFindingLifecycle`, `TenantpilotRunDeployRunbooks`, `FindingsLifecycleBackfillRunbookService`, dedicated jobs, `OperationCatalog`, and `OperationRunTriageService`.
|
||||
- The product-truth problem is cross-surface. Hiding only the visible buttons would leave CLI, deploy/runtime, catalog, and monitoring traces alive.
|
||||
- FR-253-013 requires removing the source trace when a shared registry or helper family still emits lifecycle-backfill semantics.
|
||||
|
||||
**Evidence**:
|
||||
- `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`
|
||||
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||
- `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php`
|
||||
- `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php`
|
||||
- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`
|
||||
- `apps/platform/app/Support/OperationCatalog.php`
|
||||
- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hide the system runbook only.
|
||||
- Rejected: tenant UI, CLI, deploy/runtime, and monitoring traces would still advertise supported behavior.
|
||||
- Hide the tenant findings action only.
|
||||
- Rejected: `/system` and runtime hooks would still keep the repair path productized.
|
||||
|
||||
## Decision 2 — Delete the backfill-only runtime cluster; do not keep no-op compatibility shells
|
||||
|
||||
**Decision**: Delete `TenantpilotBackfillFindingLifecycle`, delete `TenantpilotRunDeployRunbooks` if lifecycle backfill is still its only shipped responsibility, and delete the dedicated backfill service and jobs instead of leaving dormant compatibility shells.
|
||||
|
||||
**Rationale**:
|
||||
- LEAN-001 explicitly prefers replacement or deletion over shims in this repo.
|
||||
- `TenantpilotRunDeployRunbooks` currently delegates only to the shared backfill service, so leaving it behind as a no-op would preserve false product truth.
|
||||
- The dedicated workspace and tenant job chain exists only for lifecycle backfill and has no independent product purpose after cleanup.
|
||||
|
||||
**Evidence**:
|
||||
- `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php`
|
||||
- `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php`
|
||||
- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`
|
||||
- `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`
|
||||
- `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php`
|
||||
- `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep the commands as deprecated wrappers that print a skip message.
|
||||
- Rejected: still productizes the removed path.
|
||||
- Leave the service and jobs behind behind an always-false gate.
|
||||
- Rejected: dead runtime ballast is exactly what this cleanup is intended to remove.
|
||||
|
||||
## Decision 3 — Preserve canonical findings workflows; defer deeper semantics cleanup
|
||||
|
||||
**Decision**: Keep canonical findings workflow behavior unchanged and limit this slice to removing the backfill path and any direct references that disappear with it. Continue to treat acknowledged-status cleanup and creation-time lifecycle invariants as explicit follow-up candidates.
|
||||
|
||||
**Rationale**:
|
||||
- `spec-candidates.md` separates `Remove Findings Lifecycle Backfill Runtime Surfaces`, `Remove Legacy Acknowledged Finding Status Compatibility`, and `Enforce Creation-Time Finding Invariants` into distinct follow-up slices.
|
||||
- The current backfill jobs mutate more than surface wiring: they normalize legacy `acknowledged` to `triaged`, fill lifecycle fields, fill SLA fields, and consolidate drift duplicates. Folding those semantics into this cleanup would widen scope beyond “remove shipped repair tooling”.
|
||||
- The spec and approval rubric both require a bounded cleanup slice.
|
||||
|
||||
**Evidence**:
|
||||
- `docs/product/spec-candidates.md`
|
||||
- `docs/product/implementation-ledger.md`
|
||||
- `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`
|
||||
- `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Merge acknowledged-status cleanup into this slice.
|
||||
- Rejected: deeper workflow, badge, query, and RBAC consequences deserve their own bounded spec.
|
||||
- Merge creation-time invariant hardening into this slice.
|
||||
- Rejected: generator and reopen semantics hardening is broader than runtime-surface deletion and should follow after repair tooling is gone.
|
||||
|
||||
## Decision 4 — Treat operational-control backfill traces as partial residue, not active product truth
|
||||
|
||||
**Decision**: Remove remaining operational-control-related lifecycle-backfill branches and tests rather than trying to make the control path “consistent again”.
|
||||
|
||||
**Rationale**:
|
||||
- The repo already partially removed the operational-control surface for this path. `OperationalControlCatalogTest` rejects `findings.lifecycle.backfill`, and `OperationalControlManagementTest` asserts the controls page no longer renders it.
|
||||
- The backfill service and some feature tests still carry `OperationalControlBlockedException` handling and blocked-start audit expectations for the removed control key.
|
||||
- Re-adding the control key would widen product truth in the wrong direction.
|
||||
|
||||
**Evidence**:
|
||||
- `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php`
|
||||
- `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
|
||||
- `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`
|
||||
- `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`
|
||||
- `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reintroduce `findings.lifecycle.backfill` to the operational-control catalog so all traces line up.
|
||||
- Rejected: that would reverse an already-desirable cleanup and keep the non-shipping feature alive.
|
||||
|
||||
## Decision 5 — Historical `OperationRun` and audit rows remain tolerated legacy data without new aliases
|
||||
|
||||
**Decision**: Historical `operation_runs.type = findings.lifecycle.backfill` rows and prior audit rows may remain stored, but the cleanup must not add new alias handling, new UI guarantees, or special retry or cancel semantics solely for those historical rows.
|
||||
|
||||
**Rationale**:
|
||||
- LEAN-001 forbids compatibility layers without production data pressure.
|
||||
- `OperationRunTriageService` still treats `findings.lifecycle.backfill` as retryable and cancelable. That support is part of the shipped runtime story and should disappear with the runtime path rather than being preserved for historical records.
|
||||
- The spec explicitly says historical data migration and historical compatibility handling are out of scope.
|
||||
|
||||
**Evidence**:
|
||||
- `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`
|
||||
- `apps/platform/app/Support/OperationCatalog.php`
|
||||
- `specs/253-remove-findings-backfill-runtime-surfaces/spec.md`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Preserve the operation type and alias so old runs keep a polished label forever.
|
||||
- Rejected: adds a compatibility obligation for non-shipping behavior.
|
||||
- Add a migration to scrub old rows.
|
||||
- Rejected: out of scope and not justified in pre-production.
|
||||
|
||||
## Decision 6 — Validation stays in fast-feedback and confidence lanes with absence-focused proof
|
||||
|
||||
**Decision**: Replace backfill-specific start, preflight, gate, and command tests with narrow absence and regression coverage. Keep representative findings workflow regression explicit and do not add browser or heavy-governance coverage.
|
||||
|
||||
**Rationale**:
|
||||
- The new business truth is absence of the repair path plus continuity of canonical findings workflows.
|
||||
- Existing backfill tests already prove the current path thoroughly; the replacement proof should be just as targeted, but around absence and unaffected workflows.
|
||||
- Browser coverage would mostly duplicate Filament action choreography and not improve confidence on the cleanup boundaries.
|
||||
|
||||
**Evidence**:
|
||||
- `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php`
|
||||
- `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`
|
||||
- `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep the existing backfill-only tests and just rename assertions.
|
||||
- Rejected: they would still preserve a product contract for a deleted runtime path.
|
||||
- Add browser smoke for the deleted buttons.
|
||||
- Rejected: the proving purpose is server-side absence and unchanged workflow behavior, not browser choreography.
|
||||
|
||||
## Decision 7 — No panel, global-search, or asset work is part of this cleanup
|
||||
|
||||
**Decision**: Keep the cleanup inside existing system and tenant surfaces. Do not change Filament panel registration, do not introduce or alter global-search behavior, and do not add asset work.
|
||||
|
||||
**Rationale**:
|
||||
- The affected surfaces already exist and already run on Filament v5 + Livewire v4.
|
||||
- The cleanup removes a header action and a system runbook card, but it does not add a new resource, page family, or asset bundle.
|
||||
- Provider registration changes in `bootstrap/providers.php` or `filament:assets` deployment work would be unrelated scope growth.
|
||||
|
||||
**Evidence**:
|
||||
- `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`
|
||||
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||
- repo conventions in `Agents.md` and `.github/copilot-instructions.md`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a replacement informational page or asset-backed empty state.
|
||||
- Rejected: the narrowest correct implementation is removal, not replacement UX.
|
||||
@ -1,291 +0,0 @@
|
||||
# Feature Specification: Remove Findings Lifecycle Backfill Runtime Surfaces
|
||||
|
||||
**Feature Branch**: `253-remove-findings-backfill-runtime-surfaces`
|
||||
**Created**: 2026-04-28
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Prepare the Spec Kit feature for Remove Findings Lifecycle Backfill Runtime Surfaces as the smallest cleanup slice that removes visible findings lifecycle backfill runbooks, commands, tenant actions, capabilities, and deploy/runtime hooks while keeping normal findings workflows unchanged."
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot still ships a findings lifecycle repair path through system runbooks, tenant findings actions, CLI commands, deploy hooks, operation labeling, and residual operational-control traces even though current finding generators already write the relevant lifecycle fields directly.
|
||||
- **Today's failure**: Operators and maintainers can still encounter `Backfill findings lifecycle` and `Rebuild Findings Lifecycle` as supported product truth, and the repo still carries residual control and guard traces for `findings.lifecycle.backfill` even where the control page no longer renders a live card. That overstates the product, keeps pre-production repair tooling alive, and invites the wrong next action on findings and ops surfaces.
|
||||
- **User-visible improvement**: Tenant and platform operators only see supported findings workflows and supported ops controls, not an internal repair action that should not ship.
|
||||
- **Smallest enterprise-capable version**: Remove the lifecycle-backfill runtime surface end to end: system runbook exposure, tenant findings action exposure, supported CLI and deploy entry points, backfill-only execution plumbing, operation and control catalog traces, and dedicated tests or docs that only exist for this path.
|
||||
- **Explicit non-goals**: No findings workflow redesign, no legacy `acknowledged` semantics cleanup beyond direct path-removal references, no new repair surface, no new backfill, no migration shim, no historical data migration, and no general refactor of findings lifecycle semantics.
|
||||
- **Permanent complexity imported**: Net negative. The slice removes runbook-, command-, capability-, control-, catalog-, and test-surface complexity. The only enduring obligation is narrower regression coverage that proves the removed path stays gone while normal findings workflows still work.
|
||||
- **Why now**: Specs 249 through 252 already promote the broader open candidates for customer review, governance inbox, commercial entitlements, and localization. Among the remaining open items, this is the smallest safe cleanup slice. It is narrower and safer than legacy `acknowledged` cleanup because it removes visible product ballast without rewriting canonical workflow semantics, and it should land before creation-time invariant hardening so the product stops shipping repair tooling first.
|
||||
- **Why not local**: The backfill is exposed simultaneously through `/system`, tenant findings UI, CLI commands, deploy/runtime hooks, operational controls, operation catalog traces, capability seeding, and a dedicated test family. A one-file hide or feature-flag leaves product truth inconsistent and keeps drift alive in other entry points.
|
||||
- **Approval class**: Cleanup
|
||||
- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this slice is intentionally limited to visible and runtime removal only; deeper findings semantics and creation-time invariants remain explicit follow-up candidates.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Selection Rationale
|
||||
|
||||
- Specs 249 through 252 already exist for Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, and Platform Localization, so those candidates are no longer the next open preparation target.
|
||||
- This cleanup slice is deliberately smaller and safer than `Remove Legacy Acknowledged Finding Status Compatibility` because it removes visible repair tooling without changing canonical findings workflow semantics, query semantics, badges, or RBAC language.
|
||||
- This cleanup should precede `Enforce Creation-Time Finding Invariants`, because the product should first stop shipping visible repair and runtime surfaces and then harden generators so those surfaces never need to return.
|
||||
- `Cross-Tenant Compare and Promotion v1` is broader and already has an older draft spec in the repo that needs refresh rather than a new small cleanup spec.
|
||||
- `External Support Desk / PSA Handoff` remains deferred because current repo docs do not define a concrete external desk or PSA target.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/system/ops/runbooks`
|
||||
- `/system/ops/controls` as a regression-proof absence and source-trace cleanup surface, not a new visible removal target
|
||||
- `/admin/t/{tenant}/findings`
|
||||
- supported CLI and deploy/runtime entry points that currently expose findings lifecycle backfill
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned `Finding` records remain the canonical findings workflow truth and keep required `workspace_id` and `tenant_id` anchors; this feature introduces no new persistence and no data migration.
|
||||
- Workspace- and platform-owned operational traces that exist only to launch or describe findings lifecycle backfill are removed, including runbook exposure, operation and control labels, capability seeding, and dedicated repository artifacts.
|
||||
- Existing reviewable findings behavior, ownership and responsibility, SLA, and due-date truth remain unchanged and are in scope only for regression protection.
|
||||
- **RBAC**:
|
||||
- Tenant membership remains the isolation boundary for findings visibility and findings workflow actions.
|
||||
- Platform `/system` access and surviving ops-control visibility remain governed by existing platform capabilities, but the findings lifecycle backfill-specific capability is removed rather than hidden behind an alias.
|
||||
- Non-members remain deny-as-not-found and in-scope members without required capability remain forbidden on surviving actions; this cleanup must not change unrelated findings or ops authorization semantics.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: N/A - the only canonical-view surfaces in scope are platform `/system` pages, which do not inherit tenant context. The tenant findings register remains tenant-scoped and keeps its current filters and default behavior.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Removing findings lifecycle backfill surfaces must not weaken existing workspace or tenant entitlement checks. `/system` remains platform-only, `/admin/t/{tenant}/findings` remains tenant-scoped, and no cleanup path may leak hidden tenant identity or historical backfill state to unauthorized users.
|
||||
|
||||
## 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)**: header actions, system runbook launch surfaces, operation labels, operational-control cards, queued or blocked status messaging, command and deploy/runtime entry points
|
||||
- **Systems touched**: system runbooks page, system operational controls page, tenant findings header actions, operation catalog and system-console triage labels, CLI command catalog, deploy/runtime hook surfaces, and dedicated test/docs artifacts
|
||||
- **Existing pattern(s) to extend**: existing shared runbook, operational-control, action-surface, and operation-label families remain the only shared paths; this feature reduces them to supported product truth instead of extending them with a replacement repair flow
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: existing shared operation labels, shared start UX, `UiEnforcement`, operational-control catalogs, and action-surface guardrails remain authoritative for surviving operations; no new replacement contract is introduced for lifecycle backfill
|
||||
- **Why the existing shared path is sufficient or insufficient**: the shared paths are sufficient for supported operations and existing findings workflow actions. They are not a reason to keep a pre-production repair task productized now that the product no longer needs to advertise or route operators into it.
|
||||
- **Allowed deviation and why**: none
|
||||
- **Consistency impact**: backfill-specific labels, buttons, help text, paused-state copy, capability names, operation labels, command support, and test expectations must disappear together so the repo stops telling two different stories about findings lifecycle truth.
|
||||
- **Review focus**: reviewers must verify that no remaining UI, CLI, deploy/runtime, control, catalog, or repository artifact still treats findings lifecycle backfill as a supported product action.
|
||||
|
||||
## 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 shared `OperationRun` start, toast, deep-link, dedupe, and terminal-notification contracts remain authoritative for surviving operations; this feature removes the `findings.lifecycle.backfill` operation type from those surfaces.
|
||||
- **Delegated start/completion UX behaviors**: `N/A` for findings lifecycle backfill after cleanup. Queued toast, `View run` or `Open operation` links, dedupe messaging, and terminal notifications remain unchanged for other operation types.
|
||||
- **Local surface-owned behavior that remains**: none for the removed findings lifecycle backfill path.
|
||||
- **Queued DB-notification policy**: `N/A` for the removed path; no new policy is introduced.
|
||||
- **Terminal notification path**: `N/A` for the removed path.
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Boundary classification**: `N/A`
|
||||
- **Seams affected**: `N/A`
|
||||
- **Neutral platform terms preserved or introduced**: `N/A`
|
||||
- **Provider-specific semantics retained and why**: `N/A`
|
||||
- **Why this does not deepen provider coupling accidentally**: removing findings lifecycle backfill traces does not introduce or preserve any new provider-specific platform seam.
|
||||
- **Follow-up path**: none
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| System ops runbooks page: remove `Rebuild Findings Lifecycle` runbook card, preflight state, and run modal | yes | Native Filament + shared runbook primitives | runbook launch, start UX, notifications | page, card, modal, action state | no | Keeps `/system` limited to supported runbooks |
|
||||
| Tenant findings list: remove `Backfill findings lifecycle` header action and its queued or paused messaging | yes | Native Filament + shared action-surface primitives | header actions, operation start messaging | page, header action, modal, toast state | no | Keeps the findings register focused on canonical review workflow, not repair tooling |
|
||||
| System operational controls page: keep `findings.lifecycle.backfill` absent and remove remaining control-entry residue | yes | Native Filament + shared control-card primitives | status messaging, operational control cards, audit links | page, card, action state | no | Prevents a non-shipping control key from surviving as hidden source trace or stale product truth |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| System operational controls page | Primary Decision Surface | Decide which supported runtime controls remain manageable | only supported controls, their state, and their scope | audit history for supported controls | Primary because the page still owns runtime-control decisions for live features; this slice removes one non-productized entry from that decision set | Keeps runtime-control decisions tied to shipping operations only | Removes a false pause or resume decision for a feature that should not ship |
|
||||
| System ops runbooks page | Secondary Context Surface | Decide whether a supported runbook should start | only supported runbooks and their current readiness | remaining run detail and history | Not primary for findings lifecycle anymore because the repair path is removed rather than redirected | Keeps `/system` focused on real platform operations | Removes a dead-end repair option and its supporting copy |
|
||||
| Tenant findings list | Primary Decision Surface | Review findings and pick the next governance action | finding status, severity, ownership, SLA, due state, and canonical workflow actions | deeper evidence, related operations, and finding history | Primary because this is where tenant operators decide what to do with findings; repair tooling never deserved co-equal prominence | Keeps findings work aligned to triage, assignment, progress, resolve, and risk governance | Removes a maintenance action that competes with the real next action |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| System operational controls page | operator/platform, support/platform | only supported controls, current state, scope, and reason | control-change history for supported controls | linked audit detail only when opened | `Adjust supported control` | no lifecycle-backfill control row, badge, or history affordance | one control list remains the source of truth for live runtime controls |
|
||||
| System ops runbooks page | operator/platform | only supported runbooks, descriptions, readiness, and latest run context | run detail after opening a supported run | raw run data only in the run detail view | `Preflight` or `Run` a supported runbook | lifecycle-backfill copy, modal, and launch affordances are absent | no parallel repair truth remains in cards, toasts, or modals |
|
||||
| Tenant findings list | operator/MSP | findings status, ownership, due signals, and canonical workflow actions | finding history, related operations, and review context | raw/support detail remains in existing evidence and detail surfaces | `Triage` or another canonical findings workflow action | no lifecycle-repair action or paused-state helper text | findings workflow truth stays on findings actions instead of a maintenance button |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System operational controls page | Utility / System | Operational safety control center | Adjust a supported control | Same-page control card or modal | forbidden | Card-level secondary links only | Confirmation-protected card actions for supported controls only | `/system/ops/controls` | `/system/ops/controls` | system-plane scope, control scope, and reason | Operational controls / Operational control | only supported control keys remain visible | none |
|
||||
| System ops runbooks page | Monitoring / Queue / Workbench | System runbook launcher | Preflight or run a supported runbook | Same-page action modal with run-detail links | forbidden | Page-level helper links and run links only | Explicit run modal for supported runbooks only | `/system/ops/runbooks` | `/system/ops/runs/{record}` | runbook scope and run history | Runbooks / Runbook | only supported runbooks are launchable | none |
|
||||
| Tenant findings list | List / Table / Bulk | CRUD / List-first Resource | Open a finding for triage or another canonical workflow action | Full-row click to finding detail | required | Existing row `More` actions and header utilities | Existing workflow actions remain in their current grouped placements | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | tenant context, filters, status, and due-state signals | Findings / Finding | canonical findings workflow and governance truth | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System operational controls page | Platform operator | Manage supported runtime controls only | Control center | Which runtime controls are part of shipping product truth right now? | supported controls, effective state, scope, reason, expiry | audit history for supported controls | runtime safety state, scope, expiry | TenantPilot only | Pause supported control, Resume supported control | State-changing control actions for supported controls only |
|
||||
| System ops runbooks page | Platform operator | Decide whether a supported runbook should start | Workbench | Is there a supported operational action to run from here? | supported runbooks, descriptions, latest run context | linked run detail after navigation | execution readiness and recent outcome | TenantPilot only unless a surviving runbook legitimately mutates tenant state | Preflight supported runbook, Run supported runbook | Run supported runbook |
|
||||
| Tenant findings list | Tenant operator | Decide how to triage or manage findings without maintenance detours | List/detail | What findings action should I take next? | status, severity, responsibility, SLA, due state, canonical workflow actions | deeper evidence, related operations, review context | lifecycle, governance validity, due attention, responsibility | TenantPilot only for findings workflow actions; no repair mutation path remains | Triage, Start progress, Assign, Resolve, Risk accept | Existing destructive-like workflow actions only |
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature, Unit, Heavy-Governance
|
||||
- **Validation lane(s)**: fast-feedback, confidence, heavy-governance
|
||||
- **Why this classification and these lanes are sufficient**: The slice removes operator surfaces, supported commands, catalog traces, and dedicated test artifacts while requiring regression proof that canonical findings workflows still function unchanged. Narrow feature tests prove absence on system and tenant surfaces plus removed CLI and deploy entry points. Narrow unit coverage proves catalog, control, capability, trusted-state, and action-surface traces are gone. One retained heavy-governance source-scanning guard remains necessary because the cleanup deletes an operational-control key and its owning runbook service, and the repo already treats that bypass guard as `surface-guard` coverage.
|
||||
- **New or expanded test families**: focused absence and guard coverage for removed findings lifecycle backfill surfaces and traces, plus representative findings workflow regression coverage. Backfill-only preflight, start, idempotency, operational-control, and deploy-hook test families that exist only for this path are deleted or collapsed; no new heavy-governance family is introduced.
|
||||
- **Fixture / helper cost impact**: low and net-negative. The feature should remove dedicated backfill fixtures, jobs, and lane-manifest references instead of adding new heavy setup.
|
||||
- **Heavy-family visibility / justification**: one existing heavy-governance guard remains explicit in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` because the cleanup removes a control key and a runbook-service entry point that the guard already scans. The feature does not add a new heavy family; it keeps one existing guard visible instead of silently depending on it.
|
||||
- **Special surface test profile**: standard-native-filament, monitoring-state-page
|
||||
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for system and findings surfaces. Required extra proof is absence and guard coverage for CLI and catalog traces, plus regression checks for canonical findings workflow actions.
|
||||
- **Reviewer handoff**: reviewers must verify removal at three layers: no system runbook or findings header action remains, no supported CLI or deploy/runtime trigger remains, and no control, capability, operation-label, or test-lane residue still advertises lifecycle backfill. They must also confirm representative triage, assignment, progress, resolve, risk-acceptance, ownership, SLA, and due-date flows still work unchanged.
|
||||
- **Budget / baseline / trend impact**: expected net reduction because a dedicated backfill test family and related lane artifacts are removed.
|
||||
- **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/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingWorkflowRegressionTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Stop Shipping Repair Tooling (Priority: P1)
|
||||
|
||||
As a platform or tenant operator, I should only see supported findings and ops actions, not a lifecycle-repair surface that the product no longer intends to ship.
|
||||
|
||||
**Why this priority**: This is the primary trust and product-truth outcome. If the UI still advertises the repair path, the cleanup has failed even before deeper semantics work begins.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening the current system runbooks page, system operational controls page, and tenant findings list and verifying that no findings lifecycle backfill affordance remains while the ordinary findings workflow remains visible.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform operator opens the system runbooks page, **When** the page renders, **Then** there is no `Rebuild Findings Lifecycle` runbook, preflight state, or run modal.
|
||||
2. **Given** a platform operator opens the system operational controls page, **When** the page renders, **Then** there is no `findings.lifecycle.backfill` control row, pause or resume affordance, or history affordance.
|
||||
3. **Given** an entitled tenant operator opens the tenant findings list, **When** the page renders, **Then** there is no `Backfill findings lifecycle` header action and the existing findings workflow actions remain visible according to current capability rules.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Remove Hidden Runtime Entry Points (Priority: P1)
|
||||
|
||||
As a platform operator or deploy owner, I should not be able to trigger findings lifecycle backfill through a supported command or deploy/runtime hook once the product stops advertising the repair path.
|
||||
|
||||
**Why this priority**: The cleanup is incomplete if the UI is clean but CLI or deploy surfaces still launch the same repair behavior in the background.
|
||||
|
||||
**Independent Test**: Can be fully tested by checking the supported command and deploy/runtime entry points and proving that no supported path queues findings lifecycle backfill anymore.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the repo exposes supported maintenance commands, **When** the supported command catalog is reviewed after the cleanup, **Then** `tenantpilot:findings:backfill-lifecycle` is no longer a supported command.
|
||||
2. **Given** the deploy/runtime hook path is exercised after the cleanup, **When** the hook runs, **Then** it does not queue or start findings lifecycle backfill.
|
||||
3. **Given** the cleanup removes the only shipped responsibility of a generic deploy-runbooks entry point, **When** the feature lands, **Then** that entry point is removed rather than left behind as an inert compatibility shell.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep Canonical Findings Workflow Unchanged (Priority: P2)
|
||||
|
||||
As a tenant operator, I still need triage, assignment, in-progress, resolve, risk acceptance, ownership, SLA, due-date, and existing reviewable finding behavior to work exactly as before while the repair surface is removed.
|
||||
|
||||
**Why this priority**: This slice is cleanup, not redesign. It should tighten product truth without changing the day-to-day findings workflow.
|
||||
|
||||
**Independent Test**: Can be fully tested by running representative findings workflow actions after the cleanup and confirming that existing findings outcomes and reviewable behavior still work without any backfill dependency.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant operator triages, assigns, starts progress on, resolves, or risk-accepts a finding, **When** the action succeeds, **Then** the same canonical findings workflow behavior remains intact.
|
||||
2. **Given** findings already carry ownership, SLA, due-date, and reviewable context, **When** the cleanup lands, **Then** those surfaces and behaviors remain unchanged apart from the removed backfill action.
|
||||
3. **Given** a reviewer uses existing finding detail and related review context, **When** the repair path is removed, **Then** no replacement repair message or workflow detour appears.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Historical pre-production `OperationRun` or audit rows may still mention findings lifecycle backfill; this slice does not preserve special labels, filters, or compatibility handling for those old records.
|
||||
- Removing a shared catalog or control entry must remove the source trace itself, not leave stale empty cards, headers, or hidden conditionals on system surfaces.
|
||||
- If `tenantpilot:run-deploy-runbooks` exists only to launch findings lifecycle backfill, LEAN-001 forbids keeping it as a no-op compatibility shim after the backfill path is removed.
|
||||
- Any test lane manifest, docs artifact, or action-surface exemption that only references findings lifecycle backfill must be cleaned in the same slice so the repo does not keep advertising deleted behavior indirectly.
|
||||
- Normal findings workflow actions must remain visible and capability-gated even though one header action disappears; the cleanup must not collapse ordinary findings action hierarchy.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call, no new long-running work, and no new persisted truth. It removes an obsolete repair and runtime path across UI, CLI, deploy, and repository artifacts. Tenant-owned findings remain bound to required `workspace_id` and `tenant_id` anchors, and the cleanup must not imply any replacement repair flow.
|
||||
|
||||
**Constitution alignment (LEAN-001 / PROP-001 / BLOAT-001):** This is explicitly a pre-production cleanup slice. No legacy alias, fallback reader, migration shim, dormant command, or historical fixture preservation is allowed for findings lifecycle backfill. The slice removes structure rather than adding it, and it keeps deeper semantics cleanup and creation-time invariant hardening as separate follow-up work.
|
||||
|
||||
**Constitution alignment (XCUT-001):** The cleanup is cross-cutting across runbook launch surfaces, findings header actions, operation labels, operational controls, notifications, command support, and deploy/runtime hooks. Every one of those surfaces must converge on the same truth: findings lifecycle backfill is not a supported product action.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Proof stays mostly in narrow feature and unit coverage, with one retained heavy-governance source-scanning guard for operational-control bypass residue. The change should still shrink, not grow, the suite by deleting backfill-only test families and lane references while keeping representative findings workflow regression protection explicit.
|
||||
|
||||
**Constitution alignment (OPS-UX / OPS-UX-START-001):** This feature removes one `OperationRun` start surface rather than adding one. No replacement queued toast, run link, or terminal notification is introduced for findings lifecycle backfill, and shared start UX remains unchanged for every other operation type.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Removing lifecycle backfill surfaces must not change current 404 versus 403 semantics for surviving findings or system actions. The feature removes the backfill-specific capability and its visibility path rather than creating capability aliases or a hidden bypass.
|
||||
|
||||
**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / OPSURF-001):** Existing system and findings surfaces remain native operator surfaces, but non-canonical repair labels such as `Rebuild Findings Lifecycle` and `Backfill findings lifecycle` are removed from default-visible decision paths. The dominant next actions on those surfaces stay tied to supported runbooks and canonical findings workflow actions only.
|
||||
|
||||
**Constitution alignment (PROV-001):** Not applicable. This cleanup does not introduce or deepen a shared provider or platform seam.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-253-001**: The system MUST remove the system runbook entry labeled `Rebuild Findings Lifecycle` and its related preflight, run, last-run, and launch-copy surfaces from `/system/ops/runbooks`.
|
||||
- **FR-253-002**: The system MUST remove the tenant findings header action labeled `Backfill findings lifecycle` and any related queued, paused, or `Open operation` messaging that exists only for that action.
|
||||
- **FR-253-003**: The system MUST retire findings lifecycle backfill as a supported CLI path, including `tenantpilot:findings:backfill-lifecycle` and any deploy/runtime command that exists only to start the same backfill.
|
||||
- **FR-253-004**: No deploy hook, runtime hook, schedule, or operational bootstrap path may start, queue, or advertise findings lifecycle backfill after this cleanup.
|
||||
- **FR-253-005**: Backfill-specific jobs, runbook services, scope helpers, notification branches, and execution plumbing that exist only to support the removed runtime surfaces MUST be deleted rather than left dormant behind a flag, control gate, or compatibility stub.
|
||||
- **FR-253-006**: Operation-catalog entries, operation-type aliases, system-console triage traces, operational-control keys, capability constants, seed data, and other repository traces that exist only for `findings.lifecycle.backfill` MUST be removed in the same slice.
|
||||
- **FR-253-007**: Tests, lane manifests, docs, action-surface references, and repository artifacts that exist only to prove or describe findings lifecycle backfill preflight, start, pause, completion, or audit behavior MUST be removed or rewritten in the same slice.
|
||||
- **FR-253-008**: The feature MUST NOT introduce a replacement repair surface, a new backfill, a migration shim, a fallback command, or a historical data migration.
|
||||
- **FR-253-009**: Canonical findings workflows remain unchanged and in scope only for regression protection: triage, assignment, start progress, resolve, risk acceptance, ownership and responsibility, SLA, due-date, and existing reviewable finding behavior continue to work under current authorization, audit, and lifecycle rules.
|
||||
- **FR-253-010**: Legacy `acknowledged` status cleanup remains out of scope except where the removed backfill path still references it directly; the broader semantics collapse remains a separate follow-up spec.
|
||||
- **FR-253-011**: Creation-time lifecycle readiness remains a follow-up hardening concern; the product must not answer that concern by leaving a visible repair path or implying that a future backfill will return.
|
||||
- **FR-253-012**: Tenant-owned findings keep existing `workspace_id` and `tenant_id` ownership anchors; no new persisted alias, compatibility row, or auxiliary repair truth is introduced to preserve the removed behavior.
|
||||
- **FR-253-013**: If a shared UI or runtime surface currently exposes findings lifecycle backfill only because it derives from a shared catalog or registry, the cleanup MUST remove the source trace rather than hiding the entry with a local condition.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System ops runbooks page | `app/Filament/System/Pages/Ops/Runbooks.php` | Existing header actions remain only for supported runbooks; findings lifecycle backfill actions are removed | Same-page runbook cards and run-detail links for surviving runbooks | none | none | none | none | run modals remain only for supported runbooks | unchanged for surviving runbooks | This slice removes the lifecycle-backfill card, preflight, and run flow instead of adding a replacement action |
|
||||
| System operational controls page | `app/Filament/System/Pages/Ops/Controls.php` | Existing control-management actions remain only for supported controls; no findings lifecycle backfill control actions remain | Same-page control cards for supported controls | none | none | none | same-page control actions only | same-page control modals for supported controls only | unchanged for surviving controls | This slice removes the lifecycle-backfill control entry instead of hiding it behind an exception |
|
||||
| Tenant findings list | `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Existing findings workflow utilities remain; `Backfill findings lifecycle` is removed | Clickable row to finding detail remains unchanged | existing row actions unchanged | existing grouped bulk actions unchanged | existing empty-state behavior unchanged | existing finding detail header actions unchanged | `N/A` | unchanged for surviving findings workflow actions | The cleanup removes one non-canonical header action and leaves the existing findings workflow action hierarchy intact |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Findings lifecycle backfill surface**: Any supported operator-visible or supported invocation path that starts or advertises the removed repair flow, including system runbooks, tenant findings actions, supported commands, deploy hooks, control entries, and operation labels.
|
||||
- **Canonical findings workflow**: The existing tenant-owned findings lifecycle and governance actions that remain the only supported path for triage, assignment, in-progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable finding behavior.
|
||||
- **Backfill-only artifact**: A repository artifact such as a job, service, control key, capability constant, test, lane manifest, or doc that exists only to support or describe the removed findings lifecycle backfill path.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: System and tenant product surfaces expose zero visible launch affordances for findings lifecycle backfill after the cleanup.
|
||||
- **SC-002**: Supported CLI, deploy, and runtime entry points expose zero supported paths that queue or start findings lifecycle backfill after the cleanup.
|
||||
- **SC-003**: Operational-control and operation-label surfaces expose zero live product traces of `findings.lifecycle.backfill` after the cleanup.
|
||||
- **SC-004**: Representative findings workflow regression validation continues to pass for triage, assignment, start progress, resolve, risk acceptance, ownership and responsibility, SLA, due-date, and existing reviewable finding behavior without any repair-tool dependency.
|
||||
- **SC-005**: The dedicated backfill-only test and lane footprint decreases rather than grows as part of the cleanup slice.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Current findings generators already create lifecycle-ready records for normal product paths, even though invariant hardening remains a follow-up.
|
||||
- Existing findings workflow and reviewable findings behavior remain the canonical operator truth described by current findings specs and runtime surfaces.
|
||||
- Existing system runbook, operational-control, operation-catalog, and capability registries are the current places where lifecycle-backfill traces must be removed consistently.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- LEAN-001 still applies because the product remains pre-production; historical findings lifecycle backfill rows, fixtures, or aliases do not justify compatibility behavior.
|
||||
- Specs 249 through 252 already cover the broader open candidates for customer review, governance inbox, commercial entitlements, and localization, so this cleanup is the next best open small slice.
|
||||
- `Cross-Tenant Compare and Promotion v1` should return later by refreshing its older draft spec rather than being recast as a new cleanup slice here.
|
||||
- `External Support Desk / PSA Handoff` remains deferred until repo docs name a concrete external desk or PSA target.
|
||||
|
||||
## Risks
|
||||
|
||||
- Hidden references may still exist outside the named anchors, especially in lane manifests, docs, audit helpers, or shared catalog-derived UI surfaces.
|
||||
- Removing shared catalog or capability traces too narrowly could leave stale empty cards, filters, or labels that continue to imply support for the removed path.
|
||||
- If any current findings generator still depends on repair behavior implicitly, removing the visible runtime path will expose that gap immediately and will need the follow-up invariant-hardening spec rather than a reintroduced backfill.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Removing legacy `acknowledged` workflow semantics beyond direct references that disappear with the backfill path
|
||||
- Redesigning findings workflow actions, ownership semantics, SLA behavior, due-date semantics, or reviewable finding behavior
|
||||
- Introducing a replacement repair surface, a new lifecycle backfill, or any migration shim
|
||||
- Historical data migration, legacy alias preservation, or compatibility-specific handling for old backfill runs
|
||||
- Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, Localization, and broader cross-tenant compare work
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
1. `Remove Legacy Acknowledged Finding Status Compatibility` for the deeper workflow, badge, filter, and RBAC cleanup that should happen only after visible repair and runtime surfaces are gone.
|
||||
2. `Enforce Creation-Time Finding Invariants` to prove new findings are lifecycle-ready at write time so the removed repair surface never needs to return.
|
||||
3. `Cross-Tenant Compare and Promotion v1` as a refreshed broader portfolio-action spec from the older draft already in the repo, not as part of this cleanup slice.
|
||||
4. `External Support Desk / PSA Handoff` once repo docs define a concrete external desk target and bounded integration contract.
|
||||
@ -1,231 +0,0 @@
|
||||
# Tasks: Remove Findings Lifecycle Backfill Runtime Surfaces
|
||||
|
||||
**Input**: Design documents from `/specs/253-remove-findings-backfill-runtime-surfaces/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/findings-backfill-runtime-surface-removal.contract.yaml`
|
||||
|
||||
**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` feature + unit lanes already named in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`, plus one retained `heavy-governance` guard in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` because the cleanup removes an operational-control key and its owning runbook-service seam. Prefer absence-focused coverage in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`, `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`. Keep the cleanup net-negative by deleting backfill-only tests instead of widening the suite.
|
||||
**Operations**: This slice removes one existing `OperationRun` start family. Do not add a replacement runbook, alias, no-op command shell, or local start UX. Historical `operation_runs` and `audit_logs` rows may remain untouched, but no supported surface may create a new `findings.lifecycle.backfill` run after cleanup.
|
||||
**RBAC**: Preserve current `/system` platform-only access, `/admin/t/{tenant}` tenant isolation, deny-as-not-found `404` for non-members or out-of-scope users, and `403` for in-scope capability failures on surviving actions. Remove the backfill-specific platform capability constant and seed grant without widening any unrelated authorization behavior.
|
||||
**UI / Surface Guardrails**: This is a `review-mandatory` cleanup across native Filament system runbooks, native Filament system operational controls, and the tenant findings list. Keep `standard-native-filament` relief for surviving surfaces, remove the backfill affordances entirely, and do not introduce replacement helper copy, new panels, or new assets.
|
||||
**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, or provider work is introduced. `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php`, and `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php` must converge on supported runbook and findings workflow actions only.
|
||||
**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` and `US2` in parallel -> `US3` -> final cleanup and validation, because regression proof only matters after all backfill start seams and traces are removed.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [ ] Lane assignment stays `fast-feedback` plus `confidence`, with one explicit retained `heavy-governance` guard for operational-control bypass residue, and remains the narrowest sufficient proof for the removed runtime family.
|
||||
- [ ] New or changed tests stay in focused `Feature` and `Unit` files only; no browser or new heavy-governance family is added.
|
||||
- [ ] Shared helpers, factories, seeds, fixtures, and support defaults remain cheap by default; any backfill-specific setup is deleted instead of generalized.
|
||||
- [ ] Planned validation commands stay limited to the targeted Sail test commands already captured in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`.
|
||||
- [ ] The declared surface test profile stays `standard-native-filament` plus `monitoring-state-page` where the system runbooks or controls surfaces need explicit absence proof.
|
||||
- [ ] Any material suite-footprint or follow-up note resolves in this feature as `document-in-feature` or `follow-up-spec`, not as an implicit scope expansion.
|
||||
|
||||
## Phase 1: Setup (Shared Cleanup Anchors)
|
||||
|
||||
**Purpose**: Lock the concrete removal inventory and proving commands before implementation starts.
|
||||
|
||||
- [ ] T001 [P] Verify the source-surface inventory across `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php`, and `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php`
|
||||
- [ ] T002 [P] Verify the runtime-cluster and trace inventory across `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`, `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php`, `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, and `apps/platform/database/seeders/PlatformUserSeeder.php`
|
||||
- [ ] T003 [P] Verify the narrow validation-lane commands and manual smoke expectations in `specs/253-remove-findings-backfill-runtime-surfaces/plan.md` and `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md`
|
||||
|
||||
**Checkpoint**: The cleanup boundaries and proving commands are locked before any runtime file is changed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Proof Surfaces)
|
||||
|
||||
**Purpose**: Make the absence proof, regression anchors, and cleanup inventory explicit before deleting shared runtime seams.
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [ ] T004 [P] Lock the surface-removal proof plan across `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, and `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
|
||||
- [ ] T005 [P] Lock the registry, capability, and retained heavy-governance bypass proof plan across `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, and `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- [ ] T006 [P] Audit canonical findings workflow and authorization regression anchors across `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php`
|
||||
- [ ] T007 [P] Verify the backfill-only cleanup targets across `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php`, `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, and `docs/HANDOVER.md`
|
||||
|
||||
**Checkpoint**: Absence-proof files, regression anchors, and cleanup-only artifacts are explicit and ready for bounded implementation work.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Stop Shipping Repair Tooling (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Remove the visible lifecycle-backfill affordances from system and tenant operator surfaces so the product only presents supported findings and ops actions.
|
||||
|
||||
**Independent Test**: Open `/system/ops/runbooks`, `/system/ops/controls`, and `/admin/t/{tenant}/findings` and verify there is no lifecycle-backfill card, control trace, or header action while the surviving findings workflow actions still render under current authorization rules.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [ ] T008 [P] [US1] Add system runbook absence coverage for the removed card, preflight state, modal, and last-run copy in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`
|
||||
- [ ] T009 [P] [US1] Add tenant findings absence coverage for the removed header action and backfill-only `Open operation` messaging in `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`
|
||||
- [ ] T010 [P] [US1] Add system operational-control absence coverage for removed `findings.lifecycle.backfill` surface traces in `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T011 [US1] Remove the lifecycle-backfill runbook card, preflight action, run modal, and last-run display from `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php` and `apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php`
|
||||
- [ ] T012 [US1] Remove the tenant findings lifecycle-backfill header action and its backfill-only queued, paused, and link copy from `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||
- [ ] T013 [US1] Reconcile surface-level authorization continuity for surviving system and tenant actions in `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional and the visible repair tooling is gone from system and tenant operator surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Remove Hidden Runtime Entry Points (Priority: P1)
|
||||
|
||||
**Goal**: Remove every supported command, deploy hook, runtime service, job, and shared registry trace that can still start or advertise findings lifecycle backfill.
|
||||
|
||||
**Independent Test**: Review the supported command surface, shared runtime seams, operation catalog, triage helpers, capability registry, and seeder grants and verify that no supported path can queue or describe `findings.lifecycle.backfill` anymore.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [ ] T014 [P] [US2] Add command-removal coverage for the missing lifecycle-backfill CLI and deploy entry points in `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
|
||||
- [ ] T015 [P] [US2] Add operation-catalog and capability trace removal guards in `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php` and `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`
|
||||
- [ ] T016 [P] [US2] Add operational-control, triage, and retained bypass-guard residue coverage for removed backfill traces in `apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php`, `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`, and `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T017 [US2] Delete the supported CLI and deploy/runtime entry points in `apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php` and `apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php`
|
||||
- [ ] T018 [US2] Delete the dedicated backfill runtime cluster in `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`, `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillScope.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleJob.php`, `apps/platform/app/Jobs/BackfillFindingLifecycleWorkspaceJob.php`, and `apps/platform/app/Jobs/BackfillFindingLifecycleTenantIntoWorkspaceRunJob.php`
|
||||
- [ ] T019 [US2] Remove `findings.lifecycle.backfill` registry, authorization, and trusted-state traces from `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Services/SystemConsole/OperationRunTriageService.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, `apps/platform/app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, and `apps/platform/database/seeders/PlatformUserSeeder.php`
|
||||
- [ ] T020 [US2] Remove or rewrite backfill-only command, control, and action-surface expectations in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, and `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional and no supported runtime or registry path can start or advertise lifecycle backfill.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep Canonical Findings Workflow Unchanged (Priority: P2)
|
||||
|
||||
**Goal**: Preserve canonical findings workflow behavior and authorization semantics while the repair path is removed.
|
||||
|
||||
**Independent Test**: Run representative triage, assignment, start progress, resolve, and risk-accept flows after the cleanup and confirm the same tenant isolation plus `404` versus `403` semantics still hold for surviving findings and system surfaces.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [ ] T021 [P] [US3] Add representative findings workflow regression coverage for triage, assignment, start progress, resolve, risk acceptance, ownership, SLA, due-date, and reviewable continuity in `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`
|
||||
- [ ] T022 [P] [US3] Add explicit surviving-surface authorization regression assertions inside `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T023 [US3] Reconcile surviving findings workflow fixtures and assertions so they no longer depend on deleted backfill helpers in `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, and `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional and the canonical findings workflow remains unchanged after the backfill cleanup.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Remove backfill-only repository residue, keep docs and lane support honest, and run the narrow validation workflow.
|
||||
|
||||
- [ ] T024 [P] Remove or rewrite backfill-only runbook test families in `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillIdempotencyTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillBreakGlassTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillAuditFailSafeTest.php`, and `apps/platform/tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php`
|
||||
- [ ] T025 [P] Remove or rewrite backfill-only findings and control test artifacts in `apps/platform/tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php`, `apps/platform/tests/Feature/Findings/FindingBackfillTest.php`, `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`, `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`, `apps/platform/tests/Feature/Console/Spec113/DeployRunbooksCommandTest.php`, and `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
|
||||
- [ ] T026 [P] Clean remaining handover and lane-support traces in `docs/HANDOVER.md`, `apps/platform/tests/Support/TestLaneManifest.php`, `scripts/platform-test-lane`, and `scripts/platform-test-report`
|
||||
- [ ] T027 Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the cleanup across `apps/platform/app/`, `apps/platform/tests/`, and `apps/platform/database/seeders/PlatformUserSeeder.php`
|
||||
- [ ] T028 [P] Run the targeted surface-removal Pest command from `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` against `apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php`, `apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php`, `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php`, and `apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php`
|
||||
- [ ] T029 [P] Run the targeted registry and workflow Pest commands from `specs/253-remove-findings-backfill-runtime-surfaces/quickstart.md` against `apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php`, `apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowRegressionTest.php`
|
||||
- [ ] T030 Run final residue searches for `findings.lifecycle.backfill`, `Backfill findings lifecycle`, `Rebuild Findings Lifecycle`, `FindingsLifecycleBackfill`, and `TenantpilotBackfillFindingLifecycle` across `apps/platform/app/`, `apps/platform/resources/`, `apps/platform/tests/`, `apps/platform/database/`, and `docs/HANDOVER.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and locks the concrete inventory plus validation commands.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, regression anchors, and cleanup-only artifacts are explicit.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational and is part of the MVP delivery.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it targets the hidden runtime and registry seams behind the removed surfaces.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because workflow regression only proves the right thing once every backfill start surface is gone.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete so backfill-only tests, docs, and residue searches can be cleaned once.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: No dependencies beyond Foundational.
|
||||
- **US3**: Depends on US1 and US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Add or update the story tests first and confirm they fail before cleanup edits are considered complete.
|
||||
- Remove source traces instead of hiding the backfill path locally on one page.
|
||||
- Do not keep compatibility aliases, no-op commands, replacement repair surfaces, or historical row UX promises.
|
||||
- Keep acknowledged-status cleanup and creation-time lifecycle invariant hardening out of scope for this feature.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001`, `T002`, and `T003` can run in parallel during Setup.
|
||||
- `T004`, `T005`, `T006`, and `T007` can run in parallel during Foundational work.
|
||||
- `T008`, `T009`, and `T010` can run in parallel for User Story 1, followed by `T011` and `T012`, before reconciling continuity in `T013`.
|
||||
- `T014`, `T015`, and `T016` can run in parallel for User Story 2, followed by `T017`, `T018`, and `T019`, before reconciling backfill-only expectations in `T020`.
|
||||
- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete.
|
||||
- `T024`, `T025`, and `T026` can run in parallel during cross-cutting cleanup.
|
||||
- `T028` and `T029` can run in parallel during final validation.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# User Story 1 tests in parallel
|
||||
T008 apps/platform/tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php
|
||||
T009 apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php
|
||||
T010 apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php
|
||||
|
||||
# User Story 1 implementation after the tests are in place
|
||||
T011 apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php
|
||||
T012 apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# User Story 2 tests in parallel
|
||||
T014 apps/platform/tests/Feature/Console/RemoveFindingsLifecycleBackfillCommandsTest.php
|
||||
T015 apps/platform/tests/Unit/Support/OperationCatalog/RemoveFindingsLifecycleBackfillCatalogTraceTest.php + apps/platform/tests/Unit/Support/Auth/RemoveFindingsLifecycleBackfillCapabilityTraceTest.php
|
||||
T016 apps/platform/tests/Feature/System/Spec114/OpsTriageActionsTest.php + apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php + apps/platform/tests/Feature/System/OpsControls/RemoveFindingsLifecycleBackfillControlTraceTest.php
|
||||
|
||||
# User Story 2 implementation after the tests are in place
|
||||
T017 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php
|
||||
T018 apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + apps/platform/app/Jobs/BackfillFindingLifecycle*.php
|
||||
T019 apps/platform/app/Support/OperationCatalog.php + apps/platform/app/Services/SystemConsole/OperationRunTriageService.php + apps/platform/app/Support/Auth/PlatformCapabilities.php + apps/platform/database/seeders/PlatformUserSeeder.php
|
||||
```
|
||||
|
||||
## Parallel Example: Cross-Story Delivery After Foundational
|
||||
|
||||
```bash
|
||||
# Visible surfaces and hidden runtime traces can be removed in parallel after Phase 2
|
||||
T011-T013 apps/platform/app/Filament/System/Pages/Ops/Runbooks.php + apps/platform/resources/views/filament/system/pages/ops/runbooks.blade.php + apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + related surface tests
|
||||
T017-T020 apps/platform/app/Console/Commands/TenantpilotBackfillFindingLifecycle.php + apps/platform/app/Console/Commands/TenantpilotRunDeployRunbooks.php + apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php + apps/platform/app/Jobs/BackfillFindingLifecycle*.php + apps/platform/app/Support/OperationCatalog.php + apps/platform/app/Services/SystemConsole/OperationRunTriageService.php + apps/platform/app/Support/Auth/PlatformCapabilities.php + apps/platform/database/seeders/PlatformUserSeeder.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1 and 2)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Complete Phase 4: User Story 2.
|
||||
5. Run `T027`, `T028`, and `T030` before widening into workflow-regression cleanup.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Lock the removal inventory and proving commands.
|
||||
2. Remove the visible runbook, findings action, and control-surface traces.
|
||||
3. Remove the hidden CLI, deploy-hook, service, job, capability, catalog, and triage seams.
|
||||
4. Prove the canonical findings workflow and authorization semantics still behave the same.
|
||||
5. Clean backfill-only tests and docs, then finish with Pint plus the targeted Pest commands.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor can own the visible surface cleanup (`US1`) while another owns the command, runtime, and registry cleanup (`US2`) after Phase 2.
|
||||
2. Once both P1 stories land, a focused pass can own the workflow-regression slice (`US3`) without reopening runtime-surface decisions.
|
||||
3. A final pass can remove backfill-only test or docs residue and run the narrow validation commands.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Suggested MVP scope: Phase 1 through Phase 4 only. Visible-surface removal without runtime-cluster removal is not sufficient for this feature.
|
||||
- Explicit non-goals for implementation remain: legacy `acknowledged` status cleanup, creation-time lifecycle invariant hardening, a replacement repair surface, historical data migration, and compatibility aliases or no-op command shells.
|
||||
- Follow-up candidates remain the same as the prepared spec: `Remove Legacy Acknowledged Finding Status Compatibility` and `Enforce Creation-Time Finding Invariants`.
|
||||
- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths.
|
||||
Loading…
Reference in New Issue
Block a user