Merge platform-dev into 269-operationrun-terminal-outcome-feedback
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m31s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m31s
This commit is contained in:
commit
967b022b26
@ -4,11 +4,21 @@
|
||||
},
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "vendor/bin/sail",
|
||||
"command": "/Users/ahmeddarrazi/Documents/projects/wt-plattform/scripts/platform-sail",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"kroki": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/Users/ahmeddarrazi/Documents/projects/kroki-mcp-server/dist/index.js"
|
||||
],
|
||||
"env": {
|
||||
"KROKI_BASE_URL": "http://development-kroki-ccl69b-553648-194-164-192-109.traefik.me",
|
||||
"KROKI_TIMEOUT_MS": "10000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -272,6 +272,8 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `review_packs`, `evidence_snapshots`, `evidence_snapshot_items`, `stored_reports`, `findings`, `finding_exceptions`, `finding_exception_decisions`, memberships, and `audit_logs`; no new persistence planned (260-governance-service-packaging)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Tailwind v4, Pest v4, existing dashboard widgets, `TenantGovernanceAggregateResolver`, `RestoreSafetyResolver`, `BackupHealthDashboardSignal`, `OperationRunLinks`, `RequiredPermissionsLinks`, `TenantRequiredPermissionsViewModelBuilder`, tenant review/evidence/review-pack resources, shared badge rendering, and capability helpers (266-tenant-dashboard-productization-v1)
|
||||
- PostgreSQL via existing tenant-owned findings, exceptions, operation runs, evidence snapshots, review packs, tenant reviews, backup or restore evidence records, memberships, and audit logs; no new persistence planned (266-tenant-dashboard-productization-v1)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers (275-customer-facing-localization-adoption)
|
||||
- PostgreSQL via existing `users.preferred_locale`, existing workspace localization setting, existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, memberships, and `audit_logs`; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de`; no new persistence planned (275-customer-facing-localization-adoption)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -306,9 +308,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 275-customer-facing-localization-adoption: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers
|
||||
- 266-tenant-dashboard-productization-v1: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Tailwind v4, Pest v4, existing dashboard widgets, `TenantGovernanceAggregateResolver`, `RestoreSafetyResolver`, `BackupHealthDashboardSignal`, `OperationRunLinks`, `RequiredPermissionsLinks`, `TenantRequiredPermissionsViewModelBuilder`, tenant review/evidence/review-pack resources, shared badge rendering, and capability helpers
|
||||
- 260-governance-service-packaging: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure
|
||||
- 259-compliance-evidence-mapping: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Support\Ai\AiPolicyMode;
|
||||
use App\Support\Ai\AiUseCaseCatalog;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
@ -141,6 +142,11 @@ class WorkspaceSettings extends Page
|
||||
*/
|
||||
public array $entitlementSummary = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $commercialLifecycleSummary = [];
|
||||
|
||||
/**
|
||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||
*
|
||||
@ -227,6 +233,24 @@ public function content(Schema $schema): Schema
|
||||
->helperText(fn (): string => $this->localeDefaultHelperText())
|
||||
->hintAction($this->makeResetAction('localization_default_locale')),
|
||||
]),
|
||||
Section::make('Commercial posture')
|
||||
->description('Read-only subscription-backed or fallback-backed commercial posture for this workspace.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Placeholder::make('commercial_posture_source')
|
||||
->label('Commercial source')
|
||||
->content(fn (): string => $this->commercialPostureSourceText()),
|
||||
Placeholder::make('commercial_posture_state')
|
||||
->label('Commercial state')
|
||||
->content(fn (): string => $this->commercialPostureStateText()),
|
||||
Placeholder::make('commercial_posture_timing')
|
||||
->label('Commercial timing')
|
||||
->content(fn (): string => $this->commercialPostureTimingText()),
|
||||
Placeholder::make('commercial_posture_reason')
|
||||
->label('Explanation')
|
||||
->content(fn (): string => $this->commercialPostureReasonText())
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
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)
|
||||
@ -653,6 +677,7 @@ private function loadFormState(): void
|
||||
$this->workspaceOverrides = $workspaceOverrides;
|
||||
$this->resolvedSettings = $resolvedSettings;
|
||||
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
||||
$this->commercialLifecycleSummary = app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
|
||||
|
||||
$this->loadDomainLastModified();
|
||||
}
|
||||
@ -945,6 +970,43 @@ private function entitlementSourceLabel(array $decision): string
|
||||
return 'plan profile default';
|
||||
}
|
||||
|
||||
private function commercialPostureSourceText(): string
|
||||
{
|
||||
return ($this->commercialLifecycleSummary['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
|
||||
}
|
||||
|
||||
private function commercialPostureStateText(): string
|
||||
{
|
||||
return (string) ($this->commercialLifecycleSummary['subscription_state_label']
|
||||
?? $this->commercialLifecycleSummary['state_label']
|
||||
?? 'Active paid');
|
||||
}
|
||||
|
||||
private function commercialPostureTimingText(): string
|
||||
{
|
||||
$label = $this->commercialLifecycleSummary['subscription_key_date_label'] ?? null;
|
||||
$date = $this->commercialLifecycleSummary['subscription_key_date'] ?? null;
|
||||
|
||||
if (is_string($label) && $label !== '' && $date instanceof Carbon) {
|
||||
return sprintf('%s: %s', $label, $date->toDayDateTimeString());
|
||||
}
|
||||
|
||||
return 'No scheduled commercial date recorded.';
|
||||
}
|
||||
|
||||
private function commercialPostureReasonText(): string
|
||||
{
|
||||
$reason = $this->commercialLifecycleSummary['rationale'] ?? null;
|
||||
|
||||
if (is_string($reason) && $reason !== '') {
|
||||
return $reason;
|
||||
}
|
||||
|
||||
return ($this->commercialLifecycleSummary['fallback_status'] ?? true)
|
||||
? 'No current subscription record is stored. The workspace is using fallback lifecycle truth.'
|
||||
: 'No explicit commercial explanation recorded.';
|
||||
}
|
||||
|
||||
private function helperTextFor(string $field): string
|
||||
{
|
||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||
|
||||
@ -4570,15 +4570,17 @@ private function completionSummaryEntitlementSummary(): string
|
||||
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
|
||||
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
|
||||
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
|
||||
$commercialSourceLabel = $this->completionSummaryCommercialSourceLabel($decision);
|
||||
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
|
||||
|
||||
return sprintf(
|
||||
'%s - %s - %d active of %d allowed (%s)',
|
||||
'%s - %s - %d active of %d allowed (%s, %s)',
|
||||
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
|
||||
$stateLabel,
|
||||
$currentUsage,
|
||||
$effectiveValue,
|
||||
$sourceLabel,
|
||||
$commercialSourceLabel,
|
||||
);
|
||||
}
|
||||
|
||||
@ -4640,6 +4642,16 @@ private function completionSummaryEntitlementSourceLabel(array $decision): strin
|
||||
: 'plan profile default';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decision
|
||||
*/
|
||||
private function completionSummaryCommercialSourceLabel(array $decision): string
|
||||
{
|
||||
return ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION
|
||||
? 'subscription-backed'
|
||||
: 'fallback-backed';
|
||||
}
|
||||
|
||||
private function completionActionTooltip(): ?string
|
||||
{
|
||||
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
|
||||
|
||||
@ -9,8 +9,10 @@
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||
@ -19,10 +21,14 @@
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewWorkspace extends Page
|
||||
@ -114,11 +120,123 @@ public function workspaceCommercialLifecycleSummary(): array
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('update_subscription_truth')
|
||||
->label('Update subscription truth')
|
||||
->icon('heroicon-o-credit-card')
|
||||
->visible(fn (): bool => $this->canManageCommercialLifecycle())
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Update subscription truth')
|
||||
->modalDescription('This records the current subscription-backed commercial truth for the workspace and becomes the upstream lifecycle source while the record exists.')
|
||||
->form([
|
||||
Select::make('state')
|
||||
->label('Subscription state')
|
||||
->options(WorkspaceSubscriptionResolver::stateLabels())
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(function (Set $set, ?string $state): void {
|
||||
$normalizedState = $this->normalizeSubscriptionState($state);
|
||||
|
||||
if ($normalizedState !== WorkspaceSubscription::STATE_TRIAL) {
|
||||
$set('trial_ends_at', null);
|
||||
}
|
||||
|
||||
if (! in_array($normalizedState, [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
], true)) {
|
||||
$set('current_period_starts_at', null);
|
||||
}
|
||||
|
||||
if (! in_array($normalizedState, [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
WorkspaceSubscription::STATE_ENDED,
|
||||
], true)) {
|
||||
$set('current_period_ends_at', null);
|
||||
}
|
||||
})
|
||||
->helperText('Trial requires a trial end date. Active, past due, cancellation pending, and ended states require the matching current-period dates.')
|
||||
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->state),
|
||||
TextInput::make('billing_reference')
|
||||
->label('Billing reference')
|
||||
->maxLength(191)
|
||||
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->billing_reference),
|
||||
DateTimePicker::make('trial_ends_at')
|
||||
->label('Trial ends at')
|
||||
->required(fn (Get $get): bool => $this->selectedSubscriptionState($get) === WorkspaceSubscription::STATE_TRIAL)
|
||||
->visible(fn (Get $get): bool => $this->selectedSubscriptionState($get) === WorkspaceSubscription::STATE_TRIAL)
|
||||
->helperText('Required when the subscription state is Trial.')
|
||||
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->trial_ends_at?->toDateTimeString()),
|
||||
DateTimePicker::make('current_period_starts_at')
|
||||
->label('Current period starts at')
|
||||
->required(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
], true))
|
||||
->visible(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
], true))
|
||||
->helperText('Required for Active, Past due, and Cancellation pending subscriptions.')
|
||||
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->current_period_starts_at?->toDateTimeString()),
|
||||
DateTimePicker::make('current_period_ends_at')
|
||||
->label('Current period ends at')
|
||||
->required(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
WorkspaceSubscription::STATE_ENDED,
|
||||
], true))
|
||||
->visible(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
WorkspaceSubscription::STATE_ENDED,
|
||||
], true))
|
||||
->helperText('Required for Active, Past due, Cancellation pending, and Ended subscriptions.')
|
||||
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->current_period_ends_at?->toDateTimeString()),
|
||||
Textarea::make('status_reason')
|
||||
->label('Status reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4)
|
||||
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->status_reason),
|
||||
])
|
||||
->action(function (array $data, SettingsWriter $settingsWriter): void {
|
||||
$actor = auth('platform')->user();
|
||||
|
||||
if (! $actor instanceof PlatformUser) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$settingsWriter->updateWorkspaceSubscription(
|
||||
actor: $actor,
|
||||
workspace: $this->workspace,
|
||||
attributes: $data,
|
||||
);
|
||||
|
||||
$this->workspace = $this->workspace->fresh()->loadCount('tenants');
|
||||
|
||||
Notification::make()
|
||||
->title('Subscription truth updated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('change_commercial_state')
|
||||
->label('Change commercial state')
|
||||
->icon('heroicon-o-adjustments-horizontal')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => $this->canManageCommercialLifecycle())
|
||||
->visible(fn (): bool => $this->canManageCommercialLifecycle()
|
||||
&& (bool) ($this->workspaceCommercialLifecycleSummary()['fallback_status'] ?? true))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Change commercial state')
|
||||
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
|
||||
@ -171,6 +289,27 @@ private function canManageCommercialLifecycle(): bool
|
||||
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
||||
}
|
||||
|
||||
private function currentWorkspaceSubscription(): ?WorkspaceSubscription
|
||||
{
|
||||
$this->workspace->loadMissing('subscription');
|
||||
|
||||
return $this->workspace->subscription;
|
||||
}
|
||||
|
||||
private function selectedSubscriptionState(Get $get): string
|
||||
{
|
||||
return $this->normalizeSubscriptionState($get('state'));
|
||||
}
|
||||
|
||||
private function normalizeSubscriptionState(mixed $state): string
|
||||
{
|
||||
$normalizedState = is_string($state) ? trim($state) : '';
|
||||
|
||||
return in_array($normalizedState, WorkspaceSubscription::stateIds(), true)
|
||||
? $normalizedState
|
||||
: WorkspaceSubscription::STATE_ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* overall: array{label: string, color: string, icon: string|null},
|
||||
|
||||
@ -132,11 +132,26 @@ public function handle(
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'total' => count($policyIds),
|
||||
'items' => count($policyIds),
|
||||
]);
|
||||
$existingCounts = is_array($this->operationRun->summary_counts ?? null)
|
||||
? $this->operationRun->summary_counts
|
||||
: [];
|
||||
$basePolicyCount = count($policyIds);
|
||||
|
||||
$this->operationRun = $operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'running',
|
||||
outcome: 'pending',
|
||||
summaryCounts: array_merge($existingCounts, [
|
||||
'total' => max((int) ($existingCounts['total'] ?? 0), $basePolicyCount),
|
||||
'items' => max((int) ($existingCounts['items'] ?? 0), $basePolicyCount),
|
||||
'processed' => (int) ($existingCounts['processed'] ?? 0),
|
||||
'succeeded' => (int) ($existingCounts['succeeded'] ?? 0),
|
||||
'failed' => (int) ($existingCounts['failed'] ?? 0),
|
||||
'skipped' => (int) ($existingCounts['skipped'] ?? 0),
|
||||
'created' => (int) ($existingCounts['created'] ?? 0),
|
||||
'updated' => (int) ($existingCounts['updated'] ?? 0),
|
||||
]),
|
||||
);
|
||||
|
||||
if ($policyIds === []) {
|
||||
$operationRunService->updateRun(
|
||||
|
||||
@ -57,8 +57,21 @@ public function handle(OperationRunService $runs): void
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$ids = $this->normalizeIds($this->backupSetIds);
|
||||
$existingCounts = is_array($this->operationRun->summary_counts ?? null)
|
||||
? $this->operationRun->summary_counts
|
||||
: [];
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
$this->operationRun = $runs->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'running',
|
||||
summaryCounts: [
|
||||
'total' => max((int) ($existingCounts['total'] ?? 0), count($ids)),
|
||||
'processed' => (int) ($existingCounts['processed'] ?? 0),
|
||||
'succeeded' => (int) ($existingCounts['succeeded'] ?? 0),
|
||||
'failed' => (int) ($existingCounts['failed'] ?? 0),
|
||||
'skipped' => (int) ($existingCounts['skipped'] ?? 0),
|
||||
],
|
||||
);
|
||||
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
@ -161,6 +161,15 @@ public function handle(
|
||||
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
|
||||
],
|
||||
);
|
||||
$context['progress'] = array_merge(
|
||||
is_array($context['progress'] ?? null) ? $context['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'preparing',
|
||||
'label' => 'Preparing baseline capture.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
@ -284,6 +293,19 @@ public function handle(
|
||||
$resumeToken = null;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
$processingContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$processingContext['progress'] = array_merge(
|
||||
is_array($processingContext['progress'] ?? null) ? $processingContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'processing',
|
||||
'label' => 'Capturing evidence.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $processingContext]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$budgets = [
|
||||
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
||||
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
||||
@ -348,6 +370,19 @@ public function handle(
|
||||
],
|
||||
];
|
||||
|
||||
$persistingContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$persistingContext['progress'] = array_merge(
|
||||
is_array($persistingContext['progress'] ?? null) ? $persistingContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'persisting',
|
||||
'label' => 'Saving baseline snapshot.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $persistingContext]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
$snapshotResult = $this->captureNoDataSnapshotArtifact(
|
||||
$profile,
|
||||
@ -395,6 +430,15 @@ public function handle(
|
||||
'current_baseline_changed' => false,
|
||||
],
|
||||
);
|
||||
$updatedContext['progress'] = array_merge(
|
||||
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'finalizing',
|
||||
'label' => 'Finalizing baseline capture.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->operationRun->update([
|
||||
'context' => $updatedContext,
|
||||
@ -496,6 +540,15 @@ public function handle(
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
'current_baseline_changed' => $currentBaselineChanged,
|
||||
];
|
||||
$updatedContext['progress'] = array_merge(
|
||||
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'finalizing',
|
||||
'label' => 'Finalizing baseline capture.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
$this->auditCompleted(
|
||||
|
||||
@ -338,6 +338,15 @@ public function handle(
|
||||
|
||||
$strategySelection = $compareStrategyRegistry->select($effectiveScope);
|
||||
$context = $this->withCompareStrategySelection($context, $strategySelection);
|
||||
$context['progress'] = array_merge(
|
||||
is_array($context['progress'] ?? null) ? $context['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'preparing',
|
||||
'label' => 'Preparing baseline comparison.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
@ -429,6 +438,19 @@ public function handle(
|
||||
$resumeToken = null;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
$processingContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$processingContext['progress'] = array_merge(
|
||||
is_array($processingContext['progress'] ?? null) ? $processingContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'processing',
|
||||
'label' => 'Refreshing comparison evidence.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $processingContext]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$budgets = [
|
||||
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
||||
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
||||
@ -512,6 +534,19 @@ public function handle(
|
||||
launchContext: is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||
);
|
||||
|
||||
$comparePhaseContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$comparePhaseContext['progress'] = array_merge(
|
||||
is_array($comparePhaseContext['progress'] ?? null) ? $comparePhaseContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'processing',
|
||||
'label' => 'Evaluating baseline drift.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$this->operationRun->update(['context' => $comparePhaseContext]);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
try {
|
||||
$compareResult = $strategy->compare(
|
||||
context: $orchestrationContext,
|
||||
@ -694,6 +729,15 @@ public function handle(
|
||||
'counts_by_change_type' => $countsByChangeType,
|
||||
],
|
||||
);
|
||||
$updatedContext['progress'] = array_merge(
|
||||
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'finalizing',
|
||||
'label' => 'Finalizing baseline comparison.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$updatedContext['result'] = [
|
||||
'findings_total' => count($driftResults),
|
||||
'findings_upserted' => (int) $upsertResult['processed_count'],
|
||||
@ -944,6 +988,15 @@ private function completeWithCoverageWarning(
|
||||
'counts_by_change_type' => [],
|
||||
],
|
||||
);
|
||||
$updatedContext['progress'] = array_merge(
|
||||
is_array($updatedContext['progress'] ?? null) ? $updatedContext['progress'] : [],
|
||||
[
|
||||
'phase' => [
|
||||
'key' => 'finalizing',
|
||||
'label' => 'Finalizing baseline comparison.',
|
||||
],
|
||||
],
|
||||
);
|
||||
$updatedContext['result'] = [
|
||||
'findings_total' => 0,
|
||||
'findings_upserted' => 0,
|
||||
|
||||
@ -42,6 +42,21 @@ public function handle(EvidenceSnapshotService $service, OperationRunService $op
|
||||
|
||||
try {
|
||||
$payload = $service->buildSnapshotPayload($snapshot->tenant);
|
||||
$itemCount = count($payload['items']);
|
||||
|
||||
$operationRun = $operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'total' => $itemCount,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$previousActive = EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $snapshot->tenant_id)
|
||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||
@ -67,6 +82,11 @@ public function handle(EvidenceSnapshotService $service, OperationRunService $op
|
||||
'summary_payload' => $item['summary_payload'],
|
||||
'sort_order' => $item['sort_order'],
|
||||
]);
|
||||
|
||||
$operationRun = $operationRuns->incrementSummaryCounts($operationRun, [
|
||||
'processed' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($previousActive instanceof EvidenceSnapshot && $previousActive->fingerprint !== $payload['fingerprint']) {
|
||||
@ -89,7 +109,9 @@ public function handle(EvidenceSnapshotService $service, OperationRunService $op
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'total' => $itemCount,
|
||||
'processed' => $itemCount,
|
||||
'created' => $itemCount,
|
||||
'finding_count' => (int) ($payload['summary']['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($payload['summary']['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($payload['summary']['operation_count'] ?? 0),
|
||||
|
||||
@ -121,12 +121,31 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
);
|
||||
$fileCount = count($fileMap);
|
||||
|
||||
$operationRun = $operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'total' => $fileCount,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
// 7. Assemble ZIP
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
|
||||
try {
|
||||
$this->assembleZip($tempFile, $fileMap);
|
||||
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
|
||||
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
|
||||
'processed' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
// 8. Compute SHA-256
|
||||
$sha256 = hash_file('sha256', $tempFile);
|
||||
@ -184,7 +203,14 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $summary,
|
||||
summaryCounts: [
|
||||
'total' => $fileCount,
|
||||
'processed' => $fileCount,
|
||||
'created' => $fileCount,
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -210,11 +236,30 @@ private function executeReviewDerivedGeneration(
|
||||
includeOperations: $includeOperations,
|
||||
generatedAt: $generatedAt,
|
||||
);
|
||||
$fileCount = count($fileMap);
|
||||
|
||||
$operationRun = $operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'total' => $fileCount,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
|
||||
try {
|
||||
$this->assembleZip($tempFile, $fileMap);
|
||||
$this->assembleZip($tempFile, $fileMap, function () use ($operationRunService, &$operationRun): void {
|
||||
$operationRun = $operationRunService->incrementSummaryCounts($operationRun, [
|
||||
'processed' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
$sha256 = hash_file('sha256', $tempFile);
|
||||
$fileSize = filesize($tempFile);
|
||||
@ -280,7 +325,9 @@ private function executeReviewDerivedGeneration(
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'total' => $fileCount,
|
||||
'processed' => $fileCount,
|
||||
'created' => $fileCount,
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
@ -563,7 +610,7 @@ private function classifier(): SecretClassificationService
|
||||
*
|
||||
* @param array<string, string> $fileMap
|
||||
*/
|
||||
private function assembleZip(string $tempFile, array $fileMap): void
|
||||
private function assembleZip(string $tempFile, array $fileMap, ?callable $afterWrite = null): void
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
@ -577,6 +624,8 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
|
||||
foreach ($fileMap as $filename => $content) {
|
||||
$zip->addFromString($filename, $content);
|
||||
|
||||
$afterWrite && $afterWrite();
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
@ -94,6 +94,19 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
||||
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
|
||||
: array_values(array_diff($policyTypes, $foundationTypes));
|
||||
|
||||
$this->operationRun = $operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
summaryCounts: [
|
||||
'total' => count($attemptedTypes),
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
],
|
||||
);
|
||||
|
||||
$processedPolicyTypes = [];
|
||||
$coverageStatusByType = [];
|
||||
$successCount = 0;
|
||||
@ -103,7 +116,7 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
||||
$this->operationRun,
|
||||
$tenant,
|
||||
$context,
|
||||
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
||||
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use ($operationRunService, &$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
||||
$processedPolicyTypes[] = $policyType;
|
||||
$coverageStatusByType[$policyType] = array_filter([
|
||||
'status' => $success
|
||||
@ -116,10 +129,20 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
||||
if ($success) {
|
||||
$successCount++;
|
||||
|
||||
$this->operationRun = $operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$failedCount++;
|
||||
|
||||
$this->operationRun = $operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@ -210,6 +233,7 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
||||
$itemsObserved = (int) ($result['items_observed_count'] ?? 0);
|
||||
$itemsUpserted = (int) ($result['items_upserted_count'] ?? 0);
|
||||
$errorsCount = (int) ($result['errors_count'] ?? 0);
|
||||
$attemptedTypeCount = count($attemptedTypes);
|
||||
|
||||
if ($status === 'success') {
|
||||
$operationRunService->updateRun(
|
||||
@ -217,9 +241,9 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => count($policyTypes),
|
||||
'total' => $attemptedTypeCount,
|
||||
'processed' => $attemptedTypeCount,
|
||||
'succeeded' => $attemptedTypeCount,
|
||||
'failed' => 0,
|
||||
'items' => $itemsObserved,
|
||||
'updated' => $itemsUpserted,
|
||||
@ -247,15 +271,17 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
||||
}
|
||||
|
||||
if ($status === 'partial') {
|
||||
$failedUnits = max($failedCount, $errorsCount);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => max(0, count($policyTypes) - $errorsCount),
|
||||
'failed' => $errorsCount,
|
||||
'total' => $attemptedTypeCount,
|
||||
'processed' => $attemptedTypeCount,
|
||||
'succeeded' => max(0, $attemptedTypeCount - $failedUnits),
|
||||
'failed' => $failedUnits,
|
||||
'items' => $itemsObserved,
|
||||
'updated' => $itemsUpserted,
|
||||
],
|
||||
@ -292,11 +318,11 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'total' => $attemptedTypeCount,
|
||||
'processed' => $attemptedTypeCount,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => count($policyTypes),
|
||||
'skipped' => $attemptedTypeCount,
|
||||
],
|
||||
failures: [
|
||||
['code' => 'inventory.skipped', 'message' => $reason],
|
||||
@ -322,17 +348,18 @@ function (string $policyType, bool $success, ?string $errorCode, int $itemCount)
|
||||
return;
|
||||
}
|
||||
|
||||
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
|
||||
$missingPolicyTypes = array_values(array_diff($attemptedTypes, array_unique($processedPolicyTypes)));
|
||||
$failedUnits = $failedCount + count($missingPolicyTypes);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'total' => $attemptedTypeCount,
|
||||
'processed' => $successCount + $failedUnits,
|
||||
'succeeded' => $successCount,
|
||||
'failed' => max($failedCount, count($missingPolicyTypes)),
|
||||
'failed' => $failedUnits,
|
||||
],
|
||||
failures: [
|
||||
['code' => 'inventory.failed', 'reason_code' => $reasonCode, 'message' => $reason],
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class Workspace extends Model
|
||||
{
|
||||
@ -49,6 +50,14 @@ public function settings(): HasMany
|
||||
return $this->hasMany(WorkspaceSetting::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<WorkspaceSubscription, $this>
|
||||
*/
|
||||
public function subscription(): HasOne
|
||||
{
|
||||
return $this->hasOne(WorkspaceSubscription::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TenantSetting, $this>
|
||||
*/
|
||||
|
||||
57
apps/platform/app/Models/WorkspaceSubscription.php
Normal file
57
apps/platform/app/Models/WorkspaceSubscription.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkspaceSubscription extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATE_TRIAL = 'trial';
|
||||
|
||||
public const STATE_ACTIVE = 'active';
|
||||
|
||||
public const STATE_PAST_DUE = 'past_due';
|
||||
|
||||
public const STATE_CANCEL_AT_PERIOD_END = 'cancel_at_period_end';
|
||||
|
||||
public const STATE_ENDED = 'ended';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function stateIds(): array
|
||||
{
|
||||
return [
|
||||
self::STATE_TRIAL,
|
||||
self::STATE_ACTIVE,
|
||||
self::STATE_PAST_DUE,
|
||||
self::STATE_CANCEL_AT_PERIOD_END,
|
||||
self::STATE_ENDED,
|
||||
];
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'trial_ends_at' => 'datetime',
|
||||
'current_period_starts_at' => 'datetime',
|
||||
'current_period_ends_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
}
|
||||
@ -84,6 +84,12 @@ public function startCapture(
|
||||
'entra_tenant_id' => $sourceTenant->graphTenantId(),
|
||||
'entra_tenant_name' => (string) $sourceTenant->name,
|
||||
],
|
||||
'progress' => [
|
||||
'phase' => [
|
||||
'key' => 'preparing',
|
||||
'label' => 'Preparing baseline capture.',
|
||||
],
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||
|
||||
@ -144,6 +144,12 @@ public function startCompareForProfile(
|
||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||
'entra_tenant_name' => (string) $tenant->name,
|
||||
],
|
||||
'progress' => [
|
||||
'phase' => [
|
||||
'key' => 'preparing',
|
||||
'label' => 'Preparing baseline comparison.',
|
||||
],
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => $snapshotId,
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
@ -32,6 +31,8 @@ final class WorkspaceCommercialLifecycleResolver
|
||||
|
||||
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
|
||||
|
||||
public const SOURCE_WORKSPACE_SUBSCRIPTION = WorkspaceSubscriptionResolver::SOURCE_WORKSPACE_SUBSCRIPTION;
|
||||
|
||||
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
|
||||
|
||||
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
|
||||
@ -55,8 +56,8 @@ final class WorkspaceCommercialLifecycleResolver
|
||||
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingsResolver $settingsResolver,
|
||||
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
||||
private readonly WorkspaceSubscriptionResolver $workspaceSubscriptionResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -132,46 +133,37 @@ public function summary(Workspace $workspace): array
|
||||
*/
|
||||
public function resolve(Workspace $workspace): array
|
||||
{
|
||||
$stateSetting = $this->settingsResolver->resolveDetailed(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
||||
);
|
||||
$subscriptionSummary = $this->workspaceSubscriptionResolver->summary($workspace);
|
||||
|
||||
$rawState = is_string($stateSetting['value'] ?? null)
|
||||
? strtolower(trim((string) $stateSetting['value']))
|
||||
: null;
|
||||
|
||||
$state = in_array($rawState, self::stateIds(), true)
|
||||
? $rawState
|
||||
: self::STATE_ACTIVE_PAID;
|
||||
|
||||
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
|
||||
? self::SOURCE_WORKSPACE_SETTING
|
||||
: self::SOURCE_DEFAULT_ACTIVE_PAID;
|
||||
|
||||
$rationale = $this->settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: self::SETTING_DOMAIN,
|
||||
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
||||
);
|
||||
$state = (string) $subscriptionSummary['derived_lifecycle_state'];
|
||||
$source = (string) $subscriptionSummary['source'];
|
||||
|
||||
$labels = self::stateLabels();
|
||||
$descriptions = self::stateDescriptions();
|
||||
$lastChanged = $this->lastChangedMetadata($workspace);
|
||||
$lastChanged = $this->lastChangedMetadata($workspace, $source);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => $state,
|
||||
'state_label' => $labels[$state],
|
||||
'source' => $source,
|
||||
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
|
||||
? 'workspace setting'
|
||||
: 'default active paid',
|
||||
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
|
||||
'source_label' => match ($source) {
|
||||
self::SOURCE_WORKSPACE_SUBSCRIPTION => 'workspace subscription',
|
||||
self::SOURCE_WORKSPACE_SETTING => 'workspace setting',
|
||||
default => 'default active paid',
|
||||
},
|
||||
'rationale' => $subscriptionSummary['status_reason'] ?? null,
|
||||
'description' => $descriptions[$state],
|
||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
||||
'subscription_present' => (bool) ($subscriptionSummary['subscription_present'] ?? false),
|
||||
'fallback_status' => (bool) ($subscriptionSummary['fallback_status'] ?? true),
|
||||
'subscription_state' => $subscriptionSummary['state'] ?? null,
|
||||
'subscription_state_label' => $subscriptionSummary['label'] ?? null,
|
||||
'subscription_billing_reference' => $subscriptionSummary['billing_reference'] ?? null,
|
||||
'subscription_key_date_label' => $subscriptionSummary['key_date_label'] ?? null,
|
||||
'subscription_key_date' => $subscriptionSummary['key_date'] ?? null,
|
||||
'subscription_needs_review' => (bool) ($subscriptionSummary['needs_review'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
@ -231,7 +223,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
|
||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'New managed-tenant activation is frozen while this workspace is in grace.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
||||
@ -239,7 +231,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
|
||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
default => $this->decision(
|
||||
@ -247,7 +239,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
|
||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
||||
outcome: self::OUTCOME_ALLOW,
|
||||
reasonFamily: null,
|
||||
message: 'Managed-tenant activation is available for this workspace commercial state.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'Managed-tenant activation is available for this workspace commercial state.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
};
|
||||
@ -281,7 +273,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
|
||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||
outcome: self::OUTCOME_WARN,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
||||
@ -289,7 +281,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
|
||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||
outcome: self::OUTCOME_BLOCK,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
default => $this->decision(
|
||||
@ -297,7 +289,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
|
||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
||||
outcome: self::OUTCOME_ALLOW,
|
||||
reasonFamily: null,
|
||||
message: 'Review-pack starts are available for this workspace commercial state.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'Review-pack starts are available for this workspace commercial state.'),
|
||||
substrateDecision: $substrateDecision,
|
||||
),
|
||||
};
|
||||
@ -315,7 +307,7 @@ private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
||||
actionKey: $actionKey,
|
||||
outcome: self::OUTCOME_ALLOW_READ_ONLY,
|
||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
||||
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.'),
|
||||
substrateDecision: null,
|
||||
);
|
||||
}
|
||||
@ -325,11 +317,29 @@ private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
||||
actionKey: $actionKey,
|
||||
outcome: self::OUTCOME_ALLOW,
|
||||
reasonFamily: null,
|
||||
message: 'Read-only history remains available under current RBAC.',
|
||||
message: $this->lifecycleMessage($lifecycle, 'Read-only history remains available under current RBAC.'),
|
||||
substrateDecision: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lifecycle
|
||||
*/
|
||||
private function lifecycleMessage(array $lifecycle, string $message): string
|
||||
{
|
||||
return sprintf('%s Commercial source: %s.', $message, $this->commercialSourceDescriptor($lifecycle));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lifecycle
|
||||
*/
|
||||
private function commercialSourceDescriptor(array $lifecycle): string
|
||||
{
|
||||
return ($lifecycle['source'] ?? null) === self::SOURCE_WORKSPACE_SUBSCRIPTION
|
||||
? 'subscription-backed'
|
||||
: 'fallback-backed';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lifecycle
|
||||
* @param array<string, mixed>|null $substrateDecision
|
||||
@ -365,8 +375,34 @@ private function decision(
|
||||
/**
|
||||
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
||||
*/
|
||||
private function lastChangedMetadata(Workspace $workspace): array
|
||||
private function lastChangedMetadata(Workspace $workspace, string $source): array
|
||||
{
|
||||
if ($source === self::SOURCE_WORKSPACE_SUBSCRIPTION) {
|
||||
$audit = AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::WorkspaceSubscriptionUpdated->value)
|
||||
->where('resource_type', 'workspace_subscription')
|
||||
->latest('recorded_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($audit instanceof AuditLog) {
|
||||
return [
|
||||
'last_changed_at' => $audit->recorded_at,
|
||||
'last_changed_by' => $audit->actorDisplayLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
$workspace->loadMissing('subscription');
|
||||
|
||||
if ($workspace->subscription !== null) {
|
||||
return [
|
||||
'last_changed_at' => $workspace->subscription->updated_at,
|
||||
'last_changed_by' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
|
||||
|
||||
@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Entitlements;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
final class WorkspaceSubscriptionResolver
|
||||
{
|
||||
public const SOURCE_WORKSPACE_SUBSCRIPTION = 'workspace_subscription';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function stateLabels(): array
|
||||
{
|
||||
return [
|
||||
WorkspaceSubscription::STATE_TRIAL => 'Trial',
|
||||
WorkspaceSubscription::STATE_ACTIVE => 'Active',
|
||||
WorkspaceSubscription::STATE_PAST_DUE => 'Past due',
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END => 'Cancellation pending',
|
||||
WorkspaceSubscription::STATE_ENDED => 'Ended',
|
||||
];
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingsResolver $settingsResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* subscription_present: bool,
|
||||
* state: string|null,
|
||||
* label: string|null,
|
||||
* billing_reference: string|null,
|
||||
* status_reason: string|null,
|
||||
* key_date_label: string|null,
|
||||
* key_date: CarbonInterface|null,
|
||||
* needs_review: bool,
|
||||
* source: string,
|
||||
* fallback_status: bool,
|
||||
* derived_lifecycle_state: string
|
||||
* }
|
||||
*/
|
||||
public function summary(Workspace $workspace): array
|
||||
{
|
||||
$workspace->loadMissing('subscription');
|
||||
|
||||
$subscription = $workspace->subscription;
|
||||
|
||||
if ($subscription instanceof WorkspaceSubscription) {
|
||||
return $this->subscriptionSummary($workspace, $subscription);
|
||||
}
|
||||
|
||||
return $this->fallbackSummary($workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* subscription_present: bool,
|
||||
* state: string,
|
||||
* label: string,
|
||||
* billing_reference: string|null,
|
||||
* status_reason: string,
|
||||
* key_date_label: string|null,
|
||||
* key_date: CarbonInterface|null,
|
||||
* needs_review: bool,
|
||||
* source: string,
|
||||
* fallback_status: bool,
|
||||
* derived_lifecycle_state: string
|
||||
* }
|
||||
*/
|
||||
private function subscriptionSummary(Workspace $workspace, WorkspaceSubscription $subscription): array
|
||||
{
|
||||
$state = in_array($subscription->state, WorkspaceSubscription::stateIds(), true)
|
||||
? $subscription->state
|
||||
: WorkspaceSubscription::STATE_ACTIVE;
|
||||
|
||||
$keyDate = $this->keyDateForSubscription($subscription, $state);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'subscription_present' => true,
|
||||
'state' => $state,
|
||||
'label' => self::stateLabels()[$state],
|
||||
'billing_reference' => $subscription->billing_reference,
|
||||
'status_reason' => $subscription->status_reason,
|
||||
'key_date_label' => $this->keyDateLabel($state),
|
||||
'key_date' => $keyDate,
|
||||
'needs_review' => $this->needsReview($state, $keyDate),
|
||||
'source' => self::SOURCE_WORKSPACE_SUBSCRIPTION,
|
||||
'fallback_status' => false,
|
||||
'derived_lifecycle_state' => $this->derivedLifecycleState($state),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* workspace_id: int,
|
||||
* subscription_present: bool,
|
||||
* state: null,
|
||||
* label: null,
|
||||
* billing_reference: null,
|
||||
* status_reason: string|null,
|
||||
* key_date_label: null,
|
||||
* key_date: null,
|
||||
* needs_review: bool,
|
||||
* source: string,
|
||||
* fallback_status: bool,
|
||||
* derived_lifecycle_state: string
|
||||
* }
|
||||
*/
|
||||
private function fallbackSummary(Workspace $workspace): array
|
||||
{
|
||||
$stateSetting = $this->settingsResolver->resolveDetailed(
|
||||
workspace: $workspace,
|
||||
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
||||
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
||||
);
|
||||
|
||||
$rawState = is_string($stateSetting['value'] ?? null)
|
||||
? strtolower(trim((string) $stateSetting['value']))
|
||||
: null;
|
||||
|
||||
$derivedLifecycleState = in_array($rawState, WorkspaceCommercialLifecycleResolver::stateIds(), true)
|
||||
? $rawState
|
||||
: WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID;
|
||||
|
||||
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
|
||||
? WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING
|
||||
: WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID;
|
||||
|
||||
$statusReason = $this->settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
||||
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
||||
);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'subscription_present' => false,
|
||||
'state' => null,
|
||||
'label' => null,
|
||||
'billing_reference' => null,
|
||||
'status_reason' => is_string($statusReason) && trim($statusReason) !== '' ? trim($statusReason) : null,
|
||||
'key_date_label' => null,
|
||||
'key_date' => null,
|
||||
'needs_review' => false,
|
||||
'source' => $source,
|
||||
'fallback_status' => true,
|
||||
'derived_lifecycle_state' => $derivedLifecycleState,
|
||||
];
|
||||
}
|
||||
|
||||
private function derivedLifecycleState(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
WorkspaceSubscription::STATE_TRIAL => WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
|
||||
WorkspaceSubscription::STATE_ACTIVE => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||
WorkspaceSubscription::STATE_PAST_DUE => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||
WorkspaceSubscription::STATE_ENDED => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
||||
default => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||
};
|
||||
}
|
||||
|
||||
private function keyDateLabel(string $state): ?string
|
||||
{
|
||||
return match ($state) {
|
||||
WorkspaceSubscription::STATE_TRIAL => 'Trial ends',
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
WorkspaceSubscription::STATE_ENDED => 'Current period ends',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function keyDateForSubscription(WorkspaceSubscription $subscription, string $state): ?CarbonInterface
|
||||
{
|
||||
return match ($state) {
|
||||
WorkspaceSubscription::STATE_TRIAL => $subscription->trial_ends_at,
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
WorkspaceSubscription::STATE_ENDED => $subscription->current_period_ends_at,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function needsReview(string $state, ?CarbonInterface $keyDate): bool
|
||||
{
|
||||
if (! in_array($state, [WorkspaceSubscription::STATE_TRIAL, WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $keyDate instanceof CarbonInterface && $keyDate->isPast();
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
@ -19,6 +20,7 @@
|
||||
use App\Support\Settings\SettingDefinition;
|
||||
use App\Support\Settings\SettingsRegistry;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -135,6 +137,108 @@ public function updateWorkspaceCommercialLifecycle(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function updateWorkspaceSubscription(
|
||||
PlatformUser $actor,
|
||||
Workspace $workspace,
|
||||
array $attributes,
|
||||
): WorkspaceSubscription {
|
||||
$this->authorizeCommercialLifecycleManage($actor);
|
||||
|
||||
$validator = Validator::make($attributes, [
|
||||
'state' => ['required', 'string', 'in:'.implode(',', WorkspaceSubscription::stateIds())],
|
||||
'billing_reference' => ['nullable', 'string', 'max:191'],
|
||||
'trial_ends_at' => ['nullable', 'date'],
|
||||
'current_period_starts_at' => ['nullable', 'date'],
|
||||
'current_period_ends_at' => ['nullable', 'date'],
|
||||
'status_reason' => ['required', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw ValidationException::withMessages($validator->errors()->toArray());
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
$state = (string) $validated['state'];
|
||||
|
||||
if ($state === WorkspaceSubscription::STATE_TRIAL && blank($validated['trial_ends_at'] ?? null)) {
|
||||
throw ValidationException::withMessages([
|
||||
'trial_ends_at' => ['A trial end date is required for trial subscriptions.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($state, [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
], true) && blank($validated['current_period_starts_at'] ?? null)) {
|
||||
throw ValidationException::withMessages([
|
||||
'current_period_starts_at' => ['A current period start date is required for this subscription state.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($state, [
|
||||
WorkspaceSubscription::STATE_ACTIVE,
|
||||
WorkspaceSubscription::STATE_PAST_DUE,
|
||||
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
|
||||
WorkspaceSubscription::STATE_ENDED,
|
||||
], true) && blank($validated['current_period_ends_at'] ?? null)) {
|
||||
throw ValidationException::withMessages([
|
||||
'current_period_ends_at' => ['A current period end date is required for this subscription state.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $workspace, $validated): WorkspaceSubscription {
|
||||
$workspace->loadMissing('subscription');
|
||||
|
||||
$before = $workspace->subscription instanceof WorkspaceSubscription
|
||||
? $this->workspaceSubscriptionAuditPayload($workspace->subscription)
|
||||
: null;
|
||||
|
||||
$subscription = WorkspaceSubscription::query()->updateOrCreate(
|
||||
['workspace_id' => (int) $workspace->getKey()],
|
||||
[
|
||||
'state' => (string) $validated['state'],
|
||||
'billing_reference' => filled($validated['billing_reference'] ?? null)
|
||||
? trim((string) $validated['billing_reference'])
|
||||
: null,
|
||||
'trial_ends_at' => filled($validated['trial_ends_at'] ?? null)
|
||||
? Carbon::parse((string) $validated['trial_ends_at'])
|
||||
: null,
|
||||
'current_period_starts_at' => filled($validated['current_period_starts_at'] ?? null)
|
||||
? Carbon::parse((string) $validated['current_period_starts_at'])
|
||||
: null,
|
||||
'current_period_ends_at' => filled($validated['current_period_ends_at'] ?? null)
|
||||
? Carbon::parse((string) $validated['current_period_ends_at'])
|
||||
: null,
|
||||
'status_reason' => trim((string) $validated['status_reason']),
|
||||
],
|
||||
);
|
||||
|
||||
$workspace->setRelation('subscription', $subscription->fresh());
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::WorkspaceSubscriptionUpdated,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'before' => $before,
|
||||
'after' => $this->workspaceSubscriptionAuditPayload($subscription),
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
resourceType: 'workspace_subscription',
|
||||
resourceId: (string) $subscription->getKey(),
|
||||
targetLabel: 'Current workspace subscription',
|
||||
summary: 'Workspace subscription updated',
|
||||
);
|
||||
|
||||
return $subscription;
|
||||
});
|
||||
}
|
||||
|
||||
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
|
||||
{
|
||||
$this->authorizeManage($actor, $workspace);
|
||||
@ -288,6 +392,13 @@ private function authorizeManage(User $actor, Workspace $workspace): void
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeCommercialLifecycleManage(PlatformUser $actor): void
|
||||
{
|
||||
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
|
||||
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void
|
||||
{
|
||||
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
@ -305,4 +416,19 @@ private function decodeStoredValue(mixed $value): mixed
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function workspaceSubscriptionAuditPayload(WorkspaceSubscription $subscription): array
|
||||
{
|
||||
return [
|
||||
'state' => $subscription->state,
|
||||
'billing_reference' => $subscription->billing_reference,
|
||||
'trial_ends_at' => $subscription->trial_ends_at?->toAtomString(),
|
||||
'current_period_starts_at' => $subscription->current_period_starts_at?->toAtomString(),
|
||||
'current_period_ends_at' => $subscription->current_period_ends_at?->toAtomString(),
|
||||
'status_reason' => $subscription->status_reason,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,6 +170,9 @@ private function queueComposition(
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
'review_id' => $existingReview?->getKey(),
|
||||
'progress' => [
|
||||
'composite' => $this->reviewComposeProgressMetadata($snapshot),
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
@ -247,4 +250,71 @@ private function findExistingMutableReview(Tenant $tenant, string $fingerprint):
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, operation_count: int, failed_count: int, partial_count: int}
|
||||
*/
|
||||
private function reviewComposeProgressMetadata(EvidenceSnapshot $snapshot): array
|
||||
{
|
||||
$snapshot->loadMissing('items');
|
||||
|
||||
$operationsSummary = $snapshot->items
|
||||
->firstWhere('dimension_key', 'operations_summary')
|
||||
?->summary_payload;
|
||||
|
||||
$operationCount = is_numeric(data_get($operationsSummary, 'operation_count'))
|
||||
? (int) data_get($operationsSummary, 'operation_count')
|
||||
: 0;
|
||||
$failedCount = is_numeric(data_get($operationsSummary, 'failed_count'))
|
||||
? (int) data_get($operationsSummary, 'failed_count')
|
||||
: 0;
|
||||
$partialCount = is_numeric(data_get($operationsSummary, 'partial_count'))
|
||||
? (int) data_get($operationsSummary, 'partial_count')
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'label' => $this->reviewComposeProgressLabel($operationCount, $failedCount, $partialCount),
|
||||
'operation_count' => $operationCount,
|
||||
'failed_count' => $failedCount,
|
||||
'partial_count' => $partialCount,
|
||||
];
|
||||
}
|
||||
|
||||
private function reviewComposeProgressLabel(int $operationCount, int $failedCount, int $partialCount): string
|
||||
{
|
||||
$baseLabel = $operationCount > 0
|
||||
? sprintf('Review composition is aggregating %d %s.', $operationCount, $operationCount === 1 ? 'operation' : 'operations')
|
||||
: 'Review composition is aggregating related operations.';
|
||||
|
||||
if ($failedCount > 0 && $partialCount > 0) {
|
||||
return sprintf(
|
||||
'%s %d %s and %d %s currently need review.',
|
||||
$baseLabel,
|
||||
$failedCount,
|
||||
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||
$partialCount,
|
||||
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||
);
|
||||
}
|
||||
|
||||
if ($failedCount > 0) {
|
||||
return sprintf(
|
||||
'%s %d %s currently need review.',
|
||||
$baseLabel,
|
||||
$failedCount,
|
||||
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||
);
|
||||
}
|
||||
|
||||
if ($partialCount > 0) {
|
||||
return sprintf(
|
||||
'%s %d %s currently need review.',
|
||||
$baseLabel,
|
||||
$partialCount,
|
||||
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||
);
|
||||
}
|
||||
|
||||
return $baseLabel;
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +61,7 @@ enum AuditActionId: string
|
||||
|
||||
case WorkspaceSettingUpdated = 'workspace_setting.updated';
|
||||
case WorkspaceSettingReset = 'workspace_setting.reset';
|
||||
case WorkspaceSubscriptionUpdated = 'workspace_subscription.updated';
|
||||
|
||||
case BaselineProfileCreated = 'baseline_profile.created';
|
||||
case BaselineProfileUpdated = 'baseline_profile.updated';
|
||||
@ -218,6 +219,7 @@ private static function labels(): array
|
||||
self::AlertRuleDisabled->value => 'Alert rule disabled',
|
||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
@ -325,6 +327,7 @@ private static function summaries(): array
|
||||
self::PolicyProviderMissingCleared->value => 'Policy provider presence restored',
|
||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
|
||||
414
apps/platform/app/Support/OpsUx/OperationRunProgressContract.php
Normal file
414
apps/platform/app/Support/OpsUx/OperationRunProgressContract.php
Normal file
@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
final class OperationRunProgressContract
|
||||
{
|
||||
public const string NONE = 'none';
|
||||
|
||||
public const string ACTIVITY = 'activity';
|
||||
|
||||
public const string COUNTED = 'counted';
|
||||
|
||||
public const string PHASED = 'phased';
|
||||
|
||||
public const string COMPOSITE = 'composite';
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* capability: string,
|
||||
* display: string,
|
||||
* label: ?string,
|
||||
* processed: ?int,
|
||||
* total: ?int,
|
||||
* percent: ?int
|
||||
* }
|
||||
*/
|
||||
public static function forRun(OperationRun $run): array
|
||||
{
|
||||
$summaryCounts = SummaryCountsNormalizer::normalize(
|
||||
is_array($run->summary_counts) ? $run->summary_counts : [],
|
||||
);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$capability = self::capabilityForRun($run, $summaryCounts, $context);
|
||||
|
||||
return match ($capability) {
|
||||
self::COUNTED => self::countedModel($summaryCounts),
|
||||
self::PHASED => self::phasedModel($run, $context),
|
||||
self::COMPOSITE => self::compositeModel($run, $summaryCounts, $context),
|
||||
self::ACTIVITY => self::indeterminateModel(
|
||||
self::ACTIVITY,
|
||||
(string) $run->status === OperationRunStatus::Queued->value
|
||||
? 'Waiting for worker.'
|
||||
: 'Progress details pending.',
|
||||
),
|
||||
default => self::noneModel(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function capabilityForRun(OperationRun $run, array $summaryCounts, array $context): string
|
||||
{
|
||||
if (! $run->isCurrentlyActive()) {
|
||||
return self::NONE;
|
||||
}
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Queued->value) {
|
||||
return self::ACTIVITY;
|
||||
}
|
||||
|
||||
if (self::hasPhasedHint($context)) {
|
||||
return self::PHASED;
|
||||
}
|
||||
|
||||
if (self::hasCompositeHint($summaryCounts, $context)) {
|
||||
return self::COMPOSITE;
|
||||
}
|
||||
|
||||
if (self::hasCountedHint($summaryCounts)) {
|
||||
return self::COUNTED;
|
||||
}
|
||||
|
||||
return self::ACTIVITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
*/
|
||||
private static function hasCountedHint(array $summaryCounts): bool
|
||||
{
|
||||
$total = $summaryCounts['total'] ?? null;
|
||||
$processed = $summaryCounts['processed'] ?? null;
|
||||
|
||||
return is_int($total)
|
||||
&& $total > 0
|
||||
&& is_int($processed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
* @return array{
|
||||
* capability: string,
|
||||
* display: string,
|
||||
* label: string,
|
||||
* processed: int,
|
||||
* total: int,
|
||||
* percent: int
|
||||
* }
|
||||
*/
|
||||
private static function countedModel(array $summaryCounts): array
|
||||
{
|
||||
$total = max(1, (int) ($summaryCounts['total'] ?? 0));
|
||||
$processed = min(max(0, (int) ($summaryCounts['processed'] ?? 0)), $total);
|
||||
$percent = max(0, min(100, (int) round(($processed / $total) * 100)));
|
||||
|
||||
return [
|
||||
'capability' => self::COUNTED,
|
||||
'display' => self::COUNTED,
|
||||
'label' => sprintf('%d / %d processed (%d%%)', $processed, $total, $percent),
|
||||
'processed' => $processed,
|
||||
'total' => $total,
|
||||
'percent' => $percent,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{
|
||||
* capability: string,
|
||||
* display: string,
|
||||
* label: string,
|
||||
* processed: null,
|
||||
* total: null,
|
||||
* percent: null
|
||||
* }
|
||||
*/
|
||||
private static function phasedModel(OperationRun $run, array $context): array
|
||||
{
|
||||
$phase = self::phaseProgressMetadata($context);
|
||||
|
||||
if ($phase !== null) {
|
||||
return self::indeterminateModel(self::PHASED, $phase['label']);
|
||||
}
|
||||
|
||||
return self::indeterminateModel(
|
||||
self::PHASED,
|
||||
self::legacyPhasedLabel($run, $context) ?? 'Phase progress pending.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{
|
||||
* capability: string,
|
||||
* display: string,
|
||||
* label: string,
|
||||
* processed: null,
|
||||
* total: null,
|
||||
* percent: null
|
||||
* }
|
||||
*/
|
||||
private static function compositeModel(OperationRun $run, array $summaryCounts, array $context): array
|
||||
{
|
||||
$label = self::explicitCompositeLabel($context)
|
||||
?? self::legacyCompositeLabel($run, $summaryCounts, $context)
|
||||
?? 'Composite progress pending.';
|
||||
|
||||
return self::indeterminateModel(self::COMPOSITE, $label);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* capability: string,
|
||||
* display: string,
|
||||
* label: string,
|
||||
* processed: null,
|
||||
* total: null,
|
||||
* percent: null
|
||||
* }
|
||||
*/
|
||||
private static function indeterminateModel(string $capability, string $label): array
|
||||
{
|
||||
return [
|
||||
'capability' => $capability,
|
||||
'display' => 'indeterminate',
|
||||
'label' => $label,
|
||||
'processed' => null,
|
||||
'total' => null,
|
||||
'percent' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* capability: string,
|
||||
* display: string,
|
||||
* label: null,
|
||||
* processed: null,
|
||||
* total: null,
|
||||
* percent: null
|
||||
* }
|
||||
*/
|
||||
private static function noneModel(): array
|
||||
{
|
||||
return [
|
||||
'capability' => self::NONE,
|
||||
'display' => self::NONE,
|
||||
'label' => null,
|
||||
'processed' => null,
|
||||
'total' => null,
|
||||
'percent' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function hasPhasedHint(array $context): bool
|
||||
{
|
||||
if (self::phaseProgressMetadata($context) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (['baseline_capture.evidence_capture', 'baseline_compare.evidence_capture'] as $path) {
|
||||
$phaseStats = data_get($context, $path);
|
||||
|
||||
if (is_array($phaseStats) && self::looksLikePhaseStats($phaseStats)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $phaseStats
|
||||
*/
|
||||
private static function looksLikePhaseStats(array $phaseStats): bool
|
||||
{
|
||||
return count(array_intersect(
|
||||
array_keys($phaseStats),
|
||||
['requested', 'succeeded', 'skipped', 'failed', 'throttled'],
|
||||
)) >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function hasCompositeHint(array $summaryCounts, array $context): bool
|
||||
{
|
||||
if (self::explicitCompositeLabel($context) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$operationCount = $summaryCounts['operation_count'] ?? null;
|
||||
|
||||
if (is_int($operationCount) && $operationCount > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (['child_run_ids', 'operation_run_ids'] as $path) {
|
||||
$runIds = data_get($context, $path);
|
||||
|
||||
if (! is_array($runIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$numericIds = array_filter($runIds, static fn (mixed $runId): bool => is_numeric($runId));
|
||||
|
||||
if (count($numericIds) > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{key: string, label: string}|null
|
||||
*/
|
||||
private static function phaseProgressMetadata(array $context): ?array
|
||||
{
|
||||
$phase = data_get($context, 'progress.phase');
|
||||
|
||||
if (! is_array($phase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = self::cleanString($phase['key'] ?? null);
|
||||
|
||||
if ($key === null || ! in_array($key, ['preparing', 'fetching', 'processing', 'persisting', 'finalizing'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = self::cleanString($phase['label'] ?? null) ?? self::defaultPhaseLabel($key);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
|
||||
private static function defaultPhaseLabel(string $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
'preparing' => 'Preparing work.',
|
||||
'fetching' => 'Collecting required evidence.',
|
||||
'processing' => 'Processing current work.',
|
||||
'persisting' => 'Saving results.',
|
||||
'finalizing' => 'Finalizing operation.',
|
||||
default => 'Phase progress pending.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function explicitCompositeLabel(array $context): ?string
|
||||
{
|
||||
return self::cleanString(data_get($context, 'progress.composite.label'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function legacyPhasedLabel(OperationRun $run, array $context): ?string
|
||||
{
|
||||
return match ((string) $run->type) {
|
||||
'baseline_capture' => is_array(data_get($context, 'baseline_capture.evidence_capture'))
|
||||
? 'Capturing evidence.'
|
||||
: null,
|
||||
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
|
||||
? 'Refreshing comparison evidence.'
|
||||
: null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function legacyCompositeLabel(OperationRun $run, array $summaryCounts, array $context): ?string
|
||||
{
|
||||
if ((string) $run->type !== 'tenant.review.compose') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$operationCount = self::intOrNull($summaryCounts['operation_count'] ?? data_get($context, 'progress.composite.operation_count'));
|
||||
$failedCount = self::intOrNull(data_get($context, 'progress.composite.failed_count'));
|
||||
$partialCount = self::intOrNull(data_get($context, 'progress.composite.partial_count'));
|
||||
|
||||
$baseLabel = $operationCount !== null && $operationCount > 0
|
||||
? sprintf('Review composition is aggregating %d %s.', $operationCount, $operationCount === 1 ? 'operation' : 'operations')
|
||||
: 'Review composition is aggregating related operations.';
|
||||
|
||||
$attentionLabel = self::compositeAttentionLabel($failedCount, $partialCount);
|
||||
|
||||
return $attentionLabel === null
|
||||
? $baseLabel
|
||||
: sprintf('%s %s', $baseLabel, $attentionLabel);
|
||||
}
|
||||
|
||||
private static function compositeAttentionLabel(?int $failedCount, ?int $partialCount): ?string
|
||||
{
|
||||
$failedCount = $failedCount !== null && $failedCount > 0 ? $failedCount : null;
|
||||
$partialCount = $partialCount !== null && $partialCount > 0 ? $partialCount : null;
|
||||
|
||||
if ($failedCount === null && $partialCount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($failedCount !== null && $partialCount !== null) {
|
||||
return sprintf(
|
||||
'%d %s and %d %s currently need review.',
|
||||
$failedCount,
|
||||
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||
$partialCount,
|
||||
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||
);
|
||||
}
|
||||
|
||||
if ($failedCount !== null) {
|
||||
return sprintf(
|
||||
'%d %s currently need review.',
|
||||
$failedCount,
|
||||
$failedCount === 1 ? 'failed operation' : 'failed operations',
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%d %s currently need review.',
|
||||
$partialCount,
|
||||
$partialCount === 1 ? 'partial operation' : 'partial operations',
|
||||
);
|
||||
}
|
||||
|
||||
private static function cleanString(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private static function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_int($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<WorkspaceSubscription>
|
||||
*/
|
||||
class WorkspaceSubscriptionFactory extends Factory
|
||||
{
|
||||
protected $model = WorkspaceSubscription::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$periodStartsAt = now()->subDays(1)->startOfMinute();
|
||||
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'state' => WorkspaceSubscription::STATE_ACTIVE,
|
||||
'billing_reference' => 'sub_'.fake()->bothify('????##'),
|
||||
'trial_ends_at' => null,
|
||||
'current_period_starts_at' => $periodStartsAt,
|
||||
'current_period_ends_at' => $periodStartsAt->copy()->addDays(30),
|
||||
'status_reason' => 'Subscription is current.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workspace_subscriptions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete()->unique();
|
||||
$table->string('state');
|
||||
$table->string('billing_reference', 191)->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->timestamp('current_period_starts_at')->nullable();
|
||||
$table->timestamp('current_period_ends_at')->nullable();
|
||||
$table->text('status_reason');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['state', 'current_period_ends_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workspace_subscriptions');
|
||||
}
|
||||
};
|
||||
@ -33,11 +33,13 @@ @layer components {
|
||||
}
|
||||
|
||||
.tp-ops-activity-banner .tp-ops-activity-helper {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
line-height: 1.35;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
overflow-wrap: break-word;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tp-ops-activity-banner .tp-ops-activity-summary {
|
||||
@ -76,7 +78,7 @@ @layer components {
|
||||
}
|
||||
|
||||
.tp-ops-activity-banner .tp-ops-activity-helper {
|
||||
max-width: 48rem;
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.tp-ops-activity-banner .tp-ops-activity-actions {
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
$runs = $this->recentRuns();
|
||||
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
|
||||
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
|
||||
$commercialSourceDescriptor = ($commercialLifecycle['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
|
||||
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
|
||||
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
|
||||
$reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null;
|
||||
@ -56,7 +57,34 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Current state</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Workspace subscription</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<x-filament::badge :color="($commercialLifecycle['fallback_status'] ?? true) ? 'gray' : 'info'">
|
||||
{{ $commercialSourceDescriptor }}
|
||||
</x-filament::badge>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['subscription_state_label'] ?? 'No current subscription record' }}</span>
|
||||
</div>
|
||||
|
||||
@if (($commercialLifecycle['subscription_key_date_label'] ?? null) !== null && ($commercialLifecycle['subscription_key_date'] ?? null) instanceof \Carbon\CarbonInterface)
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $commercialLifecycle['subscription_key_date_label'] }}: {{ $commercialLifecycle['subscription_key_date']->toDayDateTimeString() }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (($commercialLifecycle['fallback_status'] ?? true) === false)
|
||||
@if (($commercialLifecycle['subscription_needs_review'] ?? false) === true)
|
||||
<p class="mt-2 text-sm text-warning-700 dark:text-warning-300">This subscription record needs review before the next commercial change is applied.</p>
|
||||
@endif
|
||||
@if (($commercialLifecycle['subscription_billing_reference'] ?? null) !== null)
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Reference: {{ $commercialLifecycle['subscription_billing_reference'] }}</p>
|
||||
@endif
|
||||
@else
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No current subscription record is stored. The workspace still follows the explicit lifecycle fallback or the default active-paid posture.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Derived commercial lifecycle</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<x-filament::badge :color="$commercialBadge->color" :icon="$commercialBadge->icon">
|
||||
{{ $commercialBadge->label }}
|
||||
@ -64,11 +92,7 @@
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['source_label'] ?? 'default active paid' }}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Lifecycle rationale</p>
|
||||
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
|
||||
<p class="mt-2 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $commercialLifecycle['last_changed_by'] ?? 'System default' }}
|
||||
@if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface)
|
||||
|
||||
@ -12,11 +12,13 @@
|
||||
$operationsCollectionLabel = \App\Support\OperationRunLinks::collectionLabel();
|
||||
$operationsIndexUrl = $tenant ? \App\Support\OpsUx\OperationRunUrl::index($tenant) : null;
|
||||
$primaryActionLabel = $usesCollectivePrimaryAction ? 'Review operations' : 'View operation';
|
||||
$bannerTitle = $hasTerminalVisibleRuns ? 'Operation updates' : 'Active operations';
|
||||
$bannerTitle = $hasActiveVisibleRuns && ! $hasTerminalVisibleRuns ? 'Active operations' : 'Operation updates';
|
||||
$bannerHelper = match (true) {
|
||||
$hasTerminalFollowUpVisibleRuns => 'Review needed operation updates stay visible until you open Operations or acknowledge them in this browser session.',
|
||||
$hasActiveVisibleRuns && $hasTerminalFollowUpVisibleRuns => 'Active and recent operation updates that may need review.',
|
||||
$hasActiveVisibleRuns => 'Queued and running work stays here until diagnostics are needed.',
|
||||
$hasTerminalFollowUpVisibleRuns => 'Recent operation updates that may need review.',
|
||||
$hasTerminalVisibleRuns => 'Successful operation updates stay briefly visible so you can confirm completion and keep working.',
|
||||
default => 'Queued and running work stays inside the tenant shell until you need the diagnostics view.',
|
||||
default => 'Recent operation updates.',
|
||||
};
|
||||
$primaryActionUrl = null;
|
||||
|
||||
@ -59,9 +61,9 @@ class="tp-ops-activity-banner mt-5 mb-7 w-full rounded-xl border border-gray-200
|
||||
<x-filament::icon icon="heroicon-m-bolt" class="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 lg:flex lg:items-start lg:gap-3">
|
||||
<div class="tp-ops-activity-copy-stack min-w-0 flex-1 space-y-1">
|
||||
<p class="tp-ops-activity-title text-xs font-semibold uppercase tracking-[0.12em] whitespace-nowrap text-primary-700 dark:text-primary-300" data-testid="ops-ux-activity-feedback-title">{{ $bannerTitle }}</p>
|
||||
<p class="tp-ops-activity-helper mt-1 max-w-3xl text-[13px] leading-[1.45] text-gray-500 lg:mt-0 dark:text-gray-400" data-testid="ops-ux-activity-feedback-helper">{{ $bannerHelper }}</p>
|
||||
<p class="tp-ops-activity-helper text-[13px] leading-[1.45] whitespace-normal break-words text-gray-500 dark:text-gray-400" data-testid="ops-ux-activity-feedback-helper">{{ $bannerHelper }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -106,26 +108,14 @@ class="inline-flex items-center justify-center rounded-lg border border-transpar
|
||||
@php
|
||||
$uxStatus = \App\Support\OpsUx\OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$isTerminalRun = ! $run->isCurrentlyActive();
|
||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
$hasDeterminateProgress = ! $isTerminalRun
|
||||
&& $run->status !== 'queued'
|
||||
&& is_numeric($summaryCounts['total'] ?? null)
|
||||
&& is_numeric($summaryCounts['processed'] ?? null)
|
||||
&& (int) $summaryCounts['total'] > 0;
|
||||
$progress = \App\Support\OpsUx\OperationRunProgressContract::forRun($run);
|
||||
$hasDeterminateProgress = $progress['display'] === 'counted';
|
||||
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
|
||||
$showsLifecycleAttention = $lifecycleAttention !== null
|
||||
&& ($lifecycleAttention !== 'Likely stale' || $run->status === 'queued' || ! $hasDeterminateProgress);
|
||||
$progressTotal = $hasDeterminateProgress ? max(1, (int) $summaryCounts['total']) : null;
|
||||
$progressProcessed = $hasDeterminateProgress
|
||||
? min(max(0, (int) $summaryCounts['processed']), $progressTotal)
|
||||
: null;
|
||||
$progressPercent = $hasDeterminateProgress
|
||||
? max(0, min(100, (int) round(($progressProcessed / $progressTotal) * 100)))
|
||||
: null;
|
||||
$progressLabel = $hasDeterminateProgress
|
||||
? sprintf('%d / %d processed (%d%%)', $progressProcessed, $progressTotal, $progressPercent)
|
||||
: null;
|
||||
$showsIndeterminateProgress = ! $isTerminalRun && ! $hasDeterminateProgress;
|
||||
$progressLabel = $progress['label'];
|
||||
$progressPercent = $progress['percent'];
|
||||
$showsIndeterminateProgress = $progress['display'] === 'indeterminate';
|
||||
$statusLabel = match ($uxStatus) {
|
||||
'queued' => 'Queued for execution',
|
||||
'running' => 'In progress',
|
||||
@ -141,9 +131,6 @@ class="inline-flex items-center justify-center rounded-lg border border-transpar
|
||||
'partial', 'blocked' => 'bg-warning-50 text-warning-800 ring-1 ring-inset ring-warning-200 dark:bg-warning-500/10 dark:text-warning-100 dark:ring-warning-400/25',
|
||||
default => 'bg-danger-50 text-danger-800 ring-1 ring-inset ring-danger-200 dark:bg-danger-500/10 dark:text-danger-100 dark:ring-danger-400/25',
|
||||
};
|
||||
$activityLabel = $run->status === 'queued'
|
||||
? 'Waiting for worker.'
|
||||
: 'Progress details pending.';
|
||||
$elapsedLabel = \App\Support\OpsUx\RunDurationInsights::elapsedCompact($run);
|
||||
$surfaceGuidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
$activitySummaryLine = $isTerminalRun
|
||||
@ -152,7 +139,7 @@ class="inline-flex items-center justify-center rounded-lg border border-transpar
|
||||
'%s · %s · %s',
|
||||
$run->status === 'queued' ? 'Queued' : 'Running',
|
||||
$elapsedLabel,
|
||||
$progressLabel ?? $activityLabel,
|
||||
$progressLabel ?? 'Progress details pending.',
|
||||
);
|
||||
@endphp
|
||||
|
||||
|
||||
@ -78,10 +78,11 @@ function operationActivityFeedbackSmokeLoginUrl(User $user, Tenant $tenant, stri
|
||||
const layout = banner?.querySelector('.tp-ops-activity-layout') ?? null;
|
||||
const headerCopy = banner?.querySelector('.tp-ops-activity-header-copy') ?? null;
|
||||
const title = banner?.querySelector('[data-testid="ops-ux-activity-feedback-title"]') ?? null;
|
||||
const helper = banner?.querySelector('[data-testid="ops-ux-activity-feedback-helper"]') ?? null;
|
||||
const middleColumn = banner?.querySelector('.tp-ops-activity-summary') ?? null;
|
||||
const track = banner?.querySelector('[data-testid="ops-ux-activity-feedback-track"]') ?? null;
|
||||
|
||||
if (!banner || !topbar || !tableContainer || !contentShell || !actionGroup || !layout || !header || !headerCopy || !title || !middleColumn || !track) {
|
||||
if (!banner || !topbar || !tableContainer || !contentShell || !actionGroup || !layout || !header || !headerCopy || !title || !helper || !middleColumn || !track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -93,12 +94,15 @@ function operationActivityFeedbackSmokeLoginUrl(User $user, Tenant $tenant, stri
|
||||
const actionGroupRect = actionGroup.getBoundingClientRect();
|
||||
const headerCopyRect = headerCopy.getBoundingClientRect();
|
||||
const titleRect = title.getBoundingClientRect();
|
||||
const helperRect = helper.getBoundingClientRect();
|
||||
const middleRect = middleColumn.getBoundingClientRect();
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const layoutStyle = window.getComputedStyle(layout);
|
||||
const headerStyle = window.getComputedStyle(header);
|
||||
const titleStyle = window.getComputedStyle(title);
|
||||
const helperStyle = window.getComputedStyle(helper);
|
||||
const titleLineHeight = Number.parseFloat(titleStyle.lineHeight || '0');
|
||||
const helperLineHeight = Number.parseFloat(helperStyle.lineHeight || '0');
|
||||
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
@ -125,6 +129,13 @@ function operationActivityFeedbackSmokeLoginUrl(User $user, Tenant $tenant, stri
|
||||
trackSpanRatio: middleRect.width > 0 ? trackRect.width / middleRect.width : 0,
|
||||
titleOverflows: title.scrollWidth > (title.clientWidth + 1),
|
||||
titleLineCount: titleLineHeight > 0 ? titleRect.height / titleLineHeight : 0,
|
||||
helperText: helper.textContent?.trim() ?? '',
|
||||
helperDisplay: helperStyle.display,
|
||||
helperWhiteSpace: helperStyle.whiteSpace,
|
||||
helperTextOverflow: helperStyle.textOverflow,
|
||||
helperLineClamp: helperStyle.getPropertyValue('-webkit-line-clamp'),
|
||||
helperOverflows: helper.scrollWidth > (helper.clientWidth + 1),
|
||||
helperLineCount: helperLineHeight > 0 ? helperRect.height / helperLineHeight : 0,
|
||||
headerCopyWidth: headerCopyRect.width,
|
||||
middleHeight: middleRect.height,
|
||||
actionHeight: actionGroupRect.height,
|
||||
@ -153,6 +164,13 @@ function operationActivityFeedbackSmokeLoginUrl(User $user, Tenant $tenant, stri
|
||||
->and($shellGeometry['trackSpanRatio'] ?? 0)->toBeGreaterThanOrEqual(0.72)
|
||||
->and($shellGeometry['titleOverflows'] ?? true)->toBeFalse()
|
||||
->and($shellGeometry['titleLineCount'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(1.2)
|
||||
->and($shellGeometry['helperText'] ?? '')->toBe('Queued and running work stays here until diagnostics are needed.')
|
||||
->and($shellGeometry['helperDisplay'] ?? '')->not->toBe('-webkit-box')
|
||||
->and($shellGeometry['helperWhiteSpace'] ?? '')->toBe('normal')
|
||||
->and($shellGeometry['helperTextOverflow'] ?? '')->not->toBe('ellipsis')
|
||||
->and($shellGeometry['helperLineClamp'] ?? '')->not->toBe('2')
|
||||
->and($shellGeometry['helperOverflows'] ?? true)->toBeFalse()
|
||||
->and($shellGeometry['helperLineCount'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(2.2)
|
||||
->and($shellGeometry['headerCopyWidth'] ?? 0)->toBeGreaterThanOrEqual(240)
|
||||
->and($shellGeometry['leftDelta'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(32)
|
||||
->and($shellGeometry['rightDelta'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(32)
|
||||
@ -197,6 +215,61 @@ function operationActivityFeedbackSmokeLoginUrl(User $user, Tenant $tenant, stri
|
||||
->assertDontSee('Show activity');
|
||||
});
|
||||
|
||||
it('shows repo-real phased work as indeterminate activity and still opens the canonical run detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Browser Progress Inventory Item',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows',
|
||||
'last_seen_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 4,
|
||||
],
|
||||
'context' => [
|
||||
'baseline_capture' => [
|
||||
'evidence_capture' => [
|
||||
'requested' => 10,
|
||||
'succeeded' => 3,
|
||||
'skipped' => 1,
|
||||
],
|
||||
'resume_token' => 'resume-browser-123',
|
||||
],
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
visit(operationActivityFeedbackSmokeLoginUrl($user, $tenant))
|
||||
->waitForText('Dashboard')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant))
|
||||
->resize(1440, 1200)
|
||||
->waitForText('Inventory Items')
|
||||
->waitForText('Capturing evidence.')
|
||||
->assertSee('View operation')
|
||||
->assertDontSee('4 / 10 processed (40%)')
|
||||
->assertScript("document.querySelector('[data-testid=\"ops-ux-activity-feedback-indeterminate\"]') !== null", true)
|
||||
->assertScript("document.querySelector('[role=\"progressbar\"]') === null", true)
|
||||
->click('View operation')
|
||||
->waitForText('Operation #'.(int) $run->getKey())
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
it('keeps terminal follow-up acknowledge local to the browser session and reopens for new work', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -220,7 +293,7 @@ function operationActivityFeedbackSmokeLoginUrl(User $user, Tenant $tenant, stri
|
||||
->resize(1440, 1200)
|
||||
->waitForText('Inventory Items')
|
||||
->waitForText('Acknowledge')
|
||||
->assertSee('Review needed')
|
||||
->assertSee('Recent operation updates that may need review.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
role: 'owner',
|
||||
workspaceRole: 'manager',
|
||||
);
|
||||
$user->forceFill(['preferred_locale' => 'de'])->save();
|
||||
|
||||
$tenantWithoutPublished = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantPublished->workspace_id,
|
||||
@ -77,42 +78,45 @@
|
||||
]);
|
||||
|
||||
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview], $tenantPublished))
|
||||
->waitForText('Related context')
|
||||
->assertSee('Open customer workspace')
|
||||
->waitForText('Verwandter Kontext')
|
||||
->assertSee('Kunden-Workspace öffnen')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->click('Open customer workspace')
|
||||
->waitForText('Customer-safe governance package index')
|
||||
->assertSee('Clear filters')
|
||||
->assertSee('Open review')
|
||||
->assertSee('Governance package')
|
||||
->click('Kunden-Workspace öffnen')
|
||||
->waitForText('Kundensicherer Governance-Paket-Index')
|
||||
->assertSee('Filter löschen')
|
||||
->assertSee('Review öffnen')
|
||||
->assertSee('Governance-Paket')
|
||||
->assertSee('Status')
|
||||
->assertSee('Evidence')
|
||||
->assertSee('Review the executive-ready governance package status')
|
||||
->assertSee('This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.')
|
||||
->assertSee('Partial')
|
||||
->assertSee('Review required')
|
||||
->assertSee('Available')
|
||||
->assertSee('Review package')
|
||||
->assertSee('Nachweise')
|
||||
->assertSee('Prüfen Sie für jeden berechtigten Tenant den executive-fähigen Status des Governance-Pakets')
|
||||
->assertSee('Dieser Workspace fasst die aktuelle Review- und Nachweislage für die Service-Auslieferung zusammen. Er ersetzt weder ein formales Auditurteil noch eine Zertifizierung oder rechtliche Attestierung.')
|
||||
->assertSee('Teilweise')
|
||||
->assertSee('Prüfung erforderlich')
|
||||
->assertSee('Verfügbar')
|
||||
->assertSee('Paket prüfen')
|
||||
->assertDontSee('Customer-safe governance package index')
|
||||
->assertDontSee('localization.review.customer_safe_review_workspace')
|
||||
->assertDontSee('Publishable')
|
||||
->assertDontSee('No mapped controls')
|
||||
->assertDontSee('Compliance evidence mapping v1')
|
||||
->assertDontSee('Publish review')
|
||||
->assertDontSee('Refresh review')
|
||||
->click('Clear filters')
|
||||
->click('Filter löschen')
|
||||
->waitForText('Published Tenant')
|
||||
->assertDontSee('No Published Tenant')
|
||||
->assertDontSee('No published review available yet')
|
||||
->click('Open review')
|
||||
->waitForText('Outcome summary')
|
||||
->assertSee('Download governance package')
|
||||
->assertSee('Governance package')
|
||||
->assertSee('Released governance record')
|
||||
->assertSee('Review status')
|
||||
->assertSee('Primary action')
|
||||
->assertSee('Executive entrypoint')
|
||||
->assertSee('Structured auditor appendix')
|
||||
->assertSee('Assessment basis')
|
||||
->click('Review öffnen')
|
||||
->waitForText('Ergebniszusammenfassung')
|
||||
->assertSee('Governance-Paket herunterladen')
|
||||
->assertSee('Governance-Paket')
|
||||
->assertSee('Veröffentlichter Governance-Nachweis')
|
||||
->assertSee('Review-Status')
|
||||
->assertSee('Primäre Aktion')
|
||||
->assertSee('Executive-Einstieg')
|
||||
->assertSee('Strukturierter Auditor-Anhang')
|
||||
->assertSee('Prüfgrundlage')
|
||||
->assertDontSee('Released governance record')
|
||||
->assertDontSee('Control readiness interpretation')
|
||||
->assertDontSee('Compliance evidence mapping v1')
|
||||
->assertDontSee('Publish review')
|
||||
|
||||
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
it('smokes subscription truth mutation on the system page', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'name' => 'Browser Billing Truth Tenant',
|
||||
]);
|
||||
|
||||
[$workspaceUser, $tenant] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$workspace = $tenant->workspace()->firstOrFail();
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
auth('web')->logout();
|
||||
$this->flushSession();
|
||||
$this->actingAs($platformUser, 'platform');
|
||||
|
||||
$systemPage = visit(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]));
|
||||
|
||||
$systemPage
|
||||
->waitForText('Workspace subscription')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('fallback-backed')
|
||||
->assertSee('Update subscription truth')
|
||||
->click('Update subscription truth')
|
||||
->waitForText('This records the current subscription-backed commercial truth for the workspace and becomes the upstream lifecycle source while the record exists.');
|
||||
|
||||
$systemPage->script(<<<'JS'
|
||||
(() => {
|
||||
const field = (labelText) => {
|
||||
const label = Array.from(document.querySelectorAll('label')).find((element) => element.textContent?.replace('*', '').trim() === labelText);
|
||||
|
||||
if (! label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetId = label.getAttribute('for');
|
||||
|
||||
if (targetId) {
|
||||
return document.getElementById(targetId);
|
||||
}
|
||||
|
||||
return label.parentElement?.querySelector('input, textarea, select') ?? null;
|
||||
};
|
||||
|
||||
const state = field('Subscription state');
|
||||
const billingReference = field('Billing reference');
|
||||
const statusReason = field('Status reason');
|
||||
|
||||
if (! state || ! billingReference || ! statusReason) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.value = 'ended';
|
||||
state.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
billingReference.value = 'sub_browser_truth_001';
|
||||
billingReference.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
billingReference.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
statusReason.value = 'Browser smoke recorded subscription truth.';
|
||||
statusReason.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
statusReason.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
return true;
|
||||
})();
|
||||
JS);
|
||||
|
||||
$systemPage->script(<<<'JS'
|
||||
(() => {
|
||||
const confirmButton = Array.from(document.querySelectorAll('button')).find((element) => element.textContent?.trim() === 'Confirm');
|
||||
|
||||
confirmButton?.click();
|
||||
})();
|
||||
JS);
|
||||
|
||||
$systemPage
|
||||
->waitForText('subscription-backed')
|
||||
->assertSee('Ended')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
it('smokes the admin read-only commercial summary for subscription-backed truth', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'name' => 'Browser Billing Summary Tenant',
|
||||
]);
|
||||
|
||||
[$workspaceUser, $tenant] = createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$workspace = $tenant->workspace()->firstOrFail();
|
||||
|
||||
WorkspaceSubscription::query()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => WorkspaceSubscription::STATE_ENDED,
|
||||
'billing_reference' => 'sub_browser_truth_001',
|
||||
'status_reason' => 'Browser smoke recorded subscription truth.',
|
||||
]);
|
||||
|
||||
$this->actingAs($workspaceUser)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
visit(WorkspaceSettings::getUrl(panel: 'admin'))
|
||||
->waitForText('Commercial posture')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('subscription-backed')
|
||||
->assertSee('Ended')
|
||||
->assertSee('Browser smoke recorded subscription truth.')
|
||||
->assertDontSee('Update subscription truth');
|
||||
});
|
||||
@ -149,6 +149,157 @@
|
||||
expect($backupSet->status)->toBe('partial');
|
||||
});
|
||||
|
||||
it('seeds and advances counted progress for the base backup-set policy selection', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Progress test backup',
|
||||
'status' => 'completed',
|
||||
'metadata' => ['failures' => []],
|
||||
]);
|
||||
|
||||
$policyA = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'ignored_at' => null,
|
||||
]);
|
||||
|
||||
$policyB = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'ignored_at' => null,
|
||||
]);
|
||||
|
||||
$versionA = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policyA->id,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'platform' => $policyA->platform,
|
||||
'snapshot' => ['id' => $policyA->external_id],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.update',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_ids' => [(int) $policyA->getKey(), (int) $policyB->getKey()],
|
||||
],
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
]);
|
||||
|
||||
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policyA, $policyB, $tenant, $versionA) {
|
||||
$mock->shouldReceive('capture')
|
||||
->twice()
|
||||
->andReturnUsing(function (Policy $policy) use ($policyA, $policyB, $versionA) {
|
||||
if ($policy->is($policyA)) {
|
||||
return [
|
||||
'version' => $versionA,
|
||||
'captured' => [
|
||||
'payload' => [
|
||||
'id' => $policyA->external_id,
|
||||
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => null,
|
||||
'metadata' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
expect($policy->is($policyB))->toBeTrue();
|
||||
|
||||
return [
|
||||
'failure' => [
|
||||
'policy_id' => $policyB->id,
|
||||
'reason' => 'Forbidden',
|
||||
'status' => 403,
|
||||
],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
$seededCounts = [];
|
||||
$increments = [];
|
||||
$realOperationRuns = app(OperationRunService::class);
|
||||
|
||||
$spyOperationRuns = new class($realOperationRuns, $seededCounts, $increments) extends OperationRunService
|
||||
{
|
||||
private array $seededCounts;
|
||||
|
||||
private array $increments;
|
||||
|
||||
public function __construct(private readonly OperationRunService $inner, array &$seededCounts, array &$increments)
|
||||
{
|
||||
$this->seededCounts = &$seededCounts;
|
||||
$this->increments = &$increments;
|
||||
}
|
||||
|
||||
public function updateRun(OperationRun $run, string $status, ?string $outcome = null, array $summaryCounts = [], array $failures = []): OperationRun
|
||||
{
|
||||
if ($status === 'running' && $summaryCounts !== []) {
|
||||
$this->seededCounts[] = $summaryCounts;
|
||||
}
|
||||
|
||||
return $this->inner->updateRun($run, $status, $outcome, $summaryCounts, $failures);
|
||||
}
|
||||
|
||||
public function incrementSummaryCounts(OperationRun $run, array $delta): OperationRun
|
||||
{
|
||||
$this->increments[] = $delta;
|
||||
|
||||
return $this->inner->incrementSummaryCounts($run, $delta);
|
||||
}
|
||||
};
|
||||
|
||||
$job = new AddPoliciesToBackupSetJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
policyIds: [(int) $policyA->getKey(), (int) $policyB->getKey()],
|
||||
options: [
|
||||
'include_assignments' => false,
|
||||
'include_scope_tags' => false,
|
||||
'include_foundations' => false,
|
||||
],
|
||||
idempotencyKey: 'base-progress-counted',
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
operationRunService: $spyOperationRuns,
|
||||
captureOrchestrator: app(PolicyCaptureOrchestrator::class),
|
||||
foundationSnapshots: $this->mock(FoundationSnapshotService::class),
|
||||
snapshotValidator: app(SnapshotValidator::class),
|
||||
versionService: app(VersionService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($seededCounts)->toHaveCount(1)
|
||||
->and($seededCounts[0])->toMatchArray([
|
||||
'total' => 2,
|
||||
'items' => 2,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
expect(collect($increments)->contains(fn (array $delta): bool => array_key_exists('total', $delta)))->toBeFalse();
|
||||
expect($run->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 2,
|
||||
'processed' => 2,
|
||||
'succeeded' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures RBAC foundation items with linked policy versions when include_foundations is enabled', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -74,6 +74,8 @@ function runBaselineCaptureJob(
|
||||
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
|
||||
expect($context['source_tenant_id'])->toBe((int) $tenant->getKey());
|
||||
expect($context)->toHaveKey('effective_scope');
|
||||
expect(data_get($context, 'progress.phase.key'))->toBe('preparing');
|
||||
expect(data_get($context, 'progress.phase.label'))->toBe('Preparing baseline capture.');
|
||||
|
||||
$effectiveScope = is_array($context['effective_scope'] ?? null) ? $context['effective_scope'] : [];
|
||||
expect($effectiveScope['policy_types'])->toBe(['deviceConfiguration']);
|
||||
@ -341,6 +343,8 @@ function runBaselineCaptureJob(
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(3);
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(3);
|
||||
expect(data_get($run->context, 'progress.phase.key'))->toBe('finalizing');
|
||||
expect(data_get($run->context, 'progress.phase.label'))->toBe('Finalizing baseline capture.');
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
|
||||
@ -220,6 +220,8 @@
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
|
||||
expect($context['baseline_snapshot_id'])->toBe((int) $snapshot->getKey());
|
||||
expect(data_get($context, 'progress.phase.key'))->toBe('preparing');
|
||||
expect(data_get($context, 'progress.phase.label'))->toBe('Preparing baseline comparison.');
|
||||
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
@ -163,6 +163,8 @@ public function capture(
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'progress.phase.key'))->toBe('finalizing');
|
||||
expect(data_get($context, 'progress.phase.label'))->toBe('Finalizing baseline comparison.');
|
||||
$token = $context['baseline_compare']['resume_token'] ?? null;
|
||||
expect($token)->toBeString();
|
||||
|
||||
|
||||
@ -9,10 +9,12 @@
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -62,6 +64,82 @@ function seedSnapshotInputs(Tenant $tenant): void
|
||||
->and($operationRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
});
|
||||
|
||||
it('seeds and advances counted progress while evidence snapshot items are generated', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedSnapshotInputs($tenant);
|
||||
|
||||
$service = app(EvidenceSnapshotService::class);
|
||||
$expectedItemCount = count($service->buildSnapshotPayload($tenant)['items']);
|
||||
$snapshot = $service->generate($tenant, $user);
|
||||
|
||||
$seededCounts = [];
|
||||
$increments = [];
|
||||
$realOperationRuns = app(OperationRunService::class);
|
||||
|
||||
$spyOperationRuns = new class($realOperationRuns, $seededCounts, $increments) extends OperationRunService
|
||||
{
|
||||
private array $seededCounts;
|
||||
|
||||
private array $increments;
|
||||
|
||||
public function __construct(private readonly OperationRunService $inner, array &$seededCounts, array &$increments)
|
||||
{
|
||||
$this->seededCounts = &$seededCounts;
|
||||
$this->increments = &$increments;
|
||||
}
|
||||
|
||||
public function updateRun(OperationRun $run, string $status, ?string $outcome = null, array $summaryCounts = [], array $failures = []): OperationRun
|
||||
{
|
||||
if ($status === OperationRunStatus::Running->value && $summaryCounts !== []) {
|
||||
$this->seededCounts[] = $summaryCounts;
|
||||
}
|
||||
|
||||
return $this->inner->updateRun($run, $status, $outcome, $summaryCounts, $failures);
|
||||
}
|
||||
|
||||
public function incrementSummaryCounts(OperationRun $run, array $delta): OperationRun
|
||||
{
|
||||
$this->increments[] = $delta;
|
||||
|
||||
return $this->inner->incrementSummaryCounts($run, $delta);
|
||||
}
|
||||
};
|
||||
|
||||
$job = new GenerateEvidenceSnapshotJob(
|
||||
snapshotId: (int) $snapshot->getKey(),
|
||||
operationRunId: (int) $snapshot->operation_run_id,
|
||||
);
|
||||
|
||||
$job->handle($service, $spyOperationRuns);
|
||||
|
||||
$snapshot->refresh();
|
||||
$operationRun = OperationRun::query()->findOrFail($snapshot->operation_run_id);
|
||||
|
||||
expect($seededCounts)->toHaveCount(1)
|
||||
->and($seededCounts[0])->toMatchArray([
|
||||
'total' => $expectedItemCount,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
])
|
||||
->and($increments)->toHaveCount($expectedItemCount);
|
||||
|
||||
foreach ($increments as $delta) {
|
||||
expect($delta)->toBe([
|
||||
'processed' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
expect($snapshot->items)->toHaveCount($expectedItemCount)
|
||||
->and($operationRun->summary_counts ?? [])->toMatchArray([
|
||||
'total' => $expectedItemCount,
|
||||
'processed' => $expectedItemCount,
|
||||
'created' => $expectedItemCount,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses an unchanged active snapshot fingerprint instead of creating a duplicate', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedSnapshotInputs($tenant);
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
@ -108,4 +109,28 @@ function entitlementSettingsManager(): array
|
||||
->set('data.entitlements_review_pack_generation_override_reason', '')
|
||||
->callAction('save')
|
||||
->assertHasErrors(['data.entitlements_review_pack_generation_override_reason']);
|
||||
});
|
||||
|
||||
it('shows a read-only subscription-backed commercial summary on workspace settings', function (): void {
|
||||
[$workspace, $user] = entitlementSettingsManager();
|
||||
$trialEndsAt = now()->addDays(14)->startOfMinute();
|
||||
|
||||
WorkspaceSubscription::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => 'trial',
|
||||
'billing_reference' => 'sub_trial_001',
|
||||
'trial_ends_at' => $trialEndsAt,
|
||||
'status_reason' => 'Trial access for onboarding.',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Commercial posture')
|
||||
->assertSee('subscription-backed')
|
||||
->assertSee('Trial')
|
||||
->assertSee('Trial access for onboarding.')
|
||||
->assertSee('Trial ends')
|
||||
->assertDontSee('Update subscription truth')
|
||||
->assertDontSee('Change commercial state');
|
||||
});
|
||||
@ -8,8 +8,28 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
function attemptedInventoryPolicyTypes(array $selection): array
|
||||
{
|
||||
$policyTypes = is_array($selection['policy_types'] ?? null)
|
||||
? array_values(array_filter(array_map('strval', $selection['policy_types'])))
|
||||
: [];
|
||||
|
||||
$foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
|
||||
->map(fn (array $row): mixed => $row['type'] ?? null)
|
||||
->filter(fn (mixed $type): bool => is_string($type) && $type !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ((bool) ($selection['include_foundations'] ?? false)) {
|
||||
return array_values(array_unique(array_merge($policyTypes, $foundationTypes)));
|
||||
}
|
||||
|
||||
return array_values(array_diff($policyTypes, $foundationTypes));
|
||||
}
|
||||
|
||||
it('executes a pending inventory sync run and updates bulk progress + initiator attribution', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -21,6 +41,7 @@
|
||||
$selectionPayload = $sync->defaultSelectionPayload();
|
||||
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
||||
$policyTypes = $computed['selection']['policy_types'];
|
||||
$attemptedTypes = attemptedInventoryPolicyTypes($computed['selection']);
|
||||
|
||||
$mockSync = \Mockery::mock(InventorySyncService::class);
|
||||
$mockSync
|
||||
@ -74,9 +95,9 @@
|
||||
expect($context['inventory']['coverage']['policy_types'][array_values($policyTypes)[0]]['item_count'] ?? null)->toBe(1);
|
||||
|
||||
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(count($policyTypes));
|
||||
expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes));
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(count($policyTypes));
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(count($attemptedTypes));
|
||||
expect((int) ($counts['processed'] ?? 0))->toBe(count($attemptedTypes));
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(count($attemptedTypes));
|
||||
expect((int) ($counts['failed'] ?? 0))->toBe(0);
|
||||
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
@ -96,6 +117,7 @@
|
||||
|
||||
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
||||
$policyTypes = $computed['selection']['policy_types'];
|
||||
$attemptedTypes = attemptedInventoryPolicyTypes($computed['selection']);
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -143,8 +165,8 @@
|
||||
expect($context['inventory']['coverage']['policy_types'][array_values($policyTypes)[0]]['error_code'] ?? null)->toBe('locked');
|
||||
|
||||
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
|
||||
expect((int) ($counts['processed'] ?? 0))->toBe(count($policyTypes));
|
||||
expect((int) ($counts['skipped'] ?? 0))->toBe(count($policyTypes));
|
||||
expect((int) ($counts['processed'] ?? 0))->toBe(count($attemptedTypes));
|
||||
expect((int) ($counts['skipped'] ?? 0))->toBe(count($attemptedTypes));
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(0);
|
||||
expect((int) ($counts['failed'] ?? 0))->toBe(0);
|
||||
|
||||
@ -162,6 +184,101 @@
|
||||
]);
|
||||
});
|
||||
|
||||
it('seeds and advances counted progress before inventory sync reaches a terminal outcome', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$sync = app(InventorySyncService::class);
|
||||
$selectionPayload = $sync->defaultSelectionPayload();
|
||||
$selectionPayload['include_foundations'] = false;
|
||||
$selectionPayload['policy_types'] = array_slice($selectionPayload['policy_types'], 0, 2);
|
||||
|
||||
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
||||
$attemptedTypes = $computed['selection']['policy_types'];
|
||||
|
||||
expect($attemptedTypes)->toHaveCount(2);
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'inventory_sync',
|
||||
inputs: $computed['selection'],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$mockSync = \Mockery::mock(InventorySyncService::class);
|
||||
$mockSync
|
||||
->shouldReceive('executeSelection')
|
||||
->once()
|
||||
->andReturnUsing(function (OperationRun $operationRun, $tenantArg, array $selection, ?callable $onPolicyTypeProcessed) use ($tenant, $attemptedTypes): array {
|
||||
expect($tenantArg->is($tenant))->toBeTrue();
|
||||
expect($selection['policy_types'] ?? [])->toBe($attemptedTypes);
|
||||
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->summary_counts ?? [])->toMatchArray([
|
||||
'total' => count($attemptedTypes),
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($attemptedTypes[0], true, null, 3);
|
||||
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->summary_counts ?? [])->toMatchArray([
|
||||
'total' => count($attemptedTypes),
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($attemptedTypes[1], false, 'graph_forbidden', 0);
|
||||
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->summary_counts ?? [])->toMatchArray([
|
||||
'total' => count($attemptedTypes),
|
||||
'processed' => 2,
|
||||
'succeeded' => 1,
|
||||
'failed' => 1,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'partial',
|
||||
'had_errors' => true,
|
||||
'error_codes' => ['graph_forbidden'],
|
||||
'error_context' => [],
|
||||
'errors_count' => 1,
|
||||
'items_observed_count' => 3,
|
||||
'items_upserted_count' => 3,
|
||||
'skipped_policy_types' => [],
|
||||
'processed_policy_types' => $attemptedTypes,
|
||||
'failed_policy_types' => [$attemptedTypes[1]],
|
||||
'selection_hash' => hash('sha256', implode('|', $attemptedTypes)),
|
||||
];
|
||||
});
|
||||
|
||||
$job = new RunInventorySyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
$job->handle($mockSync, app(AuditLogger::class), $opService);
|
||||
|
||||
$opRun->refresh();
|
||||
|
||||
expect($opRun->outcome)->toBe('partially_succeeded');
|
||||
expect($opRun->summary_counts ?? [])->toMatchArray([
|
||||
'total' => count($attemptedTypes),
|
||||
'processed' => 2,
|
||||
'succeeded' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('declares the inventory sync lifecycle contract explicitly', function (): void {
|
||||
$job = new RunInventorySyncJob(
|
||||
tenantId: 1,
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders the customer review workspace in german for the effective locale', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Lokalisierter Tenant']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$user->forceFill(['preferred_locale' => 'de'])->save();
|
||||
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
App::setLocale('de');
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CustomerReviewWorkspace::class)
|
||||
->assertCanSeeTableRecords([$tenant->fresh()])
|
||||
->assertSee('Kundensicherer Governance-Paket-Index')
|
||||
->assertSee('Prüfen Sie für jeden berechtigten Tenant den executive-fähigen Status des Governance-Pakets und öffnen Sie bei Bedarf die kundensichere Detailansicht.')
|
||||
->assertSee('Jede Zeile ist ein Einstieg in die Detailansicht: Dort sehen Sie Paketstatus, Executive-Einstieg, Nachweise, aktuelle Risiken und den nächsten kundensicheren Schritt.')
|
||||
->assertSee('Dieser Workspace fasst die aktuelle Review- und Nachweislage für die Service-Auslieferung zusammen. Er ersetzt weder ein formales Auditurteil noch eine Zertifizierung oder rechtliche Attestierung.')
|
||||
->assertSee('Governance-Paket')
|
||||
->assertSee('Nachweise')
|
||||
->assertSee('Nächster Schritt')
|
||||
->assertSee('Review öffnen')
|
||||
->assertDontSee('Customer-safe governance package index');
|
||||
});
|
||||
|
||||
it('renders the customer review detail surface in german for the effective locale', function (): void {
|
||||
Storage::fake('exports');
|
||||
|
||||
$tenant = Tenant::factory()->create(['name' => 'Lokalisierter Detail-Tenant']);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$user->forceFill(['preferred_locale' => 'de'])->save();
|
||||
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
Storage::disk('exports')->put('review-packs/customer-review-localization-test.zip', 'PK-test');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'file_path' => 'review-packs/customer-review-localization-test.zip',
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
App::setLocale('de');
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant).'?'.http_build_query([
|
||||
CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('Veröffentlichter Governance-Nachweis')
|
||||
->assertSee('Primäre Aktion')
|
||||
->assertSee('Executive-Einstieg')
|
||||
->assertSee('Strukturierter Auditor-Anhang')
|
||||
->assertDontSee('Released governance record');
|
||||
|
||||
$component = localizedTenantReviewComponent($user, $review->getKey());
|
||||
|
||||
$component->assertActionExists(
|
||||
'download_current_review_pack',
|
||||
fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'Governance-Paket herunterladen',
|
||||
);
|
||||
});
|
||||
|
||||
function localizedTenantReviewComponent($user, int $reviewId): Testable
|
||||
{
|
||||
return Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
|
||||
->actingAs($user)
|
||||
->test(ViewTenantReview::class, ['record' => $reviewId]);
|
||||
}
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Services\Localization\LocaleResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('allows users to save and clear a personal locale preference over workspace default', function (): void {
|
||||
@ -85,3 +87,16 @@
|
||||
|
||||
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns to the customer review workspace filter when the locale override changes', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$workspaceUrl = CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->from($workspaceUrl)
|
||||
->post(route('localization.override.update'), ['locale' => 'de'])
|
||||
->assertRedirect($workspaceUrl);
|
||||
});
|
||||
|
||||
@ -29,6 +29,7 @@ function readyOnboardingEntitlementContext(
|
||||
?int $limitOverride = null,
|
||||
?string $overrideReason = null,
|
||||
?string $commercialState = null,
|
||||
?array $subscription = null,
|
||||
): array
|
||||
{
|
||||
Queue::fake();
|
||||
@ -134,6 +135,21 @@ function readyOnboardingEntitlementContext(
|
||||
);
|
||||
}
|
||||
|
||||
if (is_array($subscription)) {
|
||||
app(SettingsWriter::class)->updateWorkspaceSubscription(
|
||||
actor: PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]),
|
||||
workspace: $workspace,
|
||||
attributes: $subscription,
|
||||
);
|
||||
}
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
||||
@ -233,6 +249,28 @@ function readyOnboardingEntitlementContext(
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('identifies subscription-backed commercial posture on the onboarding completion step', function (): void {
|
||||
$context = readyOnboardingEntitlementContext(
|
||||
activeTenantCount: 0,
|
||||
subscription: [
|
||||
'state' => 'trial',
|
||||
'billing_reference' => 'sub_trial_001',
|
||||
'trial_ends_at' => now()->addDays(14)->startOfMinute()->toDateTimeString(),
|
||||
'status_reason' => 'Trial access for onboarding.',
|
||||
],
|
||||
);
|
||||
|
||||
$context['component']
|
||||
->assertSee('Activation entitlement')
|
||||
->assertSee('Trial')
|
||||
->assertSee('subscription-backed')
|
||||
->call('completeOnboarding');
|
||||
|
||||
$context['tenant']->refresh();
|
||||
|
||||
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
|
||||
});
|
||||
|
||||
it('blocks onboarding activation with a grace commercial-state reason before tenant mutation', function (): void {
|
||||
$context = readyOnboardingEntitlementContext(
|
||||
activeTenantCount: 0,
|
||||
@ -242,6 +280,7 @@ function readyOnboardingEntitlementContext(
|
||||
$context['component']
|
||||
->assertSee('Activation entitlement')
|
||||
->assertSee('Grace')
|
||||
->assertSee('fallback-backed')
|
||||
->assertSee('New managed-tenant activation is frozen while this workspace is in grace.')
|
||||
->call('completeOnboarding');
|
||||
|
||||
|
||||
@ -116,9 +116,11 @@
|
||||
->call('refreshRuns');
|
||||
|
||||
$html = $component->html();
|
||||
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
|
||||
|
||||
expect(substr_count($html, 'data-testid="ops-ux-activity-feedback-item"'))->toBe(3)
|
||||
->and($html)->toContain('Active operations')
|
||||
->and($pageText)->toContain('Queued and running work stays here until diagnostics are needed.')
|
||||
->and(substr_count($html, 'Review operations'))->toBe(1)
|
||||
->and(substr_count($html, 'View operation'))->toBe(0)
|
||||
->and($html)->toContain('data-testid="ops-ux-activity-feedback-actions"')
|
||||
@ -153,9 +155,11 @@
|
||||
->call('refreshRuns');
|
||||
|
||||
$html = $component->html();
|
||||
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
|
||||
|
||||
expect(substr_count($html, 'data-testid="ops-ux-activity-feedback-item"'))->toBe(1)
|
||||
->and($html)->toContain('Active operations')
|
||||
->and($pageText)->toContain('Queued and running work stays here until diagnostics are needed.')
|
||||
->and(substr_count($html, 'View operation'))->toBe(1)
|
||||
->and($html)->not->toContain('Review operations')
|
||||
->and($html)->toContain(OperationRunUrl::view($run, $tenant))
|
||||
@ -195,6 +199,7 @@
|
||||
|
||||
expect($component->get('hasActiveRuns'))->toBeFalse()
|
||||
->and($html)->toContain('Operation updates')
|
||||
->and($pageText)->toContain('Successful operation updates stay briefly visible so you can confirm completion and keep working.')
|
||||
->and($pageText)->toContain('Completed successfully')
|
||||
->and($pageText)->toContain('No action needed.')
|
||||
->and($html)->toContain('View operation')
|
||||
@ -231,7 +236,7 @@
|
||||
|
||||
expect($component->get('hasActiveRuns'))->toBeFalse()
|
||||
->and($html)->toContain('Operation updates')
|
||||
->and($pageText)->toContain('Review needed')
|
||||
->and($pageText)->toContain('Recent operation updates that may need review.')
|
||||
->and($pageText)->toContain('Review the operation details before retrying.')
|
||||
->and($html)->toContain('View operation')
|
||||
->and($html)->not->toContain('Review operations')
|
||||
@ -278,6 +283,7 @@
|
||||
|
||||
expect($html)->toContain('Review operations')
|
||||
->and($html)->toContain('Operation updates')
|
||||
->and($pageText)->toContain('Active and recent operation updates that may need review.')
|
||||
->and($html)->not->toContain('View operation')
|
||||
->and($html)->toContain('Acknowledge')
|
||||
->and($html)->not->toContain('Dismiss updates')
|
||||
@ -358,6 +364,122 @@
|
||||
->and($html)->not->toContain('Likely stale');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('renders phased fallback progress without inventing a counted percentage', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 4,
|
||||
],
|
||||
'context' => [
|
||||
'baseline_capture' => [
|
||||
'evidence_capture' => [
|
||||
'requested' => 10,
|
||||
'succeeded' => 3,
|
||||
'skipped' => 1,
|
||||
],
|
||||
'resume_token' => 'resume-123',
|
||||
],
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns');
|
||||
|
||||
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
|
||||
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
|
||||
|
||||
expect($html)->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
|
||||
->and($pageText)->toMatch('/Running · .* · Capturing evidence\./')
|
||||
->and($html)->not->toContain('role="progressbar"')
|
||||
->and(strip_tags($html))->not->toContain('processed (');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('renders tenant review composite progress from canonical composite metadata without inventing a counted percentage', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'tenant.review.compose',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'progress' => [
|
||||
'composite' => [
|
||||
'label' => 'Review composition is aggregating 3 operations.',
|
||||
'operation_count' => 3,
|
||||
'failed_count' => 0,
|
||||
'partial_count' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns');
|
||||
|
||||
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
|
||||
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
|
||||
|
||||
expect($html)->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
|
||||
->and($pageText)->toMatch('/Running · .* · Review composition is aggregating 3 operations\./')
|
||||
->and($html)->not->toContain('role="progressbar"')
|
||||
->and(strip_tags($html))->not->toContain('processed (');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('renders composite fallback progress without inventing a counted percentage from aggregate counts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'cross_tenant_promotion.execute',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 4,
|
||||
'operation_count' => 3,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns');
|
||||
|
||||
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
|
||||
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
|
||||
|
||||
expect($html)->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
|
||||
->and($pageText)->toMatch('/Running · .* · Composite progress pending\./')
|
||||
->and($html)->not->toContain('role="progressbar"')
|
||||
->and(strip_tags($html))->not->toContain('processed (');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('renders an indeterminate queued indicator without fake determinate progress', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -394,6 +516,40 @@
|
||||
->and(strip_tags($html))->not->toContain('processed (');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps outcome counters outcome only at the shell host', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'succeeded' => 4,
|
||||
'failed' => 1,
|
||||
'skipped' => 2,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns');
|
||||
|
||||
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
|
||||
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
|
||||
|
||||
expect($html)->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
|
||||
->and($pageText)->toMatch('/Running · .* · Progress details pending\./')
|
||||
->and($html)->not->toContain('role="progressbar"')
|
||||
->and(strip_tags($html))->not->toContain('processed (');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps the queued status pill on one line for the compact banner layout', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -45,6 +45,55 @@
|
||||
});
|
||||
})->group('ops-ux');
|
||||
|
||||
it('stays inert when no selected tenant context exists', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns')
|
||||
->assertSet('disabled', true)
|
||||
->assertSet('hasActiveRuns', false)
|
||||
->assertDontSee('Inventory sync');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps queued shell hydration indeterminate even when a planned total exists', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 4,
|
||||
],
|
||||
'created_at' => now()->subSeconds(20),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns')
|
||||
->assertSee('Waiting for worker.')
|
||||
->assertSee('Queued for execution')
|
||||
->assertDontSee('4 / 10 processed (40%)')
|
||||
->assertDontSeeHtml('aria-valuenow=');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps a just-completed successful run visible briefly as terminal success', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -171,6 +220,38 @@
|
||||
->assertSee('Waiting for worker.');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('clamps counted progress at the shell host when processed exceeds total', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->set('tenantId', (int) $tenant->getKey())
|
||||
->call('refreshRuns')
|
||||
->assertDontSee('10 / 10 processed (100%)');
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 15,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->call('refreshRuns')
|
||||
->assertSee('10 / 10 processed (100%)')
|
||||
->assertDontSee('15 / 10 processed (150%)')
|
||||
->assertSeeHtml('aria-valuenow="100"');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {
|
||||
$contents = file_get_contents(resource_path('views/livewire/bulk-operation-progress.blade.php'));
|
||||
|
||||
|
||||
@ -129,6 +129,22 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
|
||||
);
|
||||
}
|
||||
|
||||
function setReviewPackSubscriptionState(Tenant $tenant, array $attributes): void
|
||||
{
|
||||
app(SettingsWriter::class)->updateWorkspaceSubscription(
|
||||
actor: PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]),
|
||||
workspace: $tenant->workspace,
|
||||
attributes: $attributes,
|
||||
);
|
||||
}
|
||||
|
||||
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedEntitlementReviewPackSnapshot($tenant);
|
||||
@ -245,7 +261,8 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Workspace is in grace. Review-pack starts remain available');
|
||||
->assertSee('Workspace is in grace. Review-pack starts remain available')
|
||||
->assertSee('Commercial source: fallback-backed.');
|
||||
|
||||
$pack = app(ReviewPackService::class)->generate($tenant, $user);
|
||||
|
||||
@ -256,6 +273,36 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('labels subscription-backed review pack warnings when subscription truth drives grace behavior', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedEntitlementReviewPackSnapshot($tenant);
|
||||
setReviewPackSubscriptionState($tenant, [
|
||||
'state' => 'past_due',
|
||||
'billing_reference' => 'sub_past_due_001',
|
||||
'current_period_starts_at' => now()->subDays(15)->startOfMinute()->toDateTimeString(),
|
||||
'current_period_ends_at' => now()->addDays(15)->startOfMinute()->toDateTimeString(),
|
||||
'status_reason' => 'Payment collection is pending.',
|
||||
]);
|
||||
|
||||
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
||||
|
||||
expect($decision)
|
||||
->toMatchArray([
|
||||
'is_blocked' => false,
|
||||
'is_warning' => true,
|
||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
|
||||
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION,
|
||||
])
|
||||
->and($decision['warning_reason'])->toContain('Commercial source: subscription-backed.');
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
||||
->assertSee('Commercial source: subscription-backed.');
|
||||
});
|
||||
|
||||
it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
@ -232,6 +233,97 @@ function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant):
|
||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||
});
|
||||
|
||||
it('seeds and advances counted progress while review pack files are written', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
$seededCounts = [];
|
||||
$increments = [];
|
||||
$realOperationRuns = app(OperationRunService::class);
|
||||
|
||||
$spyOperationRuns = new class($realOperationRuns, $seededCounts, $increments) extends OperationRunService
|
||||
{
|
||||
private array $seededCounts;
|
||||
|
||||
private array $increments;
|
||||
|
||||
public function __construct(private readonly OperationRunService $inner, array &$seededCounts, array &$increments)
|
||||
{
|
||||
$this->seededCounts = &$seededCounts;
|
||||
$this->increments = &$increments;
|
||||
}
|
||||
|
||||
public function updateRun(OperationRun $run, string $status, ?string $outcome = null, array $summaryCounts = [], array $failures = []): OperationRun
|
||||
{
|
||||
if ($status === OperationRunStatus::Running->value && $summaryCounts !== []) {
|
||||
$this->seededCounts[] = $summaryCounts;
|
||||
}
|
||||
|
||||
return $this->inner->updateRun($run, $status, $outcome, $summaryCounts, $failures);
|
||||
}
|
||||
|
||||
public function incrementSummaryCounts(OperationRun $run, array $delta): OperationRun
|
||||
{
|
||||
$this->increments[] = $delta;
|
||||
|
||||
return $this->inner->incrementSummaryCounts($run, $delta);
|
||||
}
|
||||
};
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
|
||||
$job->handle($spyOperationRuns);
|
||||
|
||||
$pack->refresh();
|
||||
$operationRun = OperationRun::query()->findOrFail($pack->operation_run_id);
|
||||
|
||||
expect($seededCounts)->toHaveCount(1)
|
||||
->and($seededCounts[0])->toMatchArray([
|
||||
'total' => 7,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
])
|
||||
->and($increments)->toHaveCount(7);
|
||||
|
||||
foreach ($increments as $delta) {
|
||||
expect($delta)->toBe([
|
||||
'processed' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value)
|
||||
->and($operationRun->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 7,
|
||||
'processed' => 7,
|
||||
'created' => 7,
|
||||
'finding_count' => (int) ($pack->summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($pack->summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($pack->summary['operation_count'] ?? 0),
|
||||
]);
|
||||
|
||||
expect($operationRun->summary_counts ?? [])->not->toHaveKeys([
|
||||
'data_freshness',
|
||||
'risk_acceptance',
|
||||
'evidence_resolution',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
@ -190,3 +191,120 @@
|
||||
])
|
||||
->assertHasActionErrors(['reason']);
|
||||
});
|
||||
|
||||
it('creates subscription truth through the confirmed system action and renders subscription-backed detail', function (): void {
|
||||
$workspace = Workspace::factory()->create(['name' => 'Subscription Workspace']);
|
||||
$operator = PlatformUser::factory()->create([
|
||||
'name' => 'Platform Operator',
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
$trialEndsAt = now()->addDays(14)->startOfMinute();
|
||||
|
||||
Livewire::actingAs($operator, 'platform')
|
||||
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||
->assertActionVisible('update_subscription_truth')
|
||||
->assertActionExists('update_subscription_truth', fn (Action $action): bool => $action->getLabel() === 'Update subscription truth'
|
||||
&& $action->isConfirmationRequired())
|
||||
->callAction('update_subscription_truth', data: [
|
||||
'state' => 'trial',
|
||||
'billing_reference' => 'sub_trial_001',
|
||||
'trial_ends_at' => $trialEndsAt->toDateTimeString(),
|
||||
'current_period_starts_at' => null,
|
||||
'current_period_ends_at' => null,
|
||||
'status_reason' => 'Trial access for onboarding.',
|
||||
])
|
||||
->assertNotified('Subscription truth updated');
|
||||
|
||||
$subscription = WorkspaceSubscription::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->first();
|
||||
|
||||
expect($subscription)
|
||||
->not->toBeNull()
|
||||
->and($subscription?->state)->toBe('trial')
|
||||
->and($subscription?->billing_reference)->toBe('sub_trial_001')
|
||||
->and($subscription?->status_reason)->toBe('Trial access for onboarding.');
|
||||
|
||||
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace->fresh());
|
||||
|
||||
expect($summary)
|
||||
->toMatchArray([
|
||||
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION,
|
||||
'subscription_present' => true,
|
||||
'subscription_state' => 'trial',
|
||||
'subscription_state_label' => 'Trial',
|
||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
|
||||
]);
|
||||
|
||||
$this->actingAs($operator, 'platform')
|
||||
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Workspace subscription')
|
||||
->assertSee('subscription-backed')
|
||||
->assertSee('Trial access for onboarding.')
|
||||
->assertSee('sub_trial_001')
|
||||
->assertSee('Trial ends');
|
||||
});
|
||||
|
||||
it('requires a trial end date before changing subscription truth to trial', function (): void {
|
||||
$workspace = Workspace::factory()->create(['name' => 'Trial Validation Workspace']);
|
||||
$operator = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($operator, 'platform')
|
||||
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||
->callAction('update_subscription_truth', data: [
|
||||
'state' => 'trial',
|
||||
'billing_reference' => 'sub_trial_missing_date',
|
||||
'trial_ends_at' => null,
|
||||
'current_period_starts_at' => null,
|
||||
'current_period_ends_at' => null,
|
||||
'status_reason' => 'Trial access needs an explicit end date.',
|
||||
])
|
||||
->assertHasActionErrors(['trial_ends_at']);
|
||||
|
||||
expect(WorkspaceSubscription::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps manual lifecycle fallback controls only for fallback-backed workspaces', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$operator = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($operator, 'platform')
|
||||
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
||||
->assertActionVisible('update_subscription_truth')
|
||||
->assertActionVisible('change_commercial_state');
|
||||
|
||||
WorkspaceSubscription::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => 'active',
|
||||
'current_period_starts_at' => now()->subDay(),
|
||||
'current_period_ends_at' => now()->addDays(29),
|
||||
'status_reason' => 'Annual plan is current.',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($operator, 'platform')
|
||||
->test(ViewWorkspace::class, ['workspace' => $workspace->fresh()])
|
||||
->assertActionVisible('update_subscription_truth')
|
||||
->assertActionHidden('change_commercial_state');
|
||||
});
|
||||
|
||||
@ -18,13 +18,15 @@
|
||||
Notification::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
$snapshot = seedTenantReviewEvidence($tenant, operationRunCount: 3);
|
||||
|
||||
$review = app(\App\Services\TenantReviews\TenantReviewService::class)->create($tenant, $snapshot, $user);
|
||||
$run = OperationRun::query()->findOrFail($review->operation_run_id);
|
||||
|
||||
expect($run->type)->toBe(OperationRunType::TenantReviewCompose->value)
|
||||
->and(OperationCatalog::label((string) $run->type))->toBe('Review composition');
|
||||
->and(OperationCatalog::label((string) $run->type))->toBe('Review composition')
|
||||
->and(data_get($run->context, 'progress.composite.operation_count'))->toBe(3)
|
||||
->and(data_get($run->context, 'progress.composite.label'))->toBe('Review composition is aggregating 3 operations.');
|
||||
|
||||
Queue::assertPushed(ComposeTenantReviewJob::class);
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkBackupSetRestoreJob;
|
||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
@ -9,8 +10,75 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('bulk backup set restore job initializes deduplicated totals only once across launcher replays', function () {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$firstSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'First backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$secondSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Second backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.restore',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']],
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
]);
|
||||
|
||||
$job = new BulkBackupSetRestoreJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
backupSetIds: [(int) $firstSet->getKey(), (int) $firstSet->getKey(), (int) $secondSet->getKey()],
|
||||
operationRun: $run,
|
||||
context: ['target_scope' => ['entra_tenant_id' => 'entra-test-tenant']],
|
||||
);
|
||||
|
||||
$job->handle(app(OperationRunService::class));
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 2,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
$job->handle(app(OperationRunService::class));
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->summary_counts ?? [])->toMatchArray([
|
||||
'total' => 2,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
test('bulk backup set restore job restores archived sets and their items', function () {
|
||||
$tenant = Tenant::factory()->create(['is_current' => true]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
@ -197,3 +198,30 @@ function setCommercialLifecycleState(Workspace $workspace, string $state, string
|
||||
'is_warning' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers subscription truth over fallback lifecycle settings when a current subscription exists', function (): void {
|
||||
[$workspace] = commercialLifecycleWorkspaceManager();
|
||||
|
||||
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Legacy fallback state');
|
||||
|
||||
WorkspaceSubscription::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => 'active',
|
||||
'current_period_starts_at' => now()->subDay(),
|
||||
'current_period_ends_at' => now()->addDays(29),
|
||||
'status_reason' => 'Annual plan is current.',
|
||||
]);
|
||||
|
||||
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
|
||||
|
||||
expect($summary)
|
||||
->toMatchArray([
|
||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||
'source' => 'workspace_subscription',
|
||||
'source_label' => 'workspace subscription',
|
||||
])
|
||||
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome'])
|
||||
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW)
|
||||
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome'])
|
||||
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW);
|
||||
});
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSubscription;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('summarizes a trial subscription as subscription-backed truth', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$trialEndsAt = now()->addDays(14)->startOfMinute();
|
||||
|
||||
WorkspaceSubscription::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => 'trial',
|
||||
'trial_ends_at' => $trialEndsAt,
|
||||
'status_reason' => 'Trial access for onboarding.',
|
||||
]);
|
||||
|
||||
$summary = app(WorkspaceSubscriptionResolver::class)->summary($workspace);
|
||||
|
||||
expect($summary)
|
||||
->toMatchArray([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'subscription_present' => true,
|
||||
'state' => 'trial',
|
||||
'source' => 'workspace_subscription',
|
||||
'fallback_status' => false,
|
||||
'derived_lifecycle_state' => WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
|
||||
'needs_review' => false,
|
||||
'status_reason' => 'Trial access for onboarding.',
|
||||
'key_date_label' => 'Trial ends',
|
||||
])
|
||||
->and($summary['key_date']?->toDateTimeString())
|
||||
->toBe($trialEndsAt->toDateTimeString());
|
||||
});
|
||||
|
||||
it('marks stale cancel-at-period-end subscriptions for explicit review', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$periodStartsAt = now()->subDays(30)->startOfMinute();
|
||||
$periodEndsAt = now()->subDay()->startOfMinute();
|
||||
|
||||
WorkspaceSubscription::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'state' => 'cancel_at_period_end',
|
||||
'current_period_starts_at' => $periodStartsAt,
|
||||
'current_period_ends_at' => $periodEndsAt,
|
||||
'status_reason' => 'Cancellation takes effect at period end.',
|
||||
]);
|
||||
|
||||
$summary = app(WorkspaceSubscriptionResolver::class)->summary($workspace);
|
||||
|
||||
expect($summary)
|
||||
->toMatchArray([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'subscription_present' => true,
|
||||
'state' => 'cancel_at_period_end',
|
||||
'source' => 'workspace_subscription',
|
||||
'fallback_status' => false,
|
||||
'derived_lifecycle_state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
||||
'needs_review' => true,
|
||||
'status_reason' => 'Cancellation takes effect at period end.',
|
||||
'key_date_label' => 'Current period ends',
|
||||
])
|
||||
->and($summary['key_date']?->toDateTimeString())
|
||||
->toBe($periodEndsAt->toDateTimeString());
|
||||
});
|
||||
@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OpsUx\OperationRunProgressContract;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns counted progress for running runs with trustworthy processed totals', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 4,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('counted')
|
||||
->and($progress['display'])->toBe('counted')
|
||||
->and($progress['processed'])->toBe(4)
|
||||
->and($progress['total'])->toBe(10)
|
||||
->and($progress['percent'])->toBe(40)
|
||||
->and($progress['label'])->toBe('4 / 10 processed (40%)');
|
||||
});
|
||||
|
||||
it('clamps counted progress into a truthful visible range', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 15,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('counted')
|
||||
->and($progress['processed'])->toBe(10)
|
||||
->and($progress['total'])->toBe(10)
|
||||
->and($progress['percent'])->toBe(100)
|
||||
->and($progress['label'])->toBe('10 / 10 processed (100%)');
|
||||
});
|
||||
|
||||
it('keeps queued runs activity only even when planned totals exist', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 0,
|
||||
],
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('activity')
|
||||
->and($progress['display'])->toBe('indeterminate')
|
||||
->and($progress['label'])->toBe('Waiting for worker.')
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
|
||||
it('returns no progress for terminal runs even when retained counts exist', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 10,
|
||||
],
|
||||
'started_at' => now()->subMinutes(2),
|
||||
'completed_at' => now()->subSecond(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('none')
|
||||
->and($progress['display'])->toBe('none')
|
||||
->and($progress['label'])->toBeNull()
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
|
||||
it('does not let outcome counters masquerade as counted progress', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'succeeded' => 4,
|
||||
'failed' => 1,
|
||||
'skipped' => 2,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('activity')
|
||||
->and($progress['display'])->toBe('indeterminate')
|
||||
->and($progress['label'])->toBe('Progress details pending.')
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
|
||||
it('classifies repo-real baseline evidence capture runs as phased fallback', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'baseline_capture' => [
|
||||
'evidence_capture' => [
|
||||
'requested' => 10,
|
||||
'succeeded' => 3,
|
||||
'skipped' => 1,
|
||||
],
|
||||
'resume_token' => 'resume-123',
|
||||
],
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('phased')
|
||||
->and($progress['display'])->toBe('indeterminate')
|
||||
->and($progress['label'])->toBe('Capturing evidence.')
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
|
||||
it('uses canonical phase metadata when present for phased runs', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'progress' => [
|
||||
'phase' => [
|
||||
'key' => 'persisting',
|
||||
'label' => 'Saving comparison results.',
|
||||
],
|
||||
],
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('phased')
|
||||
->and($progress['display'])->toBe('indeterminate')
|
||||
->and($progress['label'])->toBe('Saving comparison results.')
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
|
||||
it('classifies aggregate multi-run work as composite fallback', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'operation_count' => 3,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('composite')
|
||||
->and($progress['display'])->toBe('indeterminate')
|
||||
->and($progress['label'])->toBe('Composite progress pending.')
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
|
||||
it('derives a tenant review composite label from aggregate operation truth', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'tenant.review.compose',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'operation_count' => 3,
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('composite')
|
||||
->and($progress['display'])->toBe('indeterminate')
|
||||
->and($progress['label'])->toBe('Review composition is aggregating 3 operations.')
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
|
||||
it('uses explicit composite attention hints when present', function (): void {
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'tenant.review.compose',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'summary_counts' => [
|
||||
'operation_count' => 4,
|
||||
],
|
||||
'context' => [
|
||||
'progress' => [
|
||||
'composite' => [
|
||||
'label' => 'Review composition is aggregating 4 operations. 1 failed operation currently needs review.',
|
||||
],
|
||||
],
|
||||
],
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
|
||||
expect($progress['capability'])->toBe('composite')
|
||||
->and($progress['display'])->toBe('indeterminate')
|
||||
->and($progress['label'])->toBe('Review composition is aggregating 4 operations. 1 failed operation currently needs review.')
|
||||
->and($progress['percent'])->toBeNull();
|
||||
});
|
||||
1
docs/architecture.svg
Normal file
1
docs/architecture.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
161
docs/architecture/tenantpilot-enterprise-architecture.mmd
Normal file
161
docs/architecture/tenantpilot-enterprise-architecture.mmd
Normal file
@ -0,0 +1,161 @@
|
||||
flowchart TB
|
||||
%% ----------------------------------------------------
|
||||
%% 1. USERS & STAKEHOLDERS
|
||||
%% ----------------------------------------------------
|
||||
subgraph Users ["1. Users & Stakeholders"]
|
||||
SysAdmin(["Platform Superadmin"])
|
||||
WsAdmin(["Workspace Admin"])
|
||||
TenOp(["Tenant Operator"])
|
||||
CustRev(["Customer Reviewer"])
|
||||
Auditor(["Auditor / Compliance"])
|
||||
ExtSupport(["External Support / PSA"])
|
||||
end
|
||||
|
||||
%% ----------------------------------------------------
|
||||
%% 2. IDENTITY & ACCESS
|
||||
%% ----------------------------------------------------
|
||||
subgraph Identity_Access ["2. Access & Identity"]
|
||||
EntraOIDC["Entra OIDC / Tenant Users"]
|
||||
LocalUsers["Local Platform Users / Break-glass"]
|
||||
|
||||
SysPanel["Platform / System Panel"]
|
||||
AdminPanel["Admin / Tenant Panel"]
|
||||
|
||||
RBAC["Capability-based RBAC"]
|
||||
ScopeRes["Workspace / Tenant Scope Resolution"]
|
||||
end
|
||||
|
||||
%% ----------------------------------------------------
|
||||
%% 3. APPLICATION UI
|
||||
%% ----------------------------------------------------
|
||||
subgraph Application_UI ["3. Application UI (Filament)"]
|
||||
TenDash["Tenant Dashboard"]
|
||||
OpsHub["Operations Hub"]
|
||||
InvVersions["Inventory & Policy Versions"]
|
||||
FindInbox["Findings / Governance Inbox"]
|
||||
RevWorkspace["Reviews / Customer Workspace"]
|
||||
EvidReports["Evidence & Reports"]
|
||||
SuppHandoffUI["Support Handoff"]
|
||||
end
|
||||
|
||||
%% ----------------------------------------------------
|
||||
%% 4. DOMAIN SERVICES
|
||||
%% ----------------------------------------------------
|
||||
subgraph Domain_Services ["4. Domain Services"]
|
||||
WsSvc["Workspace Service"]
|
||||
TenSvc["Tenant Service"]
|
||||
OpRunSvc["OperationRun Service"]
|
||||
BackRestSvc["Backup / Restore Services"]
|
||||
DriftSvc["Drift Detection Service"]
|
||||
BaseCompSvc["Baseline / Compliance Service"]
|
||||
EvidSnapSvc["Evidence Snapshot Service"]
|
||||
RevPackSvc["Review Pack Service"]
|
||||
NotifSvc["Notification Service"]
|
||||
SuppSvc["Support Handoff Service"]
|
||||
end
|
||||
|
||||
%% ----------------------------------------------------
|
||||
%% 5. EXECUTION & OPERATIONS
|
||||
%% ----------------------------------------------------
|
||||
subgraph Execution_Operations ["5. Execution & Operations"]
|
||||
Scheduler["Scheduler"]
|
||||
QueuesJobs["Queues / Jobs"]
|
||||
OpRunLife["OperationRun Lifecycle"]
|
||||
ProgStat["Progress / Status / Failure Reason"]
|
||||
AuditEvt["Audit Events"]
|
||||
end
|
||||
|
||||
%% ----------------------------------------------------
|
||||
%% 6. PROVIDER BOUNDARY
|
||||
%% ----------------------------------------------------
|
||||
subgraph Provider_Boundary ["6. Provider Boundary"]
|
||||
ProvGateway["Provider Gateway"]
|
||||
GraphAdapt["Microsoft Graph Adapter"]
|
||||
IntuneProv["Intune Provider"]
|
||||
FutProv["Optional future providers"]
|
||||
|
||||
ExtPSA["External PSA / ITSM"]
|
||||
MailTeams["Email / Teams Notifications"]
|
||||
end
|
||||
|
||||
%% ----------------------------------------------------
|
||||
%% 7. DATA STORE
|
||||
%% ----------------------------------------------------
|
||||
subgraph Data_Store ["7. Data Store (PostgreSQL)"]
|
||||
dbOrg[("Workspaces & Tenants")]
|
||||
dbIAM[("Users, Memberships & Capabilities")]
|
||||
dbOps[("OperationRuns & AuditLogs")]
|
||||
dbDomain[("Policies, Versions & Findings")]
|
||||
dbGov[("EvidenceSnapshots & StoredReports")]
|
||||
dbSupport[("SupportRequests & Notifications")]
|
||||
end
|
||||
|
||||
%% ----------------------------------------------------
|
||||
%% 8. GOVERNANCE & EVIDENCE
|
||||
%% ----------------------------------------------------
|
||||
subgraph Governance_Evidence ["8. Governance & Evidence"]
|
||||
BaseProf["Baseline Profiles"]
|
||||
CtrlCat["Control Catalog"]
|
||||
RiskAcc["Risk Acceptance / Exceptions"]
|
||||
EvidMap["Evidence Mapping"]
|
||||
TenRev["Tenant Reviews"]
|
||||
AudPack["Auditor Packs"]
|
||||
StoreRep["Stored Reports"]
|
||||
end
|
||||
|
||||
%% ====================================================
|
||||
%% RELATIONSHIPS & EDGES
|
||||
%% ====================================================
|
||||
|
||||
%% Users to Identity
|
||||
SysAdmin & ExtSupport -->|authenticates| LocalUsers
|
||||
WsAdmin & TenOp & CustRev & Auditor -->|authenticates| EntraOIDC
|
||||
|
||||
LocalUsers -->|accesses| SysPanel
|
||||
EntraOIDC -->|accesses| AdminPanel
|
||||
|
||||
SysPanel & AdminPanel -->|authorizes| RBAC
|
||||
RBAC -->|selects scope| ScopeRes
|
||||
|
||||
%% Identity to UI
|
||||
ScopeRes -->|renders context| TenDash & OpsHub & InvVersions & FindInbox & RevWorkspace & EvidReports & SuppHandoffUI
|
||||
|
||||
%% UI to Domain
|
||||
TenDash -->|reads state| WsSvc & TenSvc
|
||||
OpsHub -->|creates run| OpRunSvc
|
||||
InvVersions -->|fetches state| BackRestSvc
|
||||
FindInbox -->|reviews| DriftSvc
|
||||
RevWorkspace -->|publishes review| RevPackSvc
|
||||
EvidReports -->|views| EvidSnapSvc
|
||||
SuppHandoffUI -->|escalates| SuppSvc
|
||||
|
||||
%% Domain to Governance
|
||||
BaseCompSvc -->|manages| BaseProf & CtrlCat
|
||||
DriftSvc -->|evaluates against| RiskAcc
|
||||
EvidSnapSvc -->|maps to| EvidMap
|
||||
RevPackSvc -->|exports report| TenRev & AudPack & StoreRep
|
||||
|
||||
%% Domain to Execution
|
||||
OpRunSvc -->|dispatches job| QueuesJobs
|
||||
BackRestSvc & DriftSvc & BaseCompSvc & EvidSnapSvc & RevPackSvc -->|dispatches job| QueuesJobs
|
||||
Scheduler -->|triggers| QueuesJobs
|
||||
|
||||
QueuesJobs -->|updates| OpRunLife
|
||||
OpRunLife -->|tracks| ProgStat
|
||||
OpRunLife -->|emits audit event| AuditEvt
|
||||
|
||||
%% Execution & Domain to Provider
|
||||
QueuesJobs -->|reads / mutates| ProvGateway
|
||||
ProvGateway -->|routes to| GraphAdapt & FutProv
|
||||
GraphAdapt -->|calls| IntuneProv
|
||||
|
||||
NotifSvc & AuditEvt -->|sends notification| MailTeams
|
||||
SuppSvc -->|creates ticket| ExtPSA
|
||||
|
||||
%% Persistence Layer (Services / Exec to DB)
|
||||
WsSvc & TenSvc -->|writes| dbOrg
|
||||
RBAC -->|reads / writes| dbIAM
|
||||
OpRunLife & AuditEvt -->|records execution truth| dbOps
|
||||
BackRestSvc & DriftSvc -->|writes| dbDomain
|
||||
EvidSnapSvc & RevPackSvc -->|writes evidence| dbGov
|
||||
NotifSvc & SuppSvc -->|writes| dbSupport
|
||||
@ -1328,6 +1328,36 @@ # TenantPilot Enterprise UI Standards**Status:** Active **Owner:** Product / En
|
||||
|
||||
show determinate progress only when summary_counts.total and summary_counts.processed are real numeric values, clamp the progressbar to 0-100, and only show processed or percentage text when it is derived from those repo-real values
|
||||
|
||||
derive progress treatment from one shared OperationRun progress contract instead of local Blade or widget math
|
||||
|
||||
allow counted progress only for current run families that persist truthful summary_counts.total plus summary_counts.processed during execution:
|
||||
|
||||
- inventory sync
|
||||
- review-pack generation
|
||||
- evidence-snapshot generation
|
||||
- backup-set policy additions
|
||||
- backup-set bulk restore
|
||||
|
||||
keep all other run families on activity-only, phased, or composite treatment until they persist equally trustworthy progress truth through the shared contract
|
||||
|
||||
limit the current non-counted phase or composite contract to these run families only:
|
||||
|
||||
- baseline_capture
|
||||
- baseline_compare
|
||||
- tenant.review.compose
|
||||
|
||||
keep queued rows activity-only even when a planned total exists
|
||||
|
||||
keep running rows without trustworthy processed and total counts activity-only or indeterminate
|
||||
|
||||
keep summary_counts.succeeded, summary_counts.failed, and summary_counts.skipped outcome-only; they must not silently replace processed as progress truth
|
||||
|
||||
for baseline_capture and baseline_compare, use canonical progress.phase metadata with short operator-safe, non-technical labels such as Preparing baseline capture., Capturing evidence., Refreshing comparison evidence., Evaluating baseline drift., Saving baseline snapshot., and Finalizing baseline comparison.
|
||||
|
||||
for tenant.review.compose, use canonical progress.composite metadata only for bounded aggregate status copy such as Review composition is aggregating 3 operations.; this remains composite indeterminate copy, not counted progress, even when operation_count is visible
|
||||
|
||||
when canonical phase or composite metadata is absent or malformed, degrade safely to the existing generic phased or composite fallback instead of inventing percentages, strategy detail, provider detail, or raw technical diagnostics in the shell
|
||||
|
||||
switch terminal-success rows to success-state copy instead of showing active progress after completion
|
||||
|
||||
separate successful completion from unresolved terminal follow-up in the shell helper copy:
|
||||
@ -1337,6 +1367,13 @@ # TenantPilot Enterprise UI Standards**Status:** Active **Owner:** Product / En
|
||||
|
||||
keep hide, dismiss, or acknowledge behavior browser-session-only and re-open the hint when a new run-enqueued event is accepted for the current tenant
|
||||
|
||||
record these as follow-up work instead of widening the current contract:
|
||||
|
||||
- provider health or support-diagnostics progress rollout
|
||||
- review-pack or evidence-snapshot overlap with other progress specs
|
||||
- child-run graph persistence or composite child-link expansion
|
||||
- dashboard cards or workflow-engine generated progress explanations
|
||||
|
||||
It MUST NOT:
|
||||
|
||||
render as a detached document-level BODY_START banner above the TenantPilot application chrome
|
||||
@ -1347,6 +1384,8 @@ # TenantPilot Enterprise UI Standards**Status:** Active **Owner:** Product / En
|
||||
|
||||
show fake percentages, guessed completion, or fake progress
|
||||
|
||||
let outcome counters, aggregate operation counts, or phase hints masquerade as counted progress
|
||||
|
||||
show active progress UI after a run is already terminal
|
||||
|
||||
persist hide or dismiss state in the database or on the OperationRun record
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
# Specification Quality Checklist: OperationRun Progress Contract v1
|
||||
|
||||
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||
**Created**: 2026-05-04
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The package stays on one bounded shared progress contract over existing `OperationRun` truth instead of widening into counted writer rollout, dashboard redesign, or a second execution framework.
|
||||
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
|
||||
- [x] The package explicitly names the repo-real anchors it builds on: `SummaryCountsNormalizer`, `OperationSummaryKeys`, `ActiveRuns`, `OperationRunService`, and the current inline progress seam in `bulk-operation-progress.blade.php`.
|
||||
- [x] Mandatory repo sections for scope, shared-pattern reuse, Ops-UX, testing, proportionality, and candidate rationale are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No unresolved clarification markers remain.
|
||||
- [x] Requirements are testable and bounded to current `OperationRun` truth, one shared progress contract, one visible shell adopter, and one standards update only.
|
||||
- [x] The package explicitly keeps `summary_counts.processed` and `summary_counts.total` as the only v1 determinate progress source.
|
||||
- [x] The package explicitly forbids `succeeded`, `failed`, and `skipped` from silently becoming progress substitutes.
|
||||
- [x] The package declares `phased` and `composite` categories without inventing fake percentages, new persistence, or hidden writer rollout.
|
||||
- [x] The package explains what remains in scope versus what is intentionally deferred to Specs 271, 272, and 273.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and is consistent with the broader roadmap direction.
|
||||
- [x] The active queue is explicitly empty, so this package records itself as a deliberate manual promotion rather than an automatic next-best-prep target.
|
||||
- [x] Repo verification explicitly excluded `269 — OperationRun Terminal Outcome Feedback` because `specs/268-operationrun-activity-feedback/` already owns that shell terminal slice.
|
||||
- [x] `273 — Tenant Dashboard Active Operations Summary Card` remains conditional on visible dashboard drift after Spec 268, so it is not a safer prep target than the progress contract.
|
||||
- [x] `271` and `272` remain legitimate follow-ups, but both depend on the shared contract from this package.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The package reuses current `OperationRun` truth and current summary-count sanitization instead of introducing a second lifecycle or persisted projection.
|
||||
- [x] The package forbids new panel, provider, global-search, asset-registration, queue-family, and notification-policy changes.
|
||||
- [x] The package preserves the current polling posture and explicitly forbids new parallel polling loops.
|
||||
- [x] The package carries current no-tenant and unauthorized shell-visibility behavior into focused feature-proof tasks instead of assuming auth semantics survive the refactor automatically.
|
||||
- [x] The package names the current inline shell progress math as the concrete repo seam to remove.
|
||||
- [x] The package keeps `OperationRun.status`, `OperationRun.outcome`, `OperationSummaryKeys`, and `SummaryCountsNormalizer` as the current authoritative truth owners.
|
||||
- [x] The planned validation commands stay consistent across `spec.md`, `plan.md`, and `tasks.md`.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned proof stays bounded to one new `Unit` suite plus focused `Feature` suites.
|
||||
- [x] No new heavy-governance or browser family is introduced by default.
|
||||
- [x] Fixture growth remains bounded to current `OperationRun` factories and current Ops-UX test helpers instead of a new matrix harness.
|
||||
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into the active prep package.
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `.specify/memory/constitution.md`, `.specify/templates/checklist-template.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/268-operationrun-activity-feedback/spec.md`, `apps/platform/app/Support/OpsUx/ActiveRuns.php`, `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`, `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`, `apps/platform/app/Services/OperationRunService.php`, and `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php` on 2026-05-04.
|
||||
- This checklist is the prep-time outcome record. If implementation widens into counted writer rollout, dashboard-specific progress work, or a persisted progress mode, the workflow outcome must change before merge.
|
||||
- No application implementation was performed while preparing this package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Reason**: the package is a bounded shared-truth contract justified by multiple repo-real consumers, keeps the visible adoption limited to the current shell host, and explicitly defers the heavier rollout work to later manual-promotion specs.
|
||||
- **Final note location**: This checklist during prep, and the active feature PR close-out entry only if implementation later forces `split` or `document-in-feature`.
|
||||
249
specs/270-operationrun-progress-contract/plan.md
Normal file
249
specs/270-operationrun-progress-contract/plan.md
Normal file
@ -0,0 +1,249 @@
|
||||
# Implementation Plan: OperationRun Progress Contract v1
|
||||
|
||||
**Branch**: `270-operationrun-progress-contract` | **Date**: 2026-05-04 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/270-operationrun-progress-contract/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
This plan prepares one bounded Ops-UX foundation slice over existing `OperationRun` truth. The implementation path is to introduce one shared progress-semantics contract in the current `App\Support\OpsUx` family, move progress-mode decisions out of `bulk-operation-progress.blade.php`, and document the contract in `docs/ui/tenantpilot-enterprise-ui-standards.md`. The slice must stay on existing `OperationRun.status`, `OperationRun.outcome`, `summary_counts`, and `context` truth; it must not widen into counted writer rollout, dashboard redesign, terminal-notification changes, or new persistence.
|
||||
|
||||
Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no asset registration or deployment step is expected.
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `SummaryCountsNormalizer` and `OperationSummaryKeys` already sanitize and whitelist numeric `summary_counts` values.
|
||||
- `OperationRunService` already owns `summary_counts` writes through `updateRun()`, `incrementSummaryCounts()`, and `maybeCompleteBulkRun()`.
|
||||
- `ActiveRuns` already owns shell-visible run selection and terminal-success grace-window filtering.
|
||||
- `BulkOperationProgress` and `bulk-operation-progress.blade.php` already render the current shell host, but the progress semantics are still decided inline in the Blade view.
|
||||
- `specs/268-operationrun-activity-feedback/` already owns the shell terminal-success and terminal-follow-up slice.
|
||||
- Historical Ops-UX specs already require numeric-only `summary_counts` and preserve the three-surface lifecycle contract.
|
||||
|
||||
### Explicit delta in this plan
|
||||
|
||||
- formalize one shared `OperationRun` progress capability and render-model contract
|
||||
- centralize counted vs activity-only vs terminal no-progress semantics in one Ops-UX helper
|
||||
- move current shell progress logic off inline Blade math and onto that shared contract
|
||||
- document future-safe boundaries for `phased` and `composite` progress without rolling them out yet
|
||||
- leave run-writer rollout, dashboard follow-up work, and phase/composite truth to later specs
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: current Ops-UX support classes, native Filament widgets/Blade, Pest v4
|
||||
**Storage**: PostgreSQL via existing `operation_runs`; no new persistence
|
||||
**Testing**: Pest Unit + Feature coverage
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: existing Laravel monolith in `apps/platform`, admin/operator plane only
|
||||
**Project Type**: Web application (Laravel monolith with Filament)
|
||||
**Performance Goals**: no new query families, no extra polling loops, and no slower-than-current shell rendering for active-run feedback
|
||||
**Constraints**: no new `summary_counts` keys, no new run lifecycle, no new persistence, and no browser-only proof requirement in this slice
|
||||
**Scale/Scope**: one shared contract, one shell adopter, one standards update, and focused regression coverage
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`
|
||||
- `apps/platform/app/Support/OpsUx/ActiveRuns.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
- one new bounded helper under `apps/platform/app/Support/OpsUx/` for progress capability/render semantics
|
||||
- `apps/platform/app/Livewire/BulkOperationProgress.php`
|
||||
- `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`
|
||||
- `apps/platform/app/Services/OperationRunService.php`
|
||||
- `apps/platform/tests/Unit/Support/OpsUx/...`
|
||||
- `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`
|
||||
- `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`
|
||||
- `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
|
||||
- `docs/ui/tenantpilot-enterprise-ui-standards.md`
|
||||
|
||||
## UI / Filament & Livewire Fit
|
||||
|
||||
- Keep the changed surface Filament-native. The visible v1 adopter remains the existing Livewire shell host rather than a new page, widget family, or dashboard card.
|
||||
- The shell host stays decision-first. The new contract decides only whether a progress line or bar is truthful; it does not widen the shell into a diagnostics surface.
|
||||
- Monitoring collection/detail pages remain diagnostics-first drill-through targets. This slice prepares their future compatibility by naming one shared contract, not by redesigning their UI.
|
||||
- The current shell host may keep bounded progress text or bars, but it must no longer calculate their eligibility or percentages inline.
|
||||
- No new asset registration, panel configuration, or provider registration change is planned.
|
||||
|
||||
## RBAC / Policy Fit
|
||||
|
||||
- Existing `OperationRun` policies remain the first and only visibility gate.
|
||||
- The progress contract derives output only after the current actor is already entitled to see the run.
|
||||
- Tenant/admin plane behavior stays unchanged: no cross-plane expansion and no new authorization surface.
|
||||
- No new mutation or retry action is introduced, so current confirmation/authorization behavior stays on existing start surfaces and run detail pages.
|
||||
|
||||
## Audit / Logging Fit
|
||||
|
||||
- Existing queued toasts and terminal DB notifications remain authoritative and unchanged.
|
||||
- Existing run audit and Monitoring behavior remain the only audit trail. No new view-level or contract-level audit stream is introduced.
|
||||
- `OperationRun.status` and `OperationRun.outcome` remain service-owned and unchanged.
|
||||
|
||||
## Data & Query Fit
|
||||
|
||||
- The contract derives only from current `OperationRun` truth: `status`, `outcome`, sanitized `summary_counts`, and bounded current `context` where trustworthy phase/composite truth may later exist.
|
||||
- Determinate progress remains limited to current running work with trustworthy numeric `processed` and `total` counters.
|
||||
- Outcome counters remain summary truth only; they are not reinterpreted as progress inputs.
|
||||
- No migration, no new JSON schema, no backfill, and no cache layer are planned.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament plus a bounded local Ops-UX helper refactor
|
||||
- **Shared-family relevance**: Ops UX start feedback and execution-truth summaries
|
||||
- **State layers in scope**: shell, page
|
||||
- **Audience modes in scope**: operator-MSP
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the shell host, diagnostics-first on Operations collection/detail
|
||||
- **Raw/support gating plan**: raw/support evidence stays on the current diagnostics surfaces only
|
||||
- **One-primary-action / duplicate-truth control**: the shell keeps the current dominant `View operation` action and only swaps local progress math for the shared contract; it does not add duplicate progress explanations
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: global-context-shell
|
||||
- **Required tests**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none planned; any attempt to add new writer semantics, a new browser family, or dashboard-specific progress logic resolves as `reject-or-split`
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: current shell host, summary-count sanitization, progress disclosure semantics, and UI standards
|
||||
- **Shared abstractions reused**: `SummaryCountsNormalizer`, `OperationSummaryKeys`, `ActiveRuns`, `OperationStatusNormalizer`, `OperationUxPresenter`, current Ops-UX shell host
|
||||
- **New abstraction introduced? why?**: yes, one bounded shared progress contract/helper because the repo already has multiple real consumers and the current progress truth gap cannot stay view-local without drift
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the repo already owns lifecycle normalization and count sanitization, but it does not currently answer progress eligibility or progress mode once and centrally
|
||||
- **Bounded deviation / spread control**: do not create multiple host-specific helpers, a registry, or a persisted progress model
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for active-surface progress disclosure only
|
||||
- **Central contract reused**: current Ops-UX start contract via `OperationRunLinks`, `OperationRunUrl`, `ActiveRuns`, `OperationStatusNormalizer`, and `OperationUxPresenter`
|
||||
- **Delegated UX behaviors**: queued toast wording, canonical view/collection links, current browser-event dispatch, and existing terminal DB notifications remain delegated to the shared contract and unchanged
|
||||
- **Surface-owned behavior kept local**: bounded shell layout and copy density only
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: `N/A`
|
||||
- **Platform-core seams**: existing `OperationRun` truth, summary-count vocabulary, and operator-facing execution language only
|
||||
- **Neutral platform terms / contracts preserved**: `Operation`, `activity`, `progress`, `terminal outcome`, `counted progress`
|
||||
- **Retained provider-specific semantics and why**: none
|
||||
- **Bounded extraction or follow-up path**: none
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins and again before merge.*
|
||||
|
||||
- Inventory-first: PASS. The slice is fully derived from existing `OperationRun` truth.
|
||||
- Read/write separation: PASS. No new write path or retry surface is introduced.
|
||||
- Graph contract path: PASS. No Graph/provider interaction is added.
|
||||
- Deterministic capabilities: PASS. Existing `OperationRun` policies remain authoritative.
|
||||
- RBAC-UX: PASS. No plane expansion; tenant/admin visibility stays on current guards and deny-as-not-found semantics.
|
||||
- Run observability: PASS. Existing start contract, terminal notifications, and Monitoring ownership remain unchanged while progress semantics are centralized.
|
||||
- Ops-UX lifecycle: PASS. No change to service-owned status/outcome transitions or `summary_counts` ownership.
|
||||
- Data minimization: PASS. Hosts stay compact and do not surface raw evidence by default.
|
||||
- Test governance: PASS. Proof stays bounded to unit plus feature coverage.
|
||||
- Proportionality / no premature abstraction: PASS. The helper is justified by multiple real consumers and avoids wider rollout or persistence.
|
||||
- Persisted truth / behavioral state: PASS. No new table, cache, or progress-mode persistence.
|
||||
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing helpers stay central, and the current shell moves closer to the Ops-UX contract.
|
||||
- Provider boundary: PASS. No provider/platform seam changes.
|
||||
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned.
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for progress-capability/render-model truth; Feature for current shell adoption and shell-visible progress output
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: one unit suite proves the shared contract itself, while focused shell feature tests prove the visible adopter no longer calculates progress locally. Browser proof remains owned by `specs/268-operationrun-activity-feedback/` because this slice does not change layout or clickability.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse current `OperationRun` factories and tenant helpers instead of introducing new provider-heavy defaults
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `global-context-shell`
|
||||
- **Closing validation and reviewer handoff**: reviewers should rerun the focused commands above, then confirm the shell uses one shared progress contract, queued runs stay indeterminate, terminal runs stay terminal, and outcome counters never create a percentage
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
|
||||
- **Review-stop questions**: did the helper stay bounded, did the shell lose its inline progress math, did any new `summary_counts` keys or writer semantics appear, and were `269`, `271`, `272`, and `273` kept out of scope?
|
||||
- **Escalation path**: `reject-or-split` for any writer rollout, dashboard redesign, or persisted progress model
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Why no dedicated follow-up spec is needed**: this package is itself the bounded contract layer. Later counted rollout and phase/composite rollout remain explicit follow-up specs rather than hidden growth here.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/270-operationrun-progress-contract/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
This preparation package intentionally stays on the core artifacts plus the readiness checklist. The repo already contains the relevant Ops-UX truth, current shell host, and adjacent tests, so no extra research, data-model, or contract package is required for a bounded implementation handoff.
|
||||
|
||||
### Source Code (expected implementation surfaces)
|
||||
|
||||
```text
|
||||
apps/platform/app/Support/OpsUx/
|
||||
apps/platform/app/Livewire/BulkOperationProgress.php
|
||||
apps/platform/resources/views/livewire/bulk-operation-progress.blade.php
|
||||
apps/platform/app/Services/OperationRunService.php
|
||||
apps/platform/tests/Unit/Support/OpsUx/
|
||||
apps/platform/tests/Feature/OpsUx/
|
||||
docs/ui/tenantpilot-enterprise-ui-standards.md
|
||||
```
|
||||
|
||||
**Structure Decision**: keep the implementation local to the existing Ops-UX support family and the current shell host. Do not introduce a new activity or progress framework outside `App\Support\OpsUx`.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
- No migration or new table is planned.
|
||||
- No new persisted user preference or progress-mode storage is allowed.
|
||||
- No new cache layer, backfill, or asset/deploy step should be required for v1.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Filament remains v5 on Livewire v4. No panel-provider change is required, and provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- No global search change is required because the slice changes shared progress semantics, not resource discovery.
|
||||
- No destructive action is added. Existing start/retry/detail surfaces remain the only mutation owners.
|
||||
- No new asset registration is expected.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Reject any implementation that reopens the shell terminal-outcome slice already owned by `specs/268-operationrun-activity-feedback/` and the deferred `269` candidate.
|
||||
- Reject any implementation that introduces new `summary_counts` keys, a persisted progress mode, or a new `OperationRun` lifecycle.
|
||||
- Reject any implementation that derives percentages from status, duration, stale heuristics, or outcome counters.
|
||||
- Reject any implementation that widens the slice into dashboard-specific redesign, activity tray work, or counted writer rollout.
|
||||
- Reject any implementation that leaves a second progress calculator in Blade, Livewire, or another current host surface.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Confirm Current Progress Truth And Drift Seams
|
||||
|
||||
- Verify the current writer seams (`OperationRunService`, `SummaryCountsNormalizer`, `OperationSummaryKeys`) and the current visible adopter (`BulkOperationProgress`).
|
||||
|
||||
### Phase 1 - Encode The Shared Progress Contract
|
||||
|
||||
- Introduce one shared progress contract/helper that classifies capability and derives render-safe output from existing `OperationRun` truth.
|
||||
|
||||
### Phase 2 - Adopt The Current Shell Host
|
||||
|
||||
- Move shell progress eligibility and percentage math out of `bulk-operation-progress.blade.php` and onto the shared contract.
|
||||
|
||||
### Phase 3 - Record The Guardrail And Future Boundaries
|
||||
|
||||
- Update the UI standards and the focused tests so later specs inherit the same contract instead of re-explaining it locally.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: the repo has truthful counters and lifecycle state, but the current visible host still computes progress ad hoc.
|
||||
- **Existing structure is insufficient because**: sanitization and lifecycle normalization alone do not decide whether determinate progress is allowed.
|
||||
- **Narrowest correct implementation**: one shared progress-semantics helper plus shell adoption and one standards-doc update.
|
||||
- **Ownership cost created**: one helper, focused tests, and one standards update.
|
||||
- **Alternative intentionally rejected**: view-local math or persisted progress modes were rejected because they either preserve drift or add unjustified persistence.
|
||||
- **Release truth**: current-release truth. The repo already renders progress and already stores the counts needed to centralize the semantics now.
|
||||
273
specs/270-operationrun-progress-contract/spec.md
Normal file
273
specs/270-operationrun-progress-contract/spec.md
Normal file
@ -0,0 +1,273 @@
|
||||
# Feature Specification: OperationRun Progress Contract v1
|
||||
|
||||
**Feature Branch**: `270-operationrun-progress-contract`
|
||||
**Created**: 2026-05-04
|
||||
**Status**: Ready for implementation
|
||||
**Input**: Manual promotion from `docs/product/spec-candidates.md` after repo-based duplicate verification excluded candidate `269` because `specs/268-operationrun-activity-feedback/` already owns the terminal-success and terminal-follow-up shell slice.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: `OperationRun` progress rendering is still decided ad hoc in the shell activity view and raw `summary_counts` conventions. The repo sanitizes numeric counts and normalizes lifecycle status, but it does not yet have one shared product contract that answers whether a run should show no progress, indeterminate activity, determinate counted progress, or a future phase/composite fallback.
|
||||
- **Today's failure**: `BulkOperationProgress` currently calculates progress inline from raw `summary_counts`, while other current or future surfaces can only see status, outcome, and flattened counters. That makes it too easy for a surface to invent fake percentages from status, duration, or outcome counters such as `failed`, `succeeded`, or `skipped`, and it leaves future `OperationRun` hosts without one truthful progress language.
|
||||
- **User-visible improvement**: Operators see one consistent, honest progress language across the covered Ops-UX surfaces: queued work and active work without trustworthy counts show activity only, active running work with trustworthy `processed` and `total` counts shows determinate progress, and terminal outcomes never masquerade as progress.
|
||||
- **Smallest enterprise-capable version**: introduce one shared Ops-UX progress contract or presenter over existing `OperationRun` truth, adopt it in the current shell activity feedback surface that already renders progress, and document the contract in the UI standards so later run hosts and later rollout specs extend the same rules instead of improvising them locally.
|
||||
- **Explicit non-goals**: no broad counted-progress rollout across run writers, no new `OperationRun` type or lifecycle, no dashboard redesign, no new queue or notification policy, no new `summary_counts` keys, no persistence for progress modes, no AI summaries, no customer-facing review changes, and no reopening of `specs/268-operationrun-activity-feedback/` or `specs/266-tenant-dashboard-productization-v1/` as the primary scope.
|
||||
- **Permanent complexity imported**: one bounded shared progress-semantics helper in `App\Support\OpsUx`, one derived progress-capability vocabulary (`none`, `activity`, `counted`, `phased`, `composite`) kept in code and docs only, focused unit plus feature coverage, and one UI standards update.
|
||||
- **Why now**: `specs/268-operationrun-activity-feedback/` already captures shell terminal outcome semantics, which makes the remaining abstraction gap visible: progress meaning still lives inline in a Blade view. The candidate backlog explicitly sequences `270` ahead of `271` and `272`, and the repo already contains the concrete seams needed to prepare this contract without inventing a new foundation.
|
||||
- **Why not local**: a local shell-only math cleanup would still leave future `OperationRun` surfaces, future counted rollouts, and future phase/composite work free to invent their own progress semantics from raw counters.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: new derived capability vocabulary, one shared Ops-UX presenter/contract seam, and one cross-surface truth contract. Defense: the vocabulary stays derived only, the contract is justified by multiple real consumers, and no new persistence or framework layer is introduced.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/...` tenant-scoped start surfaces that receive the shared shell activity hint
|
||||
- `/admin/operations` and `/admin/operations/{run}` remain the canonical operations collection/detail routes whose summaries must stay compatible with the shared progress contract even when this slice does not broaden their visible UI
|
||||
- `/admin/t/{tenant}` remains contextual-only for any existing recent-operation summary that already consumes the same Ops-UX family; no new dashboard card is introduced in this slice
|
||||
- **Data Ownership**: existing `OperationRun.status`, `OperationRun.outcome`, `OperationRun.summary_counts`, and `OperationRun.context` remain the only persisted truth. The progress contract is derived and must not introduce a new table, cache, mirror entity, or persisted progress mode.
|
||||
- **RBAC**: existing `OperationRun` policies remain authoritative. Out-of-scope tenants stay deny-as-not-found (`404` semantics through the current tenant/admin boundaries), and in-scope actors only see progress states for runs they can already view.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, activity feedback, progress disclosure, canonical run links, and execution-truth summaries
|
||||
- **Systems touched**: `BulkOperationProgress`, `ActiveRuns`, `OperationRunService`, `SummaryCountsNormalizer`, `OperationSummaryKeys`, `OperationStatusNormalizer`, `OperationUxPresenter`, current Ops-UX tests, and `docs/ui/tenantpilot-enterprise-ui-standards.md`
|
||||
- **Existing pattern(s) to extend**: current numeric-only `summary_counts` normalization, current lifecycle normalization, current active shell feedback surface, and the existing Ops-UX 3-surface contract
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\OpsUx\SummaryCountsNormalizer`, `App\Support\OpsUx\OperationSummaryKeys`, `App\Support\OpsUx\ActiveRuns`, `App\Support\OpsUx\OperationStatusNormalizer`, `App\Support\OpsUx\OperationUxPresenter`, `App\Services\OperationRunService`, and `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`
|
||||
- **Why the existing shared path is sufficient or insufficient**: the repo already sanitizes allowed counters and normalizes lifecycle state, but none of those helpers currently decides whether a surface should show progress, what kind of progress is allowed, or when progress must collapse back to terminal outcome or simple activity.
|
||||
- **Allowed deviation and why**: none planned. The feature must remove shell-local progress inference instead of creating a second widget-local contract.
|
||||
- **Consistency impact**: `Queued`, `In progress`, `Completed successfully`, indeterminate activity labels, determinate progress labels, and the rule that outcome counters never substitute for `processed` must keep one meaning across covered Ops-UX surfaces and docs.
|
||||
- **Review focus**: reviewers must block any implementation that derives percentage from status, duration, stale-state heuristics, or outcome counters, or that leaves a second local progress calculator in Blade or Livewire code.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: the existing Ops-UX start contract through `OperationRunLinks`, `OperationRunUrl`, `ActiveRuns`, `OperationStatusNormalizer`, and `OperationUxPresenter`, extended with one shared progress-semantics helper
|
||||
- **Delegated start/completion UX behaviors**: queued toasts, canonical `View operation` link generation, tenant-safe URL resolution, current `run-enqueued` browser events, and existing terminal database-notification behavior remain delegated to the current shared path and are unchanged in this slice
|
||||
- **Local surface-owned behavior that remains**: shell layout, copy density, and bounded host placement stay local to the existing shell surface; progress semantics no longer remain view-local
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Boundary classification**: `N/A`
|
||||
- **Seams affected**: `N/A`
|
||||
- **Neutral platform terms preserved or introduced**: `Operation`, `activity`, `progress`, `counted progress`, `terminal outcome`
|
||||
- **Provider-specific semantics retained and why**: none
|
||||
- **Why this does not deepen provider coupling accidentally**: the feature only formalizes progress semantics over existing platform-owned `OperationRun` truth
|
||||
- **Follow-up path**: none
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Tenant shell activity hint progress treatment | yes | Native Filament + existing Livewire/Blade surface | Ops UX activity feedback, execution-truth summaries | shell, page | no | The shell remains the visible v1 adopter; this slice moves progress semantics out of inline Blade math and into one shared contract |
|
||||
|
||||
## Decision-First Surface Role *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant shell activity hint | Primary Decision Surface | Decide whether current work simply needs time, needs inspection now, or is already terminal | operation label, lifecycle state, one truthful progress mode, and the canonical `View operation` action | full run detail, logs, evidence, and diagnostics stay in Operations detail | Primary because it is the current visible progress host and must not invent execution truth | Follows the existing start-surface workflow | Replaces ad hoc progress math with one trustworthy meaning |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant shell activity hint | operator-MSP | operation label, lifecycle state, either activity-only or counted-progress treatment, and canonical open link | one concise guidance line only when it changes the next decision | raw payloads, failure summaries, logs, and debug context | `View operation` | raw/support detail stays in Operations detail | progress mode is derived once from the shared contract instead of each surface inventing its own explanation |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant shell activity hint | Monitoring hint | Activity shell hint | Open the most relevant operation if follow-up is needed | explicit `View operation` link | forbidden | overflow navigation only | none | `/admin/operations?tenant_id={currentTenant}` | `/admin/operations/{run}` | current tenant context from the shell | Operation | lifecycle state plus one truthful progress mode | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant shell activity hint | Tenant operator | Decide whether active work is merely progressing, still waiting, or already terminal | Start-surface hint | What operation state do I need to react to right now? | operation label, lifecycle state, activity-only or counted-progress treatment, canonical open link | detailed run diagnostics and evidence on Operations pages | lifecycle, progress capability | none | `View operation`, `Show all operations` | none |
|
||||
|
||||
**UI Action Matrix**: `N/A - no Filament Resource, RelationManager, or Page action surface is being introduced or reclassified. The changed surface remains a shell/widget hint and shared Ops-UX semantics layer only.`
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no new persisted source of truth; one shared derived progress contract over existing `OperationRun` truth only
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes - one bounded shared progress contract/helper inside `App\Support\OpsUx`
|
||||
- **New enum/state/reason family?**: yes - one derived progress-capability vocabulary (`none`, `activity`, `counted`, `phased`, `composite`) used in code and docs only, not persisted
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: operators currently rely on progress semantics that are computed locally inside a shell view rather than from one central rule set
|
||||
- **Existing structure is insufficient because**: numeric-only `summary_counts` sanitization and lifecycle normalization do not answer whether a surface is allowed to show determinate progress or how terminal, queued, phased, or composite work must degrade safely
|
||||
- **Narrowest correct implementation**: one shared progress-semantics helper plus current shell adoption and one standards-doc update, with no count rollout and no new persistence
|
||||
- **Ownership cost**: one helper, focused unit plus feature tests, and one standards-doc update
|
||||
- **Alternative intentionally rejected**: leaving the progress math in `bulk-operation-progress.blade.php` or persisting an explicit progress mode on `operation_runs` were both rejected as either too local or too heavy
|
||||
- **Release truth**: current-release truth. The repo already has multiple real surfaces and writers using `summary_counts`; this slice keeps them from drifting into conflicting progress claims
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, and compatibility-specific tests are out of scope unless a later implementation slice proves they are required.
|
||||
|
||||
Canonical replacement of ad hoc progress inference is preferred over preserving duplicate logic.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: one unit suite can prove the progress-capability mapping and render-model rules cheaply, while focused feature coverage can prove the current shell host consumes the shared contract and does not regress into fake percentage output. Browser proof is not required for this slice because layout and clickability are already owned by `specs/268-operationrun-activity-feedback/`.
|
||||
- **New or expanded test families**: one focused `tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php` family plus focused extensions to the current `tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`, `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, and `tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
|
||||
- **Fixture / helper cost impact**: low to moderate. Reuse current `OperationRun` factories, tenant context helpers, and current Ops-UX feature fixtures; do not add provider-heavy setup or a new browser family.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: global-context-shell
|
||||
- **Standard-native relief or required special coverage**: standard unit plus feature coverage is sufficient; this spec deliberately does not create new browser obligations
|
||||
- **Reviewer handoff**: reviewers must confirm that the shell consumes a single shared progress contract, queued runs never show determinate progress, terminal runs never keep progress UI, outcome counters do not masquerade as progress, and the feature does not introduce new `summary_counts` keys or a second local helper
|
||||
- **Budget / baseline / trend impact**: small feature-local increase only
|
||||
- **Escalation needed**: `reject-or-split` if implementation widens into run-writer rollout, dashboard redesign, or a second persisted progress model
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Derive truthful progress modes from existing run truth (Priority: P1)
|
||||
|
||||
As an operator, I need one shared progress contract to decide whether a run shows no progress, activity-only feedback, or determinate counted progress, so the product does not invent conflicting execution claims across Ops-UX surfaces.
|
||||
|
||||
**Why this priority**: this is the core truth gap. Without a shared contract, later shell, dashboard, and rollout work will continue to duplicate or drift progress semantics.
|
||||
|
||||
**Independent Test**: create queued, running, completed-success, completed-failed, and follow-up runs with different `summary_counts`, then assert that the shared contract returns the correct capability and render model without depending on a specific UI surface.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a running run with numeric `summary_counts.total` and `summary_counts.processed`, **When** the shared contract evaluates that run, **Then** it returns counted progress and clamps the rendered values safely.
|
||||
2. **Given** a queued run or a running run without trustworthy numeric `processed` and `total` counts, **When** the shared contract evaluates it, **Then** it returns activity-only progress and does not fabricate a percentage.
|
||||
3. **Given** a terminal run, **When** the shared contract evaluates it, **Then** it returns no progress line or bar even if outcome counters are present.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Keep the current shell host on the shared contract (Priority: P1)
|
||||
|
||||
As a tenant operator, I need the current shell activity hint to consume the shared progress contract instead of local view math, so active work stays truthful and terminal work never keeps stale progress UI.
|
||||
|
||||
**Why this priority**: the current shell view is the real, repo-visible seam where ad hoc progress logic already exists.
|
||||
|
||||
**Independent Test**: render the current shell activity surface with queued, running, and terminal runs, then verify that it uses counted progress only when the shared contract allows it and otherwise falls back to activity-only or terminal semantics.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a running run with valid `processed` and `total` counts, **When** the shell hint renders it, **Then** the shell shows determinate counted progress derived through the shared contract rather than view-local math.
|
||||
2. **Given** a queued run with a planned total, **When** the shell hint renders it, **Then** the shell shows waiting or activity state only and does not promote it to determinate progress.
|
||||
3. **Given** a completed run with `succeeded`, `failed`, or `skipped` counters but no trustworthy active progress truth, **When** the shell hint renders it, **Then** the shell shows terminal outcome semantics and not a percentage.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Document future-safe progress boundaries (Priority: P2)
|
||||
|
||||
As a maintainer, I need the UI standards and developer guidance to define what counted, activity-only, phased, and composite progress mean, so later rollout specs extend one contract instead of inventing new progress languages.
|
||||
|
||||
**Why this priority**: the contract only stays useful if later surfaces and writer rollouts know exactly what they may and may not claim.
|
||||
|
||||
**Independent Test**: review the standards guidance and the focused validation suite together, then confirm the package explicitly defers counted rollout and future phase/composite implementation to named follow-up specs.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the UI standards are updated for this package, **When** a maintainer reads the progress rules, **Then** they can tell that `processed` and `total` are the only v1 determinate source and that outcome counters remain outcome-only.
|
||||
2. **Given** future phased or composite work is still unspecced, **When** a maintainer reads the package, **Then** the package clearly states that those categories must not masquerade as counted percentages until trustworthy persisted truth exists.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Queued runs may already know `total`, but they still render as activity-only rather than counted progress.
|
||||
- Running runs with `processed > total`, negative counters, or non-numeric counter values must clamp or degrade safely rather than showing impossible percentages.
|
||||
- Terminal runs that retain `processed` and `total` counts for summary truth must still drop progress UI and show terminal outcome only.
|
||||
- Runs that only have `succeeded`, `failed`, or `skipped` counts must not silently use those outcome counters as progress substitutes.
|
||||
- Phase/composite categories may be declared in the shared contract for future use, but until trustworthy phase or child-run truth exists they must degrade to non-counted display and not invent percentages.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment summary**: This feature adds no new Graph calls, no new write path, no new `OperationRun` lifecycle, no new `summary_counts` key, and no new persistence. It reuses the current Ops-UX 3-surface contract, the current `summary_counts` whitelist, and current shell activity host while centralizing progress semantics into one shared rule set.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The implementation MUST provide one shared Ops-UX progress contract or presenter that derives progress capability and render model from existing `OperationRun` truth instead of Blade- or widget-local progress math.
|
||||
- **FR-002**: The shared contract MUST define one derived vocabulary for `none`, `activity`, `counted`, `phased`, and `composite` progress capability, and that vocabulary MUST live in code and docs only rather than as persisted `OperationRun` state.
|
||||
- **FR-003**: Determinate counted progress MUST be allowed only when the run is actively running and `summary_counts.total` plus `summary_counts.processed` are trustworthy numeric values with `total > 0`.
|
||||
- **FR-004**: Queued runs and active runs without trustworthy `processed` and `total` counts MUST render as activity-only or indeterminate and MUST NOT fabricate a percentage from status, duration, stale-state heuristics, or outcome counters.
|
||||
- **FR-005**: Terminal runs MUST render no progress bar, no percentage, and no `processed / total` progress line, even when retained counters are present for audit or summary truth.
|
||||
- **FR-006**: `summary_counts.succeeded`, `summary_counts.failed`, and `summary_counts.skipped` remain outcome counters. They MUST NOT silently replace `summary_counts.processed` as the determinate progress source.
|
||||
- **FR-007**: When counted progress is allowed, the shared contract MUST sanitize or clamp `processed`, `total`, and percentage output so rendered values stay within a truthful `0-100` percent range and `processed` never exceeds `total` visibly.
|
||||
- **FR-008**: The shared contract MUST declare safe future `phased` and `composite` capabilities, and those categories MUST NOT masquerade as counted percentages until trustworthy persisted phase or child-progress truth exists.
|
||||
- **FR-009**: The current shell activity feedback surface MUST consume the shared progress contract and MUST NOT keep inline percentage calculation or progress-mode inference in `bulk-operation-progress.blade.php`.
|
||||
- **FR-010**: The feature MUST update `docs/ui/tenantpilot-enterprise-ui-standards.md` and the relevant Spec Kit artifacts so the progress contract, anti-patterns, and follow-up ownership are documented once.
|
||||
- **FR-011**: This slice MUST NOT add new `OperationRun` status values, outcome values, notification surfaces, `summary_counts` keys, or persisted progress-mode flags.
|
||||
|
||||
### Authorization and Safety Requirements
|
||||
|
||||
- **AR-001**: Tenant/admin-plane authorization semantics remain unchanged: out-of-scope access remains deny-as-not-found (`404` semantics through current tenant/admin boundaries), while in-scope visibility continues to reuse server-side `OperationRun` policies.
|
||||
- **AR-002**: No covered surface may reveal progress state or progress-derived copy for a run the current actor cannot already view.
|
||||
- **AR-003**: No destructive or mutating action is introduced. Existing run start, retry, and detail actions remain responsible for their current confirmation and authorization rules.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: The slice MUST stay Filament-native and Livewire v4-compatible. No panel-provider registration change is allowed; `apps/platform/bootstrap/providers.php` remains authoritative.
|
||||
- **NFR-002**: No new panel, no new globally searchable resource, and no new asset registration strategy are allowed.
|
||||
- **NFR-003**: Polling remains intentional and bounded. The feature may reuse existing poller families, but it MUST NOT introduce new parallel polling loops.
|
||||
- **NFR-004**: `OperationSummaryKeys` and `SummaryCountsNormalizer` remain the only allowed summary-count whitelist and sanitization owners. The progress contract must build on them rather than bypass them.
|
||||
|
||||
## Deferred Follow-Ups / Explicit Non-Goals
|
||||
|
||||
- `269 — OperationRun Terminal Outcome Feedback`, because the repo-based duplicate check shows that `specs/268-operationrun-activity-feedback/` already owns that shell slice
|
||||
- `271 — Counted Progress Rollout v1`
|
||||
- `272 — OperationRun Phase & Composite Progress v1`
|
||||
- `273 — Tenant Dashboard Active Operations Summary Card`
|
||||
- any run-writer rollout that adds or changes `summary_counts.total` / `summary_counts.processed`
|
||||
- any dashboard, tray, or customer-facing redesign beyond consuming the shared contract later
|
||||
- any new persisted progress model or telemetry system
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **OperationRun Progress Capability**: the derived classification that answers whether a run currently supports no progress, activity-only feedback, determinate counted progress, or a future phased/composite mode.
|
||||
- **OperationRun Progress Render Model**: the derived payload that a host surface consumes to show activity-only copy, counted progress values, or terminal no-progress behavior without doing its own math.
|
||||
- **Counted Progress Truth**: the specific case where active running work has trustworthy numeric `processed` and `total` counters suitable for determinate rendering.
|
||||
- **Outcome Counters**: retained numeric counts such as `succeeded`, `failed`, and `skipped` that remain outcome summary truth and do not become determinate progress implicitly.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In focused unit coverage, the shared progress contract classifies the covered fixtures into `none`, `activity`, `counted`, and safe future `phased/composite` fallbacks without relying on a specific view.
|
||||
- **SC-002**: In focused shell feature coverage, queued and terminal runs show no determinate percentage in 100% of covered scenarios.
|
||||
- **SC-003**: In focused shell feature coverage, determinate progress appears only for active running runs with trustworthy numeric `processed` and `total` values.
|
||||
- **SC-004**: In regression coverage, outcome counters such as `succeeded`, `failed`, and `skipped` never produce counted progress unless `processed` and `total` also satisfy the shared contract.
|
||||
- **SC-005**: The UI standards and Spec Kit artifacts document one canonical progress contract and explicitly assign counted rollout and phase/composite rollout to later follow-up specs.
|
||||
|
||||
## Candidate Selection Rationale
|
||||
|
||||
- **Selected candidate**: OperationRun Progress Contract v1
|
||||
- **Source locations**:
|
||||
- `docs/product/spec-candidates.md`
|
||||
- `docs/product/roadmap.md`
|
||||
- `specs/268-operationrun-activity-feedback/spec.md`
|
||||
- `apps/platform/app/Support/OpsUx/ActiveRuns.php`
|
||||
- `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`
|
||||
- `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`
|
||||
- **Why selected**: the active auto-prep queue is intentionally empty, candidate `269` fails the duplicate check because `specs/268-operationrun-activity-feedback/` already covers shell terminal-success and terminal-follow-up semantics, and the current repo still shows inline progress logic inside the shell view with no shared contract. Candidate `270` is the next bounded backlog item that closes a real truth gap without widening into dashboard work or run-writer rollout.
|
||||
- **Why this is the smallest viable implementation slice**: v1 stays on one shared progress contract plus current shell adoption and one standards update. It explicitly excludes counted writer rollout, dashboard productization, and future phase/composite execution truth.
|
||||
- **Why close alternatives were deferred**:
|
||||
- `269 — OperationRun Terminal Outcome Feedback` is already covered by `specs/268-operationrun-activity-feedback/` and is therefore not a safe prep target.
|
||||
- `273 — Tenant Dashboard Active Operations Summary Card` remains conditional on post-`268` dashboard drift and should not be promoted before that drift is visible.
|
||||
- `271 — Counted Progress Rollout v1` depends on the shared contract from this package.
|
||||
- `272 — OperationRun Phase & Composite Progress v1` depends on the shared contract from this package and likely on later trustworthy phase/composite truth.
|
||||
|
||||
## Related-Spec Guardrail Check
|
||||
|
||||
- `specs/268-operationrun-activity-feedback/`: prep package, not completed, and not safe to refresh through this skill because it already owns the shell terminal outcome slice.
|
||||
- `specs/266-tenant-dashboard-productization-v1/`: different dashboard surface with its own implementation history; used only as context for why `273` remains conditional.
|
||||
- `specs/055-ops-ux-rollout/`, `specs/160-operation-lifecycle-guarantees/`, and `specs/134-audit-log-foundation/`: implemented foundation specs that already define the numeric-only `summary_counts` and three-surface lifecycle rules; used as inherited truth only.
|
||||
177
specs/270-operationrun-progress-contract/tasks.md
Normal file
177
specs/270-operationrun-progress-contract/tasks.md
Normal file
@ -0,0 +1,177 @@
|
||||
---
|
||||
description: "Task list for OperationRun Progress Contract v1"
|
||||
---
|
||||
|
||||
# Tasks: OperationRun Progress Contract v1
|
||||
|
||||
**Input**: Design documents from `specs/270-operationrun-progress-contract/`
|
||||
**Prerequisites**: `specs/270-operationrun-progress-contract/spec.md`, `specs/270-operationrun-progress-contract/plan.md`, `specs/270-operationrun-progress-contract/checklists/requirements.md`
|
||||
|
||||
**Review Artifact**: `specs/270-operationrun-progress-contract/checklists/requirements.md` is the outcome-of-record for the review outcome class, workflow outcome, and test-governance outcome. If implementation widens into counted-writer rollout, dashboard drift work, or a new persisted progress model, update that artifact before continuing.
|
||||
|
||||
**Tests**: REQUIRED (Pest). Keep proof bounded to one new unit suite plus focused Ops-UX feature coverage. Browser coverage remains owned by `specs/268-operationrun-activity-feedback/` and must not be pulled into this slice implicitly.
|
||||
**Operations**: No new `OperationRun` type, no queue-family changes, no notification-policy changes, no new `summary_counts` keys, and no new lifecycle ownership. Existing queued toasts, terminal notifications, `run-enqueued` browser events, and canonical `OperationRun` links remain authoritative.
|
||||
**RBAC**: Reuse current `OperationRun` policies and tenant context guards. No tenantless leakage from tenant surfaces; covered surfaces stay inert when no selected tenant or `viewAny` capability exists.
|
||||
**Shared Pattern Reuse**: Reuse `SummaryCountsNormalizer`, `OperationSummaryKeys`, `ActiveRuns`, `OperationStatusNormalizer`, `OperationUxPresenter`, `OperationRunService`, `BulkOperationProgress`, and `docs/ui/tenantpilot-enterprise-ui-standards.md`. Do not create a second local progress helper in Blade or Livewire.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, resource, or asset strategy is allowed. This slice changes shared progress semantics only.
|
||||
**Organization**: Tasks are grouped by user story so the shared contract, the shell adoption, and the future-boundary documentation remain independently reviewable.
|
||||
|
||||
## Test Governance Notes
|
||||
|
||||
- Lane mix stays Unit plus Feature.
|
||||
- Prefer extending `ActivityFeedbackSurfaceTest`, `BulkOperationProgressDbOnlyTest`, and `SummaryCountsWhitelistTest` before creating broader families.
|
||||
- Browser proof stays with Spec 268 and must not become a hidden requirement here.
|
||||
- Validation commands must stay file-scoped and run through Sail.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: confirm the bounded slice, the inherited Ops-UX rules, and the specific ad hoc progress seam before runtime edits begin.
|
||||
|
||||
- [x] T001 Review `specs/270-operationrun-progress-contract/spec.md`, `specs/270-operationrun-progress-contract/plan.md`, `specs/270-operationrun-progress-contract/checklists/requirements.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/268-operationrun-activity-feedback/spec.md`, `specs/055-ops-ux-rollout/spec.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, and `.specify/memory/constitution.md` together so the slice stays on repo-real progress truth and keeps `269`, `271`, `272`, and `273` explicitly out of scope.
|
||||
- [x] T002 [P] Confirm the current writer and sanitizer seams in `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`, and `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`.
|
||||
- [x] T003 [P] Confirm the current visible progress host and reader seams in `apps/platform/app/Support/OpsUx/ActiveRuns.php`, `apps/platform/app/Livewire/BulkOperationProgress.php`, and `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`.
|
||||
- [x] T004 [P] Confirm current proof owners in `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`, `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, and `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, then record the exact shell-progress drift seam that the new unit suite must own.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: settle the progress contract and proof owners before the visible shell host is refactored.
|
||||
|
||||
**Critical**: no user-story runtime work should begin until this phase is complete.
|
||||
|
||||
- [x] T005 [P] Create failing unit coverage in `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php` for `none`, `activity`, `counted`, safe `phased`, and safe `composite` capability mapping, queued indeterminate handling, terminal no-progress handling, and outcome-counter rejection.
|
||||
- [x] T006 [P] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` for shell adoption of the shared contract: determinate progress only from running plus trustworthy `processed`/`total`, no counted progress for queued runs, and no progress line or percentage for terminal runs.
|
||||
- [x] T007 [P] Extend `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php` only as needed so the new contract still reuses numeric-only whitelist semantics and does not create hidden progress inputs from non-whitelisted keys.
|
||||
- Note: no file edit was required because the existing whitelist suite already proved the contract continued to consume the canonical numeric-only keys without introducing new progress inputs.
|
||||
|
||||
**Checkpoint**: the shared contract and focused proof owners are settled before implementation begins.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Derive truthful progress modes from existing run truth (Priority: P1)
|
||||
|
||||
**Goal**: one shared Ops-UX helper classifies progress capability and render model from current `OperationRun` truth.
|
||||
|
||||
**Independent Test**: create queued, running, and terminal runs with varying `summary_counts`, then verify the new unit suite classifies them correctly without involving a UI host.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T008 [P] [US1] Add any additional dataset or fixture coverage needed in `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php` for current real run shapes such as inventory sync, review-pack generation, and bulk jobs that already emit `processed` and `total`.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T009 [US1] Introduce one shared progress contract/helper under `apps/platform/app/Support/OpsUx/` that derives progress capability and render-safe output from `OperationRun` status, outcome, sanitized `summary_counts`, and current trusted context only.
|
||||
- [x] T010 [US1] Reuse or extend `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`, `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`, and `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php` only as needed so lifecycle semantics and progress semantics stay separate but consistent.
|
||||
- Note: the implementation reused the existing normalizer and status helper without modifying their source, which kept progress semantics bounded to the new contract helper.
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when one shared helper owns the progress semantics.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Keep the current shell host on the shared contract (Priority: P1)
|
||||
|
||||
**Goal**: the current shell activity hint consumes the shared contract instead of local progress math.
|
||||
|
||||
**Independent Test**: render the shell surface with queued, running, and terminal runs, then verify the shell shows counted progress only when the shared contract allows it and otherwise falls back to activity-only or terminal semantics.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T010A [P] [US2] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` so the shell stays inert for missing-tenant or unauthorized actors and does not expose progress-derived copy for runs the actor cannot view.
|
||||
- Note: repo truth kept the missing-tenant inert-state proof in `BulkOperationProgressDbOnlyTest.php`, while tenant-scoped visibility remained covered by the existing DB-only tenant-scoping assertion.
|
||||
- [x] T011 [P] [US2] Extend `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` for queued indeterminate state, clamped counted progress, and terminal no-progress behavior at the shell host hydration boundary.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T012 [US2] Refactor `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php` and `apps/platform/app/Livewire/BulkOperationProgress.php` so the shell host consumes the shared progress contract and no longer computes percentages or progress eligibility inline.
|
||||
- Note: repository truth only required the Blade host seam to change; `BulkOperationProgress.php` remained unchanged because it already handed the correct run collection to the view.
|
||||
- [x] T013 [US2] Update `apps/platform/app/Support/OpsUx/ActiveRuns.php` only if needed so shell-visible progress availability stays aligned with the shared contract; if implementation reveals another consumer with local progress math, stop and update the spec, plan, and checklist before touching it.
|
||||
- Note: no `ActiveRuns.php` change was required; no second shell-visible local progress calculator was found.
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when the shell surface no longer owns progress semantics ad hoc.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Document future-safe progress boundaries (Priority: P2)
|
||||
|
||||
**Goal**: the UI standards and feature package define how future counted, phased, and composite work must extend the contract.
|
||||
|
||||
**Independent Test**: review the standards update and the focused proving suite together, then confirm that counted rollout, phase/composite rollout, and dashboard follow-up remain named follow-up specs rather than hidden scope here.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T014 [US3] Update `docs/ui/tenantpilot-enterprise-ui-standards.md` with canonical progress modes, the rule that `processed` and `total` are the only v1 determinate source, the rule that outcome counters remain outcome-only, the queued indeterminate rule, the terminal no-progress rule, and the safe future boundaries for `phased` and `composite` categories.
|
||||
- [x] T015 [US3] Review the resulting implementation to confirm it introduces no new `summary_counts` keys, no writer rollout, no dashboard-specific progress surface, and no persisted progress mode.
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when the standards doc and feature package leave clear extension rules for later specs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: validate the bounded slice, stop drift, and hand off a clean implementation path.
|
||||
|
||||
- [x] T016 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`.
|
||||
- [x] T017 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`.
|
||||
- [x] T018 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
|
||||
- [x] T019 [P] Review touched code against `docs/ui/tenantpilot-enterprise-ui-standards.md` and confirm the shell remains decision-first, diagnostics-light, Filament-native, and backed by one shared progress contract.
|
||||
- [x] T020 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no new assets were registered, and no new `OperationRun` lifecycle or notification path was introduced.
|
||||
- [x] T021 [P] Review touched code to confirm the implementation reuses existing poller families and introduces no new parallel polling loops while adopting the shared progress contract.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks user-story work.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the shared progress contract.
|
||||
- **Phase 4 (US2)**: depends on Phase 3 because the shell must consume the shared contract rather than invent its own logic.
|
||||
- **Phase 5 (US3)**: depends on Phase 3 and should land with US2 so the contract and extension boundaries stay aligned.
|
||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and delivers the core enterprise-truth contract.
|
||||
- **US2 (P1)**: independently testable after Phase 3 and delivers the real visible shell adoption.
|
||||
- **US3 (P2)**: independently testable after Phase 3 and is still required for package completion because future rollout ownership is part of the approved scope.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Land the shared progress helper before removing local shell math.
|
||||
- Re-run the narrowest affected validation command after each story checkpoint before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2**, because the package only delivers value once the shared contract exists and the current visible shell host consumes it.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1.
|
||||
3. Deliver US2 on top of the shared helper.
|
||||
4. Add US3 documentation and boundary hardening.
|
||||
5. Finish with focused validation and formatting.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the shared contract and unit proof owner first.
|
||||
2. Keep shell-adoption edits serialized around `BulkOperationProgress` and its Blade view.
|
||||
3. Do not widen into writer rollout or dashboard follow-up while implementing this package.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- `271 — Counted Progress Rollout v1`
|
||||
- `272 — OperationRun Phase & Composite Progress v1`
|
||||
- `273 — Tenant Dashboard Active Operations Summary Card`
|
||||
- any browser-smoke expansion beyond the currently-owned Spec 268 overlap proof
|
||||
- any new writer-side rollout that adds or changes `summary_counts.total` or `summary_counts.processed`
|
||||
- any persisted progress mode, registry, or dashboard redesign
|
||||
@ -0,0 +1,59 @@
|
||||
# Specification Quality Checklist: Counted Progress Rollout v1
|
||||
|
||||
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||
**Created**: 2026-05-05
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The package stays on one bounded counted-writer rollout over existing `OperationRun` truth instead of widening into phase/composite contract rewrite, dashboard redesign, or a second progress framework.
|
||||
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
|
||||
- [x] The package explicitly names the repo-real anchors it builds on: `OperationRunProgressContract`, `SummaryCountsNormalizer`, `OperationSummaryKeys`, `OperationRunService`, and the current writer seams in inventory sync, review-pack generation, evidence-snapshot generation, and backup/restore fan-out.
|
||||
- [x] Mandatory repo sections for scope, shared-pattern reuse, Ops-UX, testing, proportionality, and candidate rationale are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No unresolved clarification markers remain.
|
||||
- [x] Requirements are testable and bounded to selected stable-unit writer families plus one standards update.
|
||||
- [x] The package explicitly keeps `summary_counts.processed` and `summary_counts.total` as the only determinate v1 progress source.
|
||||
- [x] The package explicitly forbids new `summary_counts` keys, fake totals, outcome-counter substitution, and a second local progress helper.
|
||||
- [x] The package explicitly records the baseline capture/compare deviation from the original candidate wording and defers that work to `272`.
|
||||
- [x] The package keeps provider, panel, global-search, asset, queue-family, notification-policy, and persistence changes out of scope.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and is consistent with the broader roadmap direction.
|
||||
- [x] The active queue is explicitly empty, so this package records itself as a deliberate manual promotion rather than an automatic next-best-prep target.
|
||||
- [x] Repo verification confirmed `specs/270-operationrun-progress-contract/` is the immediate prerequisite context for this package.
|
||||
- [x] Repo verification confirmed `269 - OperationRun Terminal Outcome Feedback` is not the safer manual-promotion target because `specs/268-operationrun-activity-feedback/` already owns that shell terminal slice.
|
||||
- [x] Repo verification confirmed the original `271` candidate wording would widen into `272` if baseline capture/compare were kept in scope under the current contract, so the narrowed scope is explicit and intentional.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The package reuses current `OperationRun` truth and current summary-count helpers instead of introducing a second lifecycle or persisted projection.
|
||||
- [x] The package names both the selected in-scope stable-unit families and the excluded phased/composite families.
|
||||
- [x] The package forbids new panel, provider, global-search, asset-registration, queue-family, notification-policy, and persistence changes.
|
||||
- [x] The package preserves the current polling posture and shell surface contract.
|
||||
- [x] The planned validation commands stay consistent across `spec.md`, `plan.md`, and `tasks.md`.
|
||||
- [x] No application implementation was performed while preparing this package.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned proof stays bounded to existing Unit plus Feature families for Ops-UX, inventory, review packs, evidence snapshots, and backup sets.
|
||||
- [x] No new heavy-governance or browser family is introduced by default.
|
||||
- [x] Fixture growth remains bounded to current tenant context helpers, current `OperationRun` factories, and existing domain job tests.
|
||||
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into the active prep package.
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `.specify/memory/constitution.md`, `.specify/templates/checklist-template.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/268-operationrun-activity-feedback/spec.md`, `specs/270-operationrun-progress-contract/spec.md`, `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`, `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`, `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`, `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Jobs/RunInventorySyncJob.php`, `apps/platform/app/Jobs/GenerateReviewPackJob.php`, `apps/platform/app/Jobs/GenerateEvidenceSnapshotJob.php`, `apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php`, `apps/platform/app/Jobs/BulkBackupSetRestoreJob.php`, `apps/platform/app/Jobs/Operations/BackupSetRestoreWorkerJob.php`, `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`, `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, and `docs/ui/tenantpilot-enterprise-ui-standards.md` on 2026-05-05.
|
||||
- This checklist is the prep-time outcome record. If implementation widens into baseline phase/composite rollout, dashboard-specific progress work, or a persisted progress model, the workflow outcome must change before merge.
|
||||
- No application implementation was performed while preparing this package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Reason**: the package is a bounded manual-promotion follow-up to Spec 270, reuses current shared truth instead of adding a new framework, and explicitly documents the candidate-scope narrowing required by current repo reality.
|
||||
- **Final note location**: This checklist during prep, and the active feature PR close-out entry only if implementation later forces `split` or `document-in-feature`.
|
||||
261
specs/271-counted-progress-rollout/plan.md
Normal file
261
specs/271-counted-progress-rollout/plan.md
Normal file
@ -0,0 +1,261 @@
|
||||
# Implementation Plan: Counted Progress Rollout v1
|
||||
|
||||
**Branch**: `271-counted-progress-rollout` | **Date**: 2026-05-05 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/271-counted-progress-rollout/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
This plan prepares one bounded writer-rollout slice on top of the existing shared progress contract from Spec 270. The implementation path is to reuse `OperationRunProgressContract` and `OperationRunService`, add truthful `total` plus `processed` writes only where the repo already exposes deterministic work units, and leave baseline capture/compare on their current phased path. The slice must not invent totals, widen into phase/composite work, add new `summary_counts` keys, or redesign current Ops-UX surfaces.
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `App\Support\OpsUx\OperationRunProgressContract` already centralizes `none`, `activity`, `counted`, `phased`, and `composite` progress modes.
|
||||
- `App\Services\OperationRunService` already owns `updateRun()`, `incrementSummaryCounts()`, and `maybeCompleteBulkRun()` as the authoritative summary-count mutation path.
|
||||
- The current tenant shell adopter already consumes the shared progress contract and can render determinate counted progress when trustworthy counts exist.
|
||||
- Inventory sync, review-pack generation, evidence-snapshot generation, `AddPoliciesToBackupSetJob`, `BulkBackupSetRestoreJob`, and `BackupSetRestoreWorkerJob` are already repo-real workflows with current tests.
|
||||
- Baseline capture and baseline compare already expose evidence-capture phase hints, which the current progress contract classifies as `phased` before `counted`.
|
||||
|
||||
### Explicit delta in this plan
|
||||
|
||||
- roll out truthful counted inputs for selected stable-unit run families only
|
||||
- standardize parent and child count discipline across `AddPoliciesToBackupSetJob`, `BulkBackupSetRestoreJob`, and `BackupSetRestoreWorkerJob`
|
||||
- keep current shell and Operations detail surfaces unchanged except for consuming newly truthful count data
|
||||
- document the narrowed scope deviation from the original candidate wording: baseline capture/compare remain deferred because repo truth now places them on the phased/composite path
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: current Ops-UX support classes, native Filament/Livewire shell feedback, Pest v4
|
||||
**Storage**: PostgreSQL via existing `operation_runs`, review-pack, evidence-snapshot, backup-set, and restore tables
|
||||
**Testing**: Pest Unit + Feature
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: existing Laravel monolith in `apps/platform`
|
||||
**Project Type**: web application (Laravel monolith with Filament)
|
||||
**Performance Goals**: no new query families, no new polling loops, and no slower-than-current active-operation feedback
|
||||
**Constraints**: no new `summary_counts` keys, no new persistence, no contract-precedence change, and no broad writer sweep
|
||||
**Scale/Scope**: one shared contract reused across 4 selected run families plus one standards update and focused regression coverage
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`
|
||||
- `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`
|
||||
- `apps/platform/app/Services/OperationRunService.php`
|
||||
- `apps/platform/app/Jobs/RunInventorySyncJob.php`
|
||||
- `apps/platform/app/Services/Inventory/InventorySyncService.php`
|
||||
- `apps/platform/app/Jobs/GenerateReviewPackJob.php`
|
||||
- `apps/platform/app/Services/ReviewPackService.php`
|
||||
- `apps/platform/app/Jobs/GenerateEvidenceSnapshotJob.php`
|
||||
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
|
||||
- `apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php`
|
||||
- `apps/platform/app/Jobs/BulkBackupSetRestoreJob.php`
|
||||
- `apps/platform/app/Jobs/Operations/BackupSetRestoreWorkerJob.php`
|
||||
- `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `apps/platform/tests/Feature/Inventory/RunInventorySyncJobTest.php`
|
||||
- `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
- `apps/platform/tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php`
|
||||
- `apps/platform/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php`
|
||||
- `apps/platform/tests/Unit/BulkBackupSetRestoreJobTest.php`
|
||||
- `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`
|
||||
- `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`
|
||||
- `docs/ui/tenantpilot-enterprise-ui-standards.md`
|
||||
|
||||
## UI / Filament & Livewire Fit
|
||||
|
||||
- The visible adopter remains the existing tenant-shell activity feedback surface. No new page, widget family, or dashboard card is introduced.
|
||||
- The shell stays decision-first. This slice changes only whether selected runs can legitimately enter the already-existing counted mode.
|
||||
- Operations collection/detail pages remain diagnostics-first drill-through targets. They inherit more truthful count data but no new layout or action model.
|
||||
- No panel registration, asset registration, or global-search changes are planned.
|
||||
|
||||
## RBAC / Policy Fit
|
||||
|
||||
- Existing capability gates for the initiating surfaces remain unchanged.
|
||||
- Existing `OperationRun` policies remain the only progress-visibility gate.
|
||||
- No new mutation surface is introduced, so current server-side authorization and confirmation behavior remains on the existing launch and detail surfaces.
|
||||
|
||||
## Audit / Logging Fit
|
||||
|
||||
- Existing queued toasts and terminal notifications remain authoritative and unchanged.
|
||||
- Existing run audit remains the only audit trail for the counted rollout. No new run-local audit channel is introduced.
|
||||
- Parent/child bulk completion still flows through existing `OperationRunService` helpers instead of feature-local completion code.
|
||||
|
||||
## Data & Query Fit
|
||||
|
||||
- Progress truth remains fully derived from `operation_runs.summary_counts` plus existing contract logic.
|
||||
- Determinate progress stays limited to work with deterministic units known before or during processing.
|
||||
- Outcome counters remain summary truth and do not replace `processed`.
|
||||
- No migration, no new JSON schema, no cache layer, and no new persisted preference or progress mode are planned.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament + existing Livewire/Blade shell surface
|
||||
- **Shared-family relevance**: Ops-UX activity feedback and run summaries
|
||||
- **State layers in scope**: shell
|
||||
- **Audience modes in scope**: operator-MSP
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first on shell, diagnostics-second on Operations detail
|
||||
- **Raw/support gating plan**: unchanged; raw/support detail remains on diagnostics surfaces only
|
||||
- **One-primary-action / duplicate-truth control**: keep one dominant `View operation` action and one progress mode derived from the shared contract
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: global-context-shell
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none planned
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: Ops-UX progress contract, summary-count service helpers, inventory/review/evidence/backup writers, shell adopter
|
||||
- **Shared abstractions reused**: `OperationRunProgressContract`, `OperationRunService`, `SummaryCountsNormalizer`, `OperationSummaryKeys`
|
||||
- **New abstraction introduced? why?**: none planned
|
||||
- **Why the existing abstraction was sufficient or insufficient**: rendering semantics are already centralized; the missing piece is writer-side counted input for specific repo-real workflows
|
||||
- **Bounded deviation / spread control**: keep all count mutation on `OperationRunService`; any run family without deterministic units stays out of scope rather than adding a local exception
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for progress truth only
|
||||
- **Central contract reused**: existing OperationRun Start UX Contract plus `OperationRunProgressContract`
|
||||
- **Delegated UX behaviors**: queued toast, canonical run links, `run-enqueued` event, and terminal notification lifecycle remain delegated and unchanged
|
||||
- **Surface-owned behavior kept local**: launch inputs and current domain-specific validation only
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: `N/A`
|
||||
- **Platform-core seams**: `OperationRun` truth, progress contract, shell feedback
|
||||
- **Neutral platform terms / contracts preserved**: `Operation`, `progress`, `counted progress`, `activity`, `terminal outcome`
|
||||
- **Retained provider-specific semantics and why**: none
|
||||
- **Bounded extraction or follow-up path**: none
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins and again before merge.*
|
||||
|
||||
- Inventory-first: PASS. The slice only enriches execution feedback over current `OperationRun` truth.
|
||||
- Read/write separation: PASS. No new external write path is introduced; current domain jobs keep their existing behavior and only improve run-count truth.
|
||||
- Graph contract path: PASS. No new Graph/provider seam is introduced.
|
||||
- Deterministic capabilities: PASS. Authorization and progress eligibility stay deterministic and testable.
|
||||
- RBAC-UX: PASS. Visibility remains on existing tenant/admin boundaries and `OperationRun` policies.
|
||||
- Run observability: PASS. Long-running work still flows through current `OperationRun` ownership and current Ops-UX surfaces.
|
||||
- Ops-UX lifecycle: PASS. `status` and `outcome` ownership remains on `OperationRunService`; only count truth is enriched.
|
||||
- Ops-UX summary counts: PASS. The rollout stays on current whitelist semantics and numeric-only values.
|
||||
- Test governance: PASS. Proof remains bounded to Unit plus Feature.
|
||||
- Proportionality / no premature abstraction: PASS. No new abstraction or persistence is introduced.
|
||||
- Persisted truth / behavioral state: PASS. No new table, cache, or status family is added.
|
||||
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing Ops-UX path remains central and the visible adopter is unchanged.
|
||||
- Provider boundary: PASS. No provider/platform seam change.
|
||||
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no assets change.
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for contract safeguards; Feature for selected writer rollouts and current shell adoption
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: domain feature suites already exist for the in-scope writer families, and the shared contract already has a focused unit suite. Browser proof remains owned by Spec 268 because this slice does not alter layout or clickability.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php tests/Unit/BulkBackupSetRestoreJobTest.php tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse current tenant context helpers and current job-specific fixtures instead of introducing new provider-heavy harnesses
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `global-context-shell`
|
||||
- **Closing validation and reviewer handoff**: rerun the two proving commands above and verify that selected families now emit truthful counts, excluded phased families remain excluded, and no writer bypasses `OperationRunService`
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
|
||||
- **Review-stop questions**: did any excluded run family sneak in, did any writer invent totals, did `processed` stay bounded by `total`, and did any new summary key or local progress helper appear?
|
||||
- **Escalation path**: `reject-or-split` for any baseline phased/composite widening, dashboard redesign, or persisted progress model
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Why no dedicated follow-up spec is needed**: this package is itself the bounded writer-rollout follow-through on Spec 270; any remaining excluded families are already named as future follow-ups.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/271-counted-progress-rollout/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (expected implementation surfaces)
|
||||
|
||||
```text
|
||||
apps/platform/app/Support/OpsUx/
|
||||
apps/platform/app/Services/OperationRunService.php
|
||||
apps/platform/app/Jobs/RunInventorySyncJob.php
|
||||
apps/platform/app/Services/Inventory/InventorySyncService.php
|
||||
apps/platform/app/Jobs/GenerateReviewPackJob.php
|
||||
apps/platform/app/Services/ReviewPackService.php
|
||||
apps/platform/app/Jobs/GenerateEvidenceSnapshotJob.php
|
||||
apps/platform/app/Services/Evidence/EvidenceSnapshotService.php
|
||||
apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php
|
||||
apps/platform/app/Jobs/BulkBackupSetRestoreJob.php
|
||||
apps/platform/app/Jobs/Operations/BackupSetRestoreWorkerJob.php
|
||||
apps/platform/tests/Unit/Support/OpsUx/
|
||||
apps/platform/tests/Feature/Inventory/
|
||||
apps/platform/tests/Feature/ReviewPack/
|
||||
apps/platform/tests/Feature/Evidence/
|
||||
apps/platform/tests/Feature/BackupSets/
|
||||
apps/platform/tests/Feature/OpsUx/
|
||||
docs/ui/tenantpilot-enterprise-ui-standards.md
|
||||
```
|
||||
|
||||
**Structure Decision**: keep the rollout local to current jobs/services plus the existing Ops-UX support family. Do not introduce a new progress rollout framework or a second writer abstraction.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
- No migration or schema change is planned.
|
||||
- No new persisted progress mode or preference is allowed.
|
||||
- No backfill is planned. Historical runs remain historical truth; the rollout affects future execution only.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- No global search or asset change is required because the slice changes only run-count truth.
|
||||
- No destructive action or confirmation model changes are planned.
|
||||
- No deployment step beyond ordinary code deploy and current test validation is expected.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Reject any implementation that changes progress-contract precedence to force phased runs into counted mode.
|
||||
- Reject any implementation that adds new `summary_counts` keys or uses outcome counters as hidden progress substitutes.
|
||||
- Reject any implementation that sets totals from speculative estimates instead of deterministic current work sets.
|
||||
- Reject any implementation that initializes parent totals multiple times or allows child retries to double-increment `processed`.
|
||||
- Reject any implementation that broadens the rollout to unrelated run families not named in the spec and tasks.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Confirm Selected Stable-Unit Writer Seams
|
||||
|
||||
- Verify current counted and terminal-only seams in inventory sync, review-pack generation, evidence-snapshot generation, `AddPoliciesToBackupSetJob`, `BulkBackupSetRestoreJob`, and `BackupSetRestoreWorkerJob`.
|
||||
- Reconfirm that baseline capture/compare remain phased under the current contract and stay out of scope.
|
||||
|
||||
### Phase 1 - Roll Out Inventory And Artifact Writer Counts
|
||||
|
||||
- Add or standardize `total` plus `processed` writes for inventory sync, review-pack generation, and evidence-snapshot generation.
|
||||
|
||||
### Phase 2 - Standardize Enumerated Backup/Restore Fan-Out Counts
|
||||
|
||||
- Align `AddPoliciesToBackupSetJob`, `BulkBackupSetRestoreJob`, and `BackupSetRestoreWorkerJob` on one total/processed discipline and current `maybeCompleteBulkRun()` semantics.
|
||||
|
||||
### Phase 3 - Lock The Guardrail And Proof
|
||||
|
||||
- Update the UI standards and focused tests so later run families do not re-open fake or partial counted rollout.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: determinable long-running work still looks indeterminate because selected writers do not persist counts early enough or consistently enough.
|
||||
- **Existing structure is insufficient because**: the shared contract already exists and cannot invent counted progress from terminal summaries alone.
|
||||
- **Narrowest correct implementation**: update only repo-verified stable-unit writers and leave all other families on their current truthful modes.
|
||||
- **Ownership cost created**: targeted job/service tests and one standards update.
|
||||
- **Alternative intentionally rejected**: broad all-writers rollout or phased-precedence changes were rejected because they would widen into a second spec.
|
||||
- **Release truth**: current-release truth. The repo already contains the contract, the visible adopter, and the selected writers needed for this rollout.
|
||||
267
specs/271-counted-progress-rollout/spec.md
Normal file
267
specs/271-counted-progress-rollout/spec.md
Normal file
@ -0,0 +1,267 @@
|
||||
# Feature Specification: Counted Progress Rollout v1
|
||||
|
||||
**Feature Branch**: `271-counted-progress-rollout`
|
||||
**Created**: 2026-05-05
|
||||
**Status**: Ready for implementation
|
||||
**Input**: Manual promotion from `docs/product/spec-candidates.md` after the 2026-05-05 repo-based next-best-prep review and explicit user preference to continue with candidate `271`.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: `specs/270-operationrun-progress-contract/` already made progress disclosure truthful, but many high-value `OperationRun` writers still emit terminal-only summaries or inconsistent partial counts. The shared contract cannot show determinate progress unless those writers persist trustworthy `total` and `processed` values while work is running.
|
||||
- **Today's failure**: operators can start inventory sync, review-pack generation, evidence snapshot generation, and several bulk backup/restore operations, yet the shell often stays indeterminate until completion even when the code already knows stable work units. Some backup/restore fan-out jobs already increment counts, while adjacent launchers only seed totals or finish with terminal summaries, so progress trust varies by run family.
|
||||
- **User-visible improvement**: selected long-running operations expose determinate progress only when stable work units exist, while unknown-total or phase-driven runs remain activity-only or phased under the existing contract.
|
||||
- **Smallest enterprise-capable version**: reuse `App\Support\OpsUx\OperationRunProgressContract` and roll out trustworthy `summary_counts.total` plus `summary_counts.processed` writes only for current run families with repo-verified stable units: inventory sync, review-pack generation, evidence snapshot generation, `AddPoliciesToBackupSetJob`, `BulkBackupSetRestoreJob`, and `BackupSetRestoreWorkerJob`.
|
||||
- **Explicit non-goals**: no broad rewrite of all `OperationRun` writers, no fake totals, no new `summary_counts` keys, no new status/outcome family, no dashboard redesign, no new notification policy, no new persistence, and no change to the `phased`/`composite` precedence already defined by Spec 270. Original candidate wording mentioned baseline capture/compare, but current repo truth classifies those runs through phased evidence-capture hints, so that portion is deferred rather than forced into this counted rollout.
|
||||
- **Permanent complexity imported**: targeted writer-side count initialization/increment points in existing jobs and services, focused Pest coverage across current domain test families, and one standards update that records which run families may claim counted progress under the existing contract.
|
||||
- **Why now**: Spec 270 is already prepared and its implementation surfaces now exist in the repo (`OperationRunProgressContract`, its unit suite, and the shell adopter). The next bounded value is to feed that contract with real counts instead of leaving it mostly theoretical for high-value operations.
|
||||
- **Why not local**: fixing one job at a time would preserve inconsistent counted-progress semantics across inventory, evidence, review exports, and backup/restore fan-out. The operator trust problem is cross-family and needs one bounded rollout slice.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: multiple run families, shared execution-truth semantics, and writer-side summary-count changes. Defense: the slice adds no persistence, no new vocabulary, and no new rendering layer; it only reuses existing `OperationRunService` helpers and the already-shipped progress contract.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/...` tenant-scoped launch surfaces that already enqueue inventory sync, review-pack generation, evidence snapshot generation, and backup/restore bulk work
|
||||
- `/admin/operations` and `/admin/operations/{run}` remain the canonical collection/detail routes that reflect the resulting counted progress through existing Ops-UX surfaces
|
||||
- **Data Ownership**: existing tenant-owned `operation_runs.summary_counts` remains the only progress truth touched by this slice. No new table, cache, mirror entity, or persisted progress-mode flag is allowed. Existing tenant-owned review-pack, evidence-snapshot, backup-set, and restore-run records remain domain truth for their own workflows but do not gain a second progress projection.
|
||||
- **RBAC**: existing capability checks for inventory sync, review pack generation, evidence snapshot generation, and backup/restore actions remain authoritative. Existing `OperationRun` policies remain the only visibility gate for progress feedback.
|
||||
|
||||
For canonical-view behavior:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: unchanged. `OperationRun` collection/detail surfaces continue to open in current tenant context, and the shell keeps tenant-scoped progress hints only for runs the actor can already view.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: unchanged. Non-members remain `404`, member-but-missing-capability remains `403`, and no run family in scope may emit progress-derived copy for an inaccessible run.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, activity feedback, execution-truth summaries
|
||||
- **Systems touched**: `OperationRunService`, `OperationRunProgressContract`, `SummaryCountsNormalizer`, `OperationSummaryKeys`, current shell activity feedback, and selected current run-writer jobs/services
|
||||
- **Existing pattern(s) to extend**: Spec 270 shared progress contract, current `summary_counts` sanitization/whitelist, current bulk-run completion helper, and current shell adopter
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\OpsUx\OperationRunProgressContract`, `App\Support\OpsUx\SummaryCountsNormalizer`, `App\Support\OpsUx\OperationSummaryKeys`, `App\Services\OperationRunService`, and current Ops-UX shell surfaces
|
||||
- **Why the existing shared path is sufficient or insufficient**: the repo already has one truthful render contract. What is missing is not another presenter, but writer-side counted inputs for specific run families that already have stable work units.
|
||||
- **Allowed deviation and why**: none planned. The rollout must converge on existing `OperationRunService` helpers rather than introduce domain-local count logic or host-local exceptions.
|
||||
- **Consistency impact**: selected run families may claim counted progress only through `total` plus `processed`. `succeeded`, `failed`, `skipped`, `created`, and `updated` remain outcome counters or secondary summaries, never hidden percentage sources.
|
||||
- **Review focus**: reviewers must block any rollout that invents totals, writes raw `summary_counts` without `OperationRunService`, changes progress-contract precedence, or quietly broadens the candidate back into phase/composite work.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: existing OperationRun Start UX Contract plus `App\Support\OpsUx\OperationRunProgressContract`
|
||||
- **Delegated start/completion UX behaviors**: queued toast wording, canonical `View operation` links, `run-enqueued` browser events, existing terminal notifications, and tenant-safe URL resolution remain delegated to the current shared OperationRun UX path and are unchanged in this slice
|
||||
- **Local surface-owned behavior that remains**: domain-specific initiation inputs and launch validation on current inventory, review-pack, evidence, and backup/restore start surfaces only
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Boundary classification**: `N/A`
|
||||
- **Seams affected**: `N/A`
|
||||
- **Neutral platform terms preserved or introduced**: `Operation`, `progress`, `counted progress`, `activity`, `terminal outcome`
|
||||
- **Provider-specific semantics retained and why**: none
|
||||
- **Why this does not deepen provider coupling accidentally**: the feature only rolls out counted inputs over existing platform-owned `OperationRun` truth and existing launchers
|
||||
- **Follow-up path**: none
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun progress feedback on the tenant shell | yes | Native Filament + existing Livewire/Blade surface | Ops-UX activity feedback and run summaries | shell | no | No new surface is introduced; the visible delta is that selected runs can now legitimately enter the existing counted-progress mode |
|
||||
|
||||
## Decision-First Surface Role *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun progress feedback on the tenant shell | Primary Decision Surface | Decide whether active work is genuinely progressing or merely queued/activity-only | operation label, lifecycle state, truthful counted or indeterminate mode, and canonical `View operation` action | full run detail, logs, evidence, and diagnostics stay on Operations pages | Primary because the shell is the first feedback surface after a launch action | Follows current start-surface workflow rather than storage objects | Replaces inconsistent indeterminate-only feedback for selected run types without adding another widget family |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun progress feedback on the tenant shell | operator-MSP | operation label, lifecycle state, honest counted or indeterminate progress label, canonical open link | one concise guidance line only when the next action changes | raw payloads, failure internals, provider diagnostics | `View operation` | raw/support detail stays on Operations detail | the shell shows only one progress mode derived from the shared contract; domain jobs do not add parallel progress copy |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun progress feedback on the tenant shell | Monitoring hint | Activity shell hint | Open the relevant operation only when follow-up is needed | explicit `View operation` link | forbidden | overflow navigation only | none | `/admin/operations?tenant_id={currentTenant}` | `/admin/operations/{run}` | current tenant context from the shell | Operation | lifecycle state plus one truthful progress mode | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun progress feedback on the tenant shell | Tenant operator | Decide whether active work is genuinely advancing or only waiting | Start-surface hint | Is this operation actually making measurable progress right now? | operation label, lifecycle state, counted or indeterminate mode, canonical open link | detailed run diagnostics and evidence on Operations pages | lifecycle, progress capability | none | `View operation`, `Show all operations` | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no by default; the slice reuses `OperationRunProgressContract` and `OperationRunService`
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: selected high-value runs already know stable units of work, but the product often cannot show determinate progress because those counts are never written while the run is active.
|
||||
- **Existing structure is insufficient because**: Spec 270 centralized rendering semantics, but the shared contract cannot infer counted progress honestly without writer-side `total` and `processed` inputs.
|
||||
- **Narrowest correct implementation**: update only repo-verified stable-unit run families to initialize/increment counts through existing helpers and keep all other runs on current activity/phased/composite semantics.
|
||||
- **Ownership cost**: targeted writer tests, one small standards update, and review discipline around count initialization/increment points.
|
||||
- **Alternative intentionally rejected**: inferring percentage from outcome counters or forcing baseline phased runs into counted mode was rejected because that would either be dishonest or widen the slice into Spec 272.
|
||||
- **Release truth**: current-release truth. The repo already contains the writers, helpers, and shell adopter needed to make selected runs truthful 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 of terminal-only or inconsistent counted semantics is preferred over preserving duplicate progress logic.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: the rollout changes runtime count truth in existing jobs and services, while the visible shell adopter is already covered by Spec 268/270. Focused domain feature tests plus the existing progress-contract unit suite are the narrowest honest proof.
|
||||
- **New or expanded test families**: extend `tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php` only as needed, plus current domain feature suites for inventory, review packs, evidence snapshots, backup sets, and shell progress feedback
|
||||
- **Fixture / helper cost impact**: low to moderate. Reuse existing operation-run factories, tenant helpers, and current domain job tests; do not add provider-heavy browser setup or new heavy-governance families.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: global-context-shell
|
||||
- **Standard-native relief or required special coverage**: ordinary Unit plus Feature coverage only. No new browser requirement is justified because the layout contract remains owned by Spec 268.
|
||||
- **Reviewer handoff**: reviewers must confirm that selected run families emit truthful `total` plus `processed` counts, that excluded phased runs remain phased, that no new `summary_counts` keys appear, and that `OperationRunService` remains the only writer path.
|
||||
- **Budget / baseline / trend impact**: small feature-local increase only
|
||||
- **Escalation needed**: `reject-or-split` if implementation widens into baseline phased/composite work, dashboard redesign, or a new persisted progress model
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php tests/Unit/BulkBackupSetRestoreJobTest.php tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See truthful counted progress for inventory sync (Priority: P1)
|
||||
|
||||
As a tenant operator, I need inventory sync to emit real running counts for the selected stable work units, so the shell can show determinate progress only when the sync is truly advancing.
|
||||
|
||||
**Why this priority**: inventory sync is a core operator workflow and already has a bounded unit set through attempted policy types, which makes it the cleanest counted rollout target.
|
||||
|
||||
**Independent Test**: start a sync run with multiple selected policy types, drive success/failure callback paths, and verify that `total` initializes once, `processed` increments per attempted type, and the shell can render counted progress while the run is active.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an inventory sync run starts with multiple attempted policy types, **When** the run enters `running`, **Then** `summary_counts.total` reflects the attempted type count before terminal completion.
|
||||
2. **Given** an attempted type finishes successfully or fails, **When** the callback reports that result, **Then** `summary_counts.processed` increments once and the corresponding outcome counter updates without exceeding `total`.
|
||||
3. **Given** the run completes, **When** terminal summary is written, **Then** `processed`, `total`, and outcome counters remain internally consistent and no fake counted mode is introduced for excluded or skipped work.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - See truthful counted progress for evidence and review artifact generation (Priority: P1)
|
||||
|
||||
As a tenant operator, I need review-pack generation and evidence-snapshot generation to surface real running counts, so governance artifact work no longer looks indistinguishable from generic background activity.
|
||||
|
||||
**Why this priority**: these operations are visible, valuable, and already have deterministic work sets in current code (`fileMap` entries and payload items).
|
||||
|
||||
**Independent Test**: queue review-pack and evidence-snapshot runs, verify that each job initializes `total` from its deterministic work set, increments `processed` as entries are generated, and preserves truthful terminal summaries.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a review-pack job builds its ZIP file map, **When** generation begins, **Then** the run initializes `summary_counts.total` from the post-option-filtered file set and increments `processed` as files are added to the archive.
|
||||
2. **Given** an evidence-snapshot job receives a payload with snapshot items, **When** the job persists those items, **Then** the run initializes `total` from the payload item count and increments `processed` as items are created.
|
||||
3. **Given** either job fails before all units complete, **When** the run becomes terminal, **Then** counted progress stops, terminal outcome stays authoritative, and the shell does not keep a stale determinate percentage.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Standardize counted progress for existing backup and restore bulk fan-out paths (Priority: P2)
|
||||
|
||||
As a tenant operator, I need current backup and restore bulk fan-out operations to follow one total/processed discipline, so bulk backup/restore work does not oscillate between accurate counts and partial-count drift.
|
||||
|
||||
**Why this priority**: backup/restore fan-out already contains partial counted seams in repo-real jobs, so standardization is lower-risk than greenfield rollout and strengthens an operator-critical workflow family.
|
||||
|
||||
**Independent Test**: run existing backup-set add and bulk restore flows with mixed success, skip, and failure outcomes, then verify that launchers initialize totals once, workers increment `processed` exactly once per unit, and `maybeCompleteBulkRun()` closes the run only when all units are accounted for.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a bulk backup/restore launcher enqueues child work for a bounded ID set, **When** the bulk run starts, **Then** `summary_counts.total` is initialized once from the deduplicated ID list.
|
||||
2. **Given** each child worker succeeds, skips, or fails, **When** it reports its outcome, **Then** `processed` increments exactly once per child and `maybeCompleteBulkRun()` closes the parent only after `processed >= total`.
|
||||
3. **Given** `BackupSetDeleteWorkerJob` or `BackupSetForceDeleteWorkerJob` is reviewed during implementation, **When** the path is not one of the enumerated `271` seams, **Then** it remains follow-up work and does not receive counted rollout in this slice.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Selected run families with zero measurable units must initialize truthfully and avoid impossible percentages or divide-by-zero behavior.
|
||||
- Dedupe, archived, skipped, and not-found paths must still advance `processed` exactly once when they consume one planned work unit.
|
||||
- `processed` must never exceed `total`, even when workers retry or a launcher accidentally replays a child.
|
||||
- Runs that already expose `phased` or `composite` hints through `OperationRunProgressContract` must stay on those modes in this slice; the rollout must not silently reorder contract precedence.
|
||||
- Terminal `succeeded`, `failed`, `skipped`, `created`, or `updated` counters must remain summary truth only and never become back-door progress substitutes.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment summary**: This feature introduces no new Graph contract, no new persistence, no new lifecycle state, and no new `summary_counts` key. It only rolls out trustworthy counted inputs for selected existing `OperationRun` writers and keeps all progress disclosure on the current shared contract.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The implementation MUST reuse `App\Support\OpsUx\OperationRunProgressContract`, `App\Services\OperationRunService`, `App\Support\OpsUx\SummaryCountsNormalizer`, and `App\Support\OpsUx\OperationSummaryKeys` as the only counted-progress contract and summary-count writing path.
|
||||
- **FR-002**: The v1 counted-rollout target set is limited to repo-verified stable-unit run families: inventory sync, review-pack generation, evidence-snapshot generation, `AddPoliciesToBackupSetJob`, `BulkBackupSetRestoreJob`, and `BackupSetRestoreWorkerJob`.
|
||||
- **FR-003**: For each selected run family, `summary_counts.total` MUST be initialized before or at the start of measurable work from a deterministic unit set already known to the current job/service.
|
||||
- **FR-004**: For each selected run family, `summary_counts.processed` MUST increment as each planned unit reaches a terminal per-unit outcome (`succeeded`, `failed`, or `skipped`) and MUST remain `<= total`.
|
||||
- **FR-005**: `summary_counts.succeeded`, `summary_counts.failed`, `summary_counts.skipped`, `summary_counts.created`, and `summary_counts.updated` remain outcome counters or secondary summaries. They MUST NOT replace `processed` as the counted-progress source.
|
||||
- **FR-006**: Parent bulk runs that already use `OperationRunService::maybeCompleteBulkRun()` MUST keep using that helper rather than open-coding bulk completion rules.
|
||||
- **FR-007**: Review-pack generation MUST derive its counted unit set from the actual post-option-filtered file map that is written into the ZIP archive, not from a speculative or pre-redaction estimate.
|
||||
- **FR-008**: Evidence-snapshot generation MUST derive its counted unit set from the actual payload item set that will be persisted for the snapshot, not from later terminal summary fields.
|
||||
- **FR-009**: Inventory sync MUST use stable currently attempted work units from the current selection/callback path and MUST NOT derive percentages from observed item counts alone when those item counts are only known at the end.
|
||||
- **FR-010**: This slice MUST NOT force baseline capture or baseline compare onto counted progress while `OperationRunProgressContract` still classifies those runs through phased evidence-capture hints. That deviation from the original candidate wording is intentional repo-truth narrowing, not accidental omission.
|
||||
- **FR-011**: The feature MUST update `docs/ui/tenantpilot-enterprise-ui-standards.md` to record which current run families may claim counted progress under Spec 270 and which families remain activity-only or phased/composite.
|
||||
- **FR-012**: The feature MUST NOT add new `summary_counts` keys, new progress capabilities, new notification surfaces, new polling loops, or a second progress calculator in Blade/Livewire code.
|
||||
|
||||
### Authorization and Safety Requirements
|
||||
|
||||
- **AR-001**: Existing tenant/admin-plane authorization remains unchanged: non-members or out-of-scope actors stay `404`, and member-but-missing-capability stays `403`.
|
||||
- **AR-002**: No in-scope surface may show counted progress for a run the current actor cannot already view through existing `OperationRun` policies.
|
||||
- **AR-003**: No new destructive or state-changing UI action is introduced. Existing launch surfaces keep their current authorization and confirmation rules.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: Filament remains v5 on Livewire v4. No panel-provider registration change is allowed; `apps/platform/bootstrap/providers.php` remains authoritative.
|
||||
- **NFR-002**: No new panel, globally searchable resource, or asset-registration strategy is allowed.
|
||||
- **NFR-003**: No new parallel polling loop is allowed. Existing shell and monitoring pollers remain unchanged.
|
||||
- **NFR-004**: Summary-count writes remain numeric-only and sanitize through existing whitelist semantics.
|
||||
- **NFR-005**: The rollout must stay bounded enough that all changed run families can be proved with file-scoped Pest commands rather than a new heavy-governance or browser family.
|
||||
|
||||
## Deferred Follow-Ups / Explicit Non-Goals
|
||||
|
||||
- `272 - OperationRun Phase & Composite Progress v1`
|
||||
- `273 - Tenant Dashboard Active Operations Summary Card`
|
||||
- broad counted rollout across all remaining `OperationRun` writers
|
||||
- baseline capture or baseline compare counted rollout while those runs still advertise phased evidence-capture hints through the current progress contract
|
||||
- any new persisted progress model, telemetry registry, or dashboard/activity redesign
|
||||
- any change to queued/terminal notification policy or current shell layout contract
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **Counted Progress Rollout Unit**: the deterministic per-run-family work unit that can safely drive `total` plus `processed` without inventing progress.
|
||||
- **Writer-side Count Initialization**: the point where a selected job or service seeds `summary_counts.total` from a bounded current work set.
|
||||
- **Writer-side Count Advancement**: the point where a selected job or worker increments `processed` plus the relevant outcome counters exactly once per planned unit.
|
||||
- **Excluded Phased Run Family**: a run family, such as baseline capture/compare under current repo truth, that remains on phased/composite hints instead of counted rollout.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
- Selected run families emit truthful `total` plus `processed` counts during execution and no longer depend on terminal-only summary updates for visible progress.
|
||||
- The tenant shell can show determinate counted progress for those selected run families through the existing `OperationRunProgressContract` without any view-local math changes.
|
||||
- Excluded phased/composite families remain on their current truthful modes and are not silently forced into counted percentages.
|
||||
- No new `summary_counts` keys, statuses, persistence layers, or notification policies are introduced.
|
||||
- Focused Unit plus Feature suites prove the rollout for every selected run family and catch any attempt to exceed `processed > total` or to derive counted progress from outcome counters.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 270 remains the authoritative progress contract and stays unchanged except for consuming new truthful writer-side counts.
|
||||
- Review-pack file-map entries and evidence-snapshot payload items are stable enough in current repo truth to serve as counted work units.
|
||||
- Existing bulk backup/restore workers already represent one child-per-planned-unit semantics where counted rollout is justified.
|
||||
|
||||
## Risks
|
||||
|
||||
- Review-pack generation can easily over-count if totals are derived before options remove files from the final archive.
|
||||
- Inventory sync may regress into impossible percentages if retries or callback reuse increment `processed` more than once per attempted type.
|
||||
- Backup/restore bulk launchers can drift if `total` is initialized repeatedly or if child workers increment `processed` on both retry and success paths.
|
||||
- Pulling baseline capture/compare into this slice would conflict with the existing phased/composite precedence and reopen Spec 272 implicitly.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking safe implementation. If any currently assumed stable unit set proves non-deterministic during implementation, that run family must move to a follow-up spec instead of widening this slice.
|
||||
195
specs/271-counted-progress-rollout/tasks.md
Normal file
195
specs/271-counted-progress-rollout/tasks.md
Normal file
@ -0,0 +1,195 @@
|
||||
---
|
||||
description: "Task list for Counted Progress Rollout v1"
|
||||
---
|
||||
|
||||
# Tasks: Counted Progress Rollout v1
|
||||
|
||||
**Input**: Design documents from `specs/271-counted-progress-rollout/`
|
||||
**Prerequisites**: `specs/271-counted-progress-rollout/spec.md`, `specs/271-counted-progress-rollout/plan.md`, `specs/271-counted-progress-rollout/checklists/requirements.md`
|
||||
|
||||
**Review Artifact**: `specs/271-counted-progress-rollout/checklists/requirements.md` is the outcome-of-record for the review outcome class, workflow outcome, and test-governance outcome. If implementation widens into baseline phase/composite rollout, dashboard work, or a new persisted progress model, update that artifact before continuing.
|
||||
|
||||
**Tests**: REQUIRED (Pest). Keep proof bounded to existing Unit plus Feature suites for Ops-UX, inventory, review packs, evidence snapshots, and backup sets. Browser coverage remains owned by `specs/268-operationrun-activity-feedback/` and must not become a hidden requirement here.
|
||||
**Operations**: No new `OperationRun` type, no queue-family changes, no notification-policy changes, no new `summary_counts` keys, and no new lifecycle ownership. Existing queued toasts, terminal notifications, `run-enqueued` browser events, and canonical `OperationRun` links remain authoritative.
|
||||
**RBAC**: Reuse current `OperationRun` policies and tenant-context guards. No tenantless leakage from tenant surfaces; counted progress must remain invisible for inaccessible runs.
|
||||
**Shared Pattern Reuse**: Reuse `OperationRunProgressContract`, `SummaryCountsNormalizer`, `OperationSummaryKeys`, `OperationRunService`, existing shell progress adopters, and `docs/ui/tenantpilot-enterprise-ui-standards.md`. Do not create a second local progress helper in Blade, Livewire, or jobs.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, resource, global-search behavior, or asset strategy is allowed. This slice changes run-count truth only.
|
||||
**Organization**: Tasks are grouped by user story so inventory sync, artifact generation, enumerated backup-set add/restore fan-out, and the future-boundary documentation remain independently reviewable.
|
||||
|
||||
## Test Governance Notes
|
||||
|
||||
- Lane mix stays Unit plus Feature.
|
||||
- Prefer extending existing domain suites before creating a new family.
|
||||
- Browser proof stays with Spec 268 and must not become a hidden requirement here.
|
||||
- Validation commands must stay file-scoped and run through Sail.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: confirm the bounded manual-promotion slice, the inherited Ops-UX rules, and the repo-real stable-unit seams before runtime edits begin.
|
||||
|
||||
- [x] T001 Review `specs/271-counted-progress-rollout/spec.md`, `specs/271-counted-progress-rollout/plan.md`, `specs/271-counted-progress-rollout/checklists/requirements.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/270-operationrun-progress-contract/spec.md`, `specs/268-operationrun-activity-feedback/spec.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, and `.specify/memory/constitution.md` together so the slice stays on repo-real counted progress and keeps baseline phase/composite work explicitly out of scope.
|
||||
- [x] T002 [P] Confirm the current writer and sanitizer seams in `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`, `apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php`, and `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php`.
|
||||
- [x] T003 [P] Confirm the selected stable-unit writer seams in `apps/platform/app/Jobs/RunInventorySyncJob.php`, `apps/platform/app/Jobs/GenerateReviewPackJob.php`, `apps/platform/app/Jobs/GenerateEvidenceSnapshotJob.php`, `apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php`, `apps/platform/app/Jobs/BulkBackupSetRestoreJob.php`, and `apps/platform/app/Jobs/Operations/BackupSetRestoreWorkerJob.php`.
|
||||
- [x] T004 [P] Confirm the explicit exclusions in `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, then confirm the shell-proof owners in `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`, `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`, and `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: settle the shared contract guardrail and the focused proof owners before domain jobs are changed.
|
||||
|
||||
**Critical**: no user-story runtime work should begin until this phase is complete.
|
||||
|
||||
- [x] T005 [P] Create or extend failing coverage in `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php` for the selected counted run shapes and the explicit rule that baseline capture/compare remain phased under the current contract.
|
||||
- [x] T006 [P] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` and `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` so the shell shows counted progress only when a selected run family emits trustworthy `processed` and `total`, and stays indeterminate or non-counted elsewhere.
|
||||
- [x] T007 [P] Extend `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php` only if needed so the rollout still reuses numeric-only whitelist semantics, introduces no new summary keys, and does not allow outcome counters to masquerade as progress.
|
||||
|
||||
**Checkpoint**: the shared contract boundary and focused proof owners are settled before implementation begins.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See truthful counted progress for inventory sync (Priority: P1)
|
||||
|
||||
**Goal**: inventory sync exposes truthful counted progress from its current deterministic work-unit set.
|
||||
|
||||
**Independent Test**: start a sync run with multiple attempted work units, drive mixed outcomes, and verify that `total` initializes once, `processed` increments once per completed unit, and the shell can render counted progress while the run is active.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T008 [P] [US1] Extend `apps/platform/tests/Feature/Inventory/RunInventorySyncJobTest.php` for deterministic `total` initialization, zero-unit handling, one-per-unit `processed` increments, mixed success/failure handling, and `processed <= total` invariants.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T009 [US1] Update `apps/platform/app/Jobs/RunInventorySyncJob.php` and `apps/platform/app/Services/Inventory/InventorySyncService.php` only as needed so inventory sync seeds `summary_counts.total` from deterministic attempted work units and increments `processed` exactly once per completed unit through `OperationRunService`.
|
||||
- [x] T010 [US1] Review the inventory-sync terminal summary merge path so completion preserves truthful counted progress and does not overwrite running counts with incompatible totals or outcome math.
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when inventory sync can truthfully enter counted mode without changing the shared contract.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - See truthful counted progress for review and evidence artifact generation (Priority: P1)
|
||||
|
||||
**Goal**: review-pack generation and evidence-snapshot generation expose truthful counted progress from the deterministic work sets already present in repo truth.
|
||||
|
||||
**Independent Test**: queue both run families, verify each job initializes `total` from its current deterministic work set, increments `processed` as work items complete, and leaves the shell on truthful counted or non-counted states only.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T011 [P] [US2] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php` and `apps/platform/tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php` for deterministic `total` initialization, zero-unit handling, running `processed` increments, and truthful terminal behavior.
|
||||
- [x] T012 [P] [US2] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` or `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` only as needed so the shell proves counted adoption for these writer shapes without introducing view-local progress math.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T013 [US2] Update `apps/platform/app/Jobs/GenerateReviewPackJob.php` and `apps/platform/app/Services/ReviewPackService.php` only as needed so `summary_counts.total` comes from the final post-option-filtered file map and `processed` advances as archive entries are written.
|
||||
- [x] T014 [US2] Update `apps/platform/app/Jobs/GenerateEvidenceSnapshotJob.php` and `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` only as needed so `summary_counts.total` comes from the persisted payload item set and `processed` advances once per created item.
|
||||
- [x] T015 [US2] Review review-pack and evidence failure paths so terminal outcomes remain authoritative and no stale counted progress or impossible percentages survive a failed run.
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when artifact-generation runs can truthfully enter counted mode through the existing contract.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Standardize counted progress for existing backup and restore fan-out paths (Priority: P2)
|
||||
|
||||
**Goal**: current backup/restore fan-out paths follow one total/processed discipline rather than a mix of partial or launcher-only count behavior.
|
||||
|
||||
**Independent Test**: execute current backup-set add and bulk restore flows with mixed success, skip, and failure results, then verify that parent totals initialize once, child workers advance `processed` exactly once per planned unit, and `maybeCompleteBulkRun()` owns parent completion.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T016 [P] [US3] Extend `apps/platform/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php` and `apps/platform/tests/Unit/BulkBackupSetRestoreJobTest.php` for one-time `total` initialization, zero-unit handling, exact `processed` increments, mixed child outcomes, and helper-owned completion.
|
||||
- [x] T017 [P] [US3] Review `apps/platform/app/Jobs/Operations/BackupSetDeleteWorkerJob.php` and `apps/platform/app/Jobs/Operations/BackupSetForceDeleteWorkerJob.php` only to confirm they remain out of scope for `271`; record that exclusion or any follow-up in the review artifact instead of widening the implementation slice.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T018 [US3] Standardize `apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php`, `apps/platform/app/Jobs/BulkBackupSetRestoreJob.php`, and `apps/platform/app/Jobs/Operations/BackupSetRestoreWorkerJob.php` on one total/processed discipline through `OperationRunService` helpers.
|
||||
- [x] T019 [US3] Keep the backup/restore rollout limited to `AddPoliciesToBackupSetJob`, `BulkBackupSetRestoreJob`, and `BackupSetRestoreWorkerJob`; record any additional seam as follow-up explicitly instead of widening the implementation slice.
|
||||
- [x] T020 [US3] Review parent/child retry, archived, not-found, and skipped paths so `processed` advances exactly once per planned unit and parent completion remains helper-owned.
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when the selected backup/restore fan-out paths share one truthful count discipline.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Preserve the future boundary in standards and review artifacts (Priority: P2)
|
||||
|
||||
**Goal**: future contributors can extend counted progress without silently widening this slice into phase/composite rollout or a second progress framework.
|
||||
|
||||
**Independent Test**: review the standards update and the completed proof list together, then confirm that baseline phase/composite rollout, dashboard work, and any non-deterministic writer families remain named follow-ups rather than hidden scope here.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T021 [US4] Update `docs/ui/tenantpilot-enterprise-ui-standards.md` with the current counted-rollout rules: `processed` and `total` remain the only determinate v1 source, outcome counters remain outcome-only, and baseline phase/composite families stay excluded until their follow-up spec.
|
||||
- [x] T022 [US4] Review the resulting package and touched code to confirm there is still no baseline phase/composite widening, no second progress helper, no new summary key, no new panel/asset/global-search change, and no new notification-policy change.
|
||||
|
||||
**Checkpoint**: User Story 4 is independently functional when future-extension boundaries are explicit in both docs and the feature package.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: validate the bounded slice, stop drift, and hand off a clean implementation path.
|
||||
|
||||
- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`.
|
||||
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php tests/Unit/BulkBackupSetRestoreJobTest.php tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`.
|
||||
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
|
||||
- [x] T026 [P] Review touched code against `docs/ui/tenantpilot-enterprise-ui-standards.md` and confirm the shell remains decision-first, diagnostics-light, Filament-native, and backed by one shared progress contract.
|
||||
- [x] T027 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no new assets were registered, and no new `OperationRun` lifecycle or notification path was introduced.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks user-story work.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the first truthful counted writer family.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should land after or alongside US1 so artifact-generation runs adopt the same count discipline.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and should land after the first counted families so backup/restore fan-out follows the same helper-owned pattern.
|
||||
- **Phase 6 (US4)**: depends on Phases 3 through 5 so the documented boundary matches the implemented slice.
|
||||
- **Phase 7 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and delivers the cleanest counted rollout target.
|
||||
- **US2 (P1)**: independently testable after Phase 2 and delivers visible counted progress for governance artifact generation.
|
||||
- **US3 (P2)**: independently testable after Phase 2 and completes the approved backup/restore fan-out part of the candidate.
|
||||
- **US4 (P2)**: independently testable after Phases 3 through 5 and is required for package completion because the narrowed `271`/`272` boundary is part of the approved scope.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or extend the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Land the writer-side counted-truth changes before adjusting any shared shell assertions that depend on those writers.
|
||||
- Re-run the narrowest affected validation command after each story checkpoint before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2**, because the first enterprise-visible value arrives once inventory and artifact-generation runs can truthfully enter counted mode through the current shell adopter.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1.
|
||||
3. Deliver US2.
|
||||
4. Deliver US3.
|
||||
5. Land US4 documentation and boundary hardening.
|
||||
6. Finish with focused validation and formatting.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the shared contract boundary and proof owners first.
|
||||
2. Keep inventory, artifact-generation, and backup/restore edits serialized per family.
|
||||
3. Do not widen into baseline phase/composite rollout, dashboard follow-up work, or a second progress framework while implementing this package.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- `272 - OperationRun Phase & Composite Progress v1`
|
||||
- `273 - Tenant Dashboard Active Operations Summary Card`
|
||||
- any browser-smoke expansion beyond the currently-owned Spec 268 overlap proof
|
||||
- any new writer-side rollout that cannot prove deterministic work units
|
||||
- any persisted progress mode, registry, or dashboard redesign
|
||||
@ -0,0 +1,61 @@
|
||||
# Specification Quality Checklist: OperationRun Phase & Composite Progress v1
|
||||
|
||||
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||
**Created**: 2026-05-05
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The package stays on one bounded non-counted progress extension over existing `OperationRun` truth instead of widening into a workflow engine, dashboard redesign, child-run graph, or a second progress framework.
|
||||
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
|
||||
- [x] The package explicitly names the repo-real anchors it builds on: `OperationRunProgressContract`, `OperationUxPresenter`, `OperationStatusNormalizer`, current baseline phase context, and `tenant.review.compose` aggregate operation truth.
|
||||
- [x] Mandatory repo sections for scope, shared-pattern reuse, Ops-UX, testing, proportionality, and candidate rationale are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No unresolved clarification markers remain.
|
||||
- [x] Requirements are testable and bounded to selected current phase/composite run families plus one standards update.
|
||||
- [x] The package explicitly keeps `processed` and `total` as the only determinate progress source and keeps phase/composite v1 explicitly non-counted.
|
||||
- [x] The package explicitly forbids fake percentages, raw technical phase labels, new `summary_counts` keys, a child-run graph, and a workflow engine.
|
||||
- [x] The package explicitly records the candidate narrowing: review-pack and evidence-snapshot overlap remain with Spec 271, while provider health and support diagnostics remain deferred until repo-real queued progress truth exists.
|
||||
- [x] The package keeps provider, panel, global-search, asset, queue-family, notification-policy, and persistence changes out of scope.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and is consistent with the broader roadmap direction.
|
||||
- [x] The active queue is explicitly empty, so this package records itself as a deliberate manual promotion rather than an automatic next-best-prep target.
|
||||
- [x] Repo verification confirmed `specs/270-operationrun-progress-contract/` is the immediate prerequisite context for this package.
|
||||
- [x] Repo verification confirmed `specs/271-counted-progress-rollout/` remains the adjacent counted boundary and must not be silently reopened here.
|
||||
- [x] Repo verification confirmed baseline capture and baseline compare already persist phase-shaped context that currently terminates in generic fallback labels.
|
||||
- [x] Repo verification confirmed `tenant.review.compose` already has repo-real aggregate operation truth suitable for a bounded composite summary without adding a child-run graph.
|
||||
- [x] Repo verification confirmed provider health and support diagnostics do not currently expose equivalent queued phase/composite `OperationRun` truth and therefore remain deferred.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The package reuses current `OperationRun` truth and the current shared progress contract instead of introducing a second lifecycle or persisted projection.
|
||||
- [x] The package names both the selected in-scope run families and the excluded candidate families.
|
||||
- [x] The package forbids new panel, provider, global-search, asset-registration, dashboard, workflow-engine, and persistence changes.
|
||||
- [x] The package preserves the current polling posture, current terminal-outcome contract, and current counted precedence.
|
||||
- [x] The planned validation commands stay consistent across `spec.md`, `plan.md`, and `tasks.md`.
|
||||
- [x] No application implementation was performed while preparing this package.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned proof stays bounded to existing Unit plus Feature families for Ops-UX, Baselines, TenantReview, and current run-detail compatibility.
|
||||
- [x] No new heavy-governance or browser family is introduced by default.
|
||||
- [x] Fixture growth remains bounded to current tenant context helpers, current baseline helpers, current `OperationRun` factories, and current tenant-review fixtures.
|
||||
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into the active prep package.
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `.specify/memory/constitution.md`, `.specify/templates/checklist-template.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/268-operationrun-activity-feedback/spec.md`, `specs/270-operationrun-progress-contract/spec.md`, `specs/271-counted-progress-rollout/spec.md`, `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php`, `apps/platform/app/Services/Baselines/BaselineCaptureService.php`, `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`, `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/TenantReviews/TenantReviewService.php`, `apps/platform/app/Jobs/ComposeTenantReviewJob.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `docs/ui/tenantpilot-enterprise-ui-standards.md` on 2026-05-05.
|
||||
- This checklist is the prep-time outcome record. If implementation widens into provider health/support progress, review-pack overlap, dashboard work, child-run graph persistence, or a workflow engine, the workflow outcome must change before merge.
|
||||
- No application implementation was performed while preparing this package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Reason**: the package is a bounded manual-promotion follow-up to Spec 270 that reuses current shared truth, preserves the counted boundary from Spec 271, and explicitly documents the candidate narrowing required by current repo reality.
|
||||
- **Final note location**: This checklist during prep, and the active feature PR close-out entry only if implementation later forces `split` or `document-in-feature`.
|
||||
260
specs/272-operationrun-phase-composite-progress/plan.md
Normal file
260
specs/272-operationrun-phase-composite-progress/plan.md
Normal file
@ -0,0 +1,260 @@
|
||||
# Implementation Plan: OperationRun Phase & Composite Progress v1
|
||||
|
||||
**Branch**: `272-operationrun-phase-composite-progress` | **Date**: 2026-05-05 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/272-operationrun-phase-composite-progress/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
This plan prepares the bounded non-counted follow-up to Spec 270 and the adjacent counted boundary from Spec 271. The implementation path is to reuse `OperationRunProgressContract`, `OperationUxPresenter`, and `OperationRunService`, add canonical operator-safe phase metadata for `baseline_capture` and `baseline_compare`, and add one bounded composite summary path for `tenant.review.compose` from existing aggregate operation truth. The slice must not invent fake percentages, reopen counted rollout work, add a workflow engine, create a child-run graph, or widen into provider health, support diagnostics, or dashboard work.
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `App\Support\OpsUx\OperationRunProgressContract` already centralizes `none`, `activity`, `counted`, `phased`, and `composite` progress modes.
|
||||
- The current contract already classifies baseline evidence-capture context as `phased` and aggregate multi-run truth as `composite`, but it currently returns only generic placeholder labels.
|
||||
- `App\Jobs\CaptureBaselineSnapshotJob` and `App\Jobs\CompareBaselineToTenantJob` already persist repo-real baseline phase-shaped context, including eligibility and evidence-capture stats.
|
||||
- `App\Services\TenantReviews\TenantReviewService` and `App\Jobs\ComposeTenantReviewJob` already create the canonical `tenant.review.compose` run type, and current evidence snapshot summaries already contain aggregate operation counts used later in tenant review composition.
|
||||
- The current tenant shell adopter already renders generic phased/composite fallbacks through the shared contract without inventing a percentage.
|
||||
- Spec 271 already owns truthful counted rollout for stable-unit families and must remain distinct from this slice.
|
||||
|
||||
### Explicit delta in this plan
|
||||
|
||||
- add canonical operator-safe phase metadata and label derivation for `baseline_capture` and `baseline_compare`
|
||||
- add one bounded composite summary path for `tenant.review.compose` from current aggregate operation truth
|
||||
- keep counted, terminal, and generic activity semantics unchanged except for consuming more truthful non-counted detail
|
||||
- document the narrowed candidate boundary explicitly: review-pack and evidence-snapshot overlap remain with Spec 271, while provider health and support diagnostics remain deferred until repo-real queued progress truth exists
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: existing Ops-UX contract/presenter classes, current baseline services/jobs, current tenant-review services/jobs, Pest v4
|
||||
**Storage**: PostgreSQL via existing `operation_runs`, baseline, evidence snapshot, and tenant review tables
|
||||
**Testing**: Pest Unit + Feature
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: existing Laravel monolith in `apps/platform`
|
||||
**Project Type**: web application (Laravel monolith with Filament)
|
||||
**Performance Goals**: no new poller family, no new query family, and no slower-than-current activity feedback disclosure
|
||||
**Constraints**: no fake percentages, no new `summary_counts` keys, no new persistence, no child-run graph, no dashboard redesign, and no provider-health or support-diagnostics rollout
|
||||
**Scale/Scope**: one shared contract extension across 3 selected run families plus one UI standards update and focused regression coverage
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php`
|
||||
- `apps/platform/app/Services/OperationRunService.php`
|
||||
- `apps/platform/app/Services/Baselines/BaselineCaptureService.php`
|
||||
- `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- `apps/platform/app/Services/TenantReviews/TenantReviewService.php`
|
||||
- `apps/platform/app/Jobs/ComposeTenantReviewJob.php`
|
||||
- `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`
|
||||
- `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`
|
||||
- `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`
|
||||
- `apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php`
|
||||
- `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`
|
||||
- `apps/platform/tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php`
|
||||
- `apps/platform/tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||
- `apps/platform/tests/Feature/TenantReview/TenantReviewOperationsUxTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
|
||||
- `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- `apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php`
|
||||
- `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
|
||||
- `docs/ui/tenantpilot-enterprise-ui-standards.md`
|
||||
|
||||
## UI / Filament & Livewire Fit
|
||||
|
||||
- The visible adopter remains the existing tenant-shell activity feedback surface. No new page, dashboard card, widget family, or top-level monitoring surface is introduced.
|
||||
- Canonical Operations collection/detail routes remain diagnostics-first drill-through targets. They may inherit safer phase/composite disclosure through the shared presenter path, but they do not gain a second progress framework.
|
||||
- The shell remains decision-first. This slice only improves whether selected runs can describe truthful non-counted phase/composite state.
|
||||
- Filament stays v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
|
||||
|
||||
## RBAC / Policy Fit
|
||||
|
||||
- Existing capability gates for baseline capture, baseline compare, and tenant review remain unchanged.
|
||||
- Existing `OperationRun` policies remain the only progress-visibility gate.
|
||||
- No new mutation surface is introduced, so current server-side authorization and confirmation behavior stays on the existing launch/detail surfaces.
|
||||
|
||||
## Audit / Logging Fit
|
||||
|
||||
- Existing queued toasts and terminal notifications remain authoritative and unchanged.
|
||||
- Existing audit ownership remains the only audit trail for the covered runs; no new run-local audit channel is introduced.
|
||||
- Progress detail remains derived execution truth inside the current `OperationRun` ownership model, not a second audit or event stream.
|
||||
|
||||
## Data & Query Fit
|
||||
|
||||
- Progress truth remains fully derived from existing `operation_runs.context`, `operation_runs.summary_counts`, and current contract logic.
|
||||
- Selected phase metadata stays inside existing `context` JSON and does not create a new persisted entity.
|
||||
- Composite progress for `tenant.review.compose` stays bounded to currently available aggregate operation truth from evidence snapshot or review summary data.
|
||||
- No migration, no new JSON table, no cache layer, and no new persisted user preference or progress mode are planned.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament + existing Livewire/Blade shell surface
|
||||
- **Shared-family relevance**: Ops-UX activity feedback and current run summaries
|
||||
- **State layers in scope**: shell, detail compatibility
|
||||
- **Audience modes in scope**: operator-MSP
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first on shell, diagnostics-second on Operations detail
|
||||
- **Raw/support gating plan**: unchanged; raw/support detail remains on diagnostics surfaces only
|
||||
- **One-primary-action / duplicate-truth control**: keep one dominant `View operation` action and one shared progress contract that now supplies richer non-counted labels
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: global-context-shell
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none planned
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: Ops-UX progress contract, Ops-UX presenter, baseline jobs/services, tenant-review queue/composition path, shell activity feedback
|
||||
- **Shared abstractions reused**: `OperationRunProgressContract`, `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunService`
|
||||
- **New abstraction introduced? why?**: no new top-level abstraction planned; extend the current contract/presenter path only
|
||||
- **Why the existing abstraction was sufficient or insufficient**: classification already exists; what is missing is canonical operator-safe phase/composite detail for selected run families
|
||||
- **Bounded deviation / spread control**: selected families only; any additional run family without repo-real truthful phase/composite detail stays on current generic fallback
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for active progress meaning only
|
||||
- **Central contract reused**: existing OperationRun Start UX Contract plus `OperationRunProgressContract`
|
||||
- **Delegated UX behaviors**: queued toast, canonical run links, `run-enqueued` event, and terminal-notification lifecycle remain delegated and unchanged
|
||||
- **Surface-owned behavior kept local**: current launch inputs, detailed diagnostics, and domain-specific result explanations stay local to their current surfaces
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: `N/A`
|
||||
- **Platform-core seams**: `OperationRun` truth, Ops-UX contract/presenter, baseline and tenant-review execution truth
|
||||
- **Neutral platform terms / contracts preserved**: `Operation`, `activity`, `phase progress`, `composite progress`, `terminal outcome`
|
||||
- **Retained provider-specific semantics and why**: none
|
||||
- **Bounded extraction or follow-up path**: provider health and support diagnostics remain an explicit later follow-up if queued progress truth exists later
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins and again before merge.*
|
||||
|
||||
- Inventory-first: PASS. The slice only enriches current execution feedback over existing operational truth.
|
||||
- Read/write separation: PASS. No new external write path is introduced; current domain jobs keep their existing product responsibilities and only persist better progress detail in `OperationRun` context.
|
||||
- Graph contract path: PASS. No new Graph or provider contract is introduced.
|
||||
- Deterministic capabilities: PASS. Authorization and run visibility remain deterministic and testable.
|
||||
- RBAC-UX: PASS. Visibility remains on existing tenant/admin boundaries and current `OperationRun` policies.
|
||||
- Run observability: PASS. Long-running work still flows through current `OperationRun` ownership and current Ops-UX surfaces.
|
||||
- Ops-UX lifecycle: PASS. `status` and `outcome` ownership remains on `OperationRunService`; this slice only adds richer non-counted detail.
|
||||
- Ops-UX summary counts: PASS. No new `summary_counts` key is planned.
|
||||
- Test governance: PASS. Proof remains bounded to Unit plus Feature.
|
||||
- Proportionality / no premature abstraction: PASS. No new workflow engine, child-run graph, or second presenter path is introduced.
|
||||
- Persisted truth / behavioral state: PASS. No new table, cache, or lifecycle family is added.
|
||||
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing Ops-UX path remains central and the visible adopter is unchanged.
|
||||
- Provider boundary: PASS. No provider/platform seam change.
|
||||
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no assets change.
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for contract precedence and label derivation; Feature for selected baseline and tenant-review writer seams plus current shell adoption
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the shared contract already has a focused unit suite, and the selected run families already have domain feature suites that can prove current context/summary truth without adding new browser or heavy-governance obligations
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/TenantReview/TenantReviewOperationsUxTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/TenantReview/TenantReviewRbacTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse current tenant context helpers, existing baseline helpers, and current tenant-review fixtures instead of adding a new provider-heavy harness
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `global-context-shell`
|
||||
- **Closing validation and reviewer handoff**: rerun the two proving commands above and verify that selected run families now expose truthful non-counted detail, the full precedence chain `phased > composite > counted > activity` still holds, counted semantics stay counted-only, malformed metadata degrades safely, current `404`/`403` visibility semantics remain intact, no new polling loop was introduced, and excluded families remain excluded
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
|
||||
- **Review-stop questions**: did any run family leak raw technical phase labels, did any composite summary masquerade as a percentage, did any excluded family sneak in, and did any new `summary_counts` key or workflow framework appear?
|
||||
- **Escalation path**: `reject-or-split` for any provider-health/support expansion, dashboard work, child-run graph persistence, or workflow-engine drift
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Why no dedicated follow-up spec is needed**: this package is itself the bounded non-counted progress follow-up to Spec 270; remaining excluded ideas are already named as explicit later follow-ups.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/272-operationrun-phase-composite-progress/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (expected implementation surfaces)
|
||||
|
||||
```text
|
||||
apps/platform/app/Support/OpsUx/
|
||||
apps/platform/app/Services/OperationRunService.php
|
||||
apps/platform/app/Services/Baselines/BaselineCaptureService.php
|
||||
apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php
|
||||
apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
apps/platform/app/Services/TenantReviews/TenantReviewService.php
|
||||
apps/platform/app/Jobs/ComposeTenantReviewJob.php
|
||||
apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php
|
||||
apps/platform/tests/Unit/Support/OpsUx/
|
||||
apps/platform/tests/Feature/OpsUx/
|
||||
apps/platform/tests/Feature/Baselines/
|
||||
apps/platform/tests/Feature/TenantReview/
|
||||
apps/platform/tests/Feature/Filament/
|
||||
docs/ui/tenantpilot-enterprise-ui-standards.md
|
||||
```
|
||||
|
||||
**Structure Decision**: keep the rollout local to current jobs/services plus the existing Ops-UX support family. Do not introduce a workflow engine, child-run graph, or second progress framework.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
- No migration or schema change is planned.
|
||||
- No new persisted progress mode or preference is allowed.
|
||||
- Selected phase/composite detail remains inside existing `operation_runs.context` JSON only.
|
||||
- No backfill is planned. Historical runs remain historical truth; the rollout affects future execution and active-run rendering only.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- No global search or asset change is required because the slice changes only current run-progress truth.
|
||||
- No destructive action or confirmation model changes are planned.
|
||||
- No deployment step beyond ordinary code deploy, focused tests, and current formatting validation is expected.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Reject any implementation that turns phase/composite labels into fake percentages or a synthetic progress bar.
|
||||
- Reject any implementation that leaks raw technical step names, exception class names, or transport details into the default operator-facing progress label.
|
||||
- Reject any implementation that adds a child-run graph or persists `child_run_ids` as a new framework instead of a bounded current-release need.
|
||||
- Reject any implementation that widens the rollout to provider health, support diagnostics, review-pack, or evidence-snapshot overlap without an explicit follow-up spec.
|
||||
- Reject any implementation that changes counted precedence or adds a new `summary_counts` key to compensate for missing phase/composite truth.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Confirm The Selected Non-Counted Seams
|
||||
|
||||
- Verify the current phase and composite fallback behavior in `OperationRunProgressContract`, the current shell adopter, the current baseline jobs, and the current tenant-review queue/composition path.
|
||||
- Reconfirm that provider health, support diagnostics, review-pack overlap, and child-run graph persistence remain out of scope.
|
||||
|
||||
### Phase 1 - Roll Out Baseline Phase Metadata
|
||||
|
||||
- Add canonical operator-safe phase metadata for `baseline_capture` and `baseline_compare` over the repo-real lifecycle boundaries that already exist today.
|
||||
|
||||
### Phase 2 - Roll Out Tenant Review Composite Summary
|
||||
|
||||
- Seed a bounded composite summary for `tenant.review.compose` from current evidence-basis operation truth and keep it explicitly non-counted.
|
||||
|
||||
### Phase 3 - Lock The Guardrail And Proof
|
||||
|
||||
- Update the UI standards and focused tests so later non-counted progress work extends one contract rather than inventing new semantics.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: current non-counted runs know more than the product currently says, yet the shell still shows vague generic fallback copy.
|
||||
- **Existing structure is insufficient because**: the shared contract can classify `phased` and `composite`, but it cannot yet explain them with canonical operator-safe detail for selected run families.
|
||||
- **Narrowest correct implementation**: update only selected repo-real phase/composite families and keep all other work on current generic fallback or counted semantics.
|
||||
- **Ownership cost created**: selected context writes in existing jobs/services, focused tests, and one standards update.
|
||||
- **Alternative intentionally rejected**: a workflow engine, child-run graph, or fake percentage model was rejected because all would be structurally heavier and less truthful than the current-release need.
|
||||
- **Release truth**: current-release truth. The repo already contains the contract, the shell adopter, the baseline phase hints, and the tenant-review aggregate truth needed for this rollout.
|
||||
310
specs/272-operationrun-phase-composite-progress/spec.md
Normal file
310
specs/272-operationrun-phase-composite-progress/spec.md
Normal file
@ -0,0 +1,310 @@
|
||||
# Feature Specification: OperationRun Phase & Composite Progress v1
|
||||
|
||||
**Feature Branch**: `272-operationrun-phase-composite-progress`
|
||||
**Created**: 2026-05-05
|
||||
**Status**: Ready for implementation
|
||||
**Input**: Manual promotion from `docs/product/spec-candidates.md` after the repo-based next-best-prep review confirmed the automatic queue is intentionally empty and the user explicitly chose to promote candidate `272`.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: `specs/270-operationrun-progress-contract/` already introduced truthful `none`, `activity`, `counted`, `phased`, and `composite` progress capabilities, but the repo still treats `phased` and `composite` as generic fallbacks. Baseline capture and baseline compare already persist phase-shaped context, and `tenant.review.compose` already carries aggregate operation truth, yet operators still only get vague `Phase progress pending.` or `Composite progress pending.` copy.
|
||||
- **Today's failure**: long-running, non-countable, or aggregate runs can expose meaningful execution truth today, but the shared contract cannot translate that truth into operator-safe phase text or composite summaries. That leaves enterprise operators with either vague indeterminate copy or pressure to invent fake percentages and local explanations.
|
||||
- **User-visible improvement**: selected non-countable `OperationRun` families show truthful operator-safe phase text or composite child summaries while remaining explicitly non-counted. Operators can tell what the system is doing without a fabricated percentage.
|
||||
- **Smallest enterprise-capable version**: extend the existing shared Ops-UX progress contract and current shell adopter so `baseline_capture`, `baseline_compare`, and `tenant.review.compose` can surface canonical non-counted phase/composite labels from existing `OperationRun.context` and existing aggregate summary truth, without adding a workflow engine, a dashboard redesign, or a second presenter layer.
|
||||
- **Explicit non-goals**: no full workflow engine, no new top-level monitoring page, no child-run graph persistence, no provider health or support-diagnostics rollout unless repo-real queued progress truth appears first, no review-pack or evidence-snapshot overlap with Spec 271, no fake percentages, no AI-generated progress explanations, no new `summary_counts` keys, and no new `OperationRun` lifecycle state.
|
||||
- **Permanent complexity imported**: one bounded non-counted progress detail extension inside the existing Ops-UX contract, one derived phase-step vocabulary kept in code and docs only, selected context-shape updates for repo-real run families, focused Pest Unit plus Feature coverage, and one UI standards update.
|
||||
- **Why now**: Spec 270 already prepared the shared contract and Spec 271 already claims the counted rollout boundary. The next truthful gap is the current generic `phased` and `composite` fallback behavior. Repo truth already contains real baseline phase context and tenant-review aggregate counts, so the product can now make those categories honest and useful without inventing a new framework.
|
||||
- **Why not local**: adding baseline-only or tenant-review-only labels would fragment progress semantics across the shell, Operations detail, and future non-counted run families. The truth gap is shared, so the fix must stay on the shared contract.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: derived phase vocabulary, selected persisted context metadata for non-counted progress, and one shared contract extension. Defense: the vocabulary stays derived only, persistence stays inside the existing `operation_runs.context` JSON, and the feature reuses the current Ops-UX contract rather than adding a second framework.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/...` tenant-scoped launch surfaces whose existing shell feedback adopts the shared progress contract
|
||||
- `/admin/t/{tenant}` remains the contextual surface where recent-operation activity hints stay visible; no new tenant dashboard card is introduced
|
||||
- `/admin/operations` and `/admin/operations/{run}` remain the canonical collection/detail routes that must stay compatible with the resulting phase/composite truth
|
||||
- **Data Ownership**: existing `operation_runs.context`, `operation_runs.summary_counts`, `operation_runs.status`, and `operation_runs.outcome` remain the only persisted execution truth. This feature may standardize new phase/composite context payloads for selected run families inside `context`, but it must not add a table, cache, mirror projection, or persisted progress-mode flag.
|
||||
- **RBAC**: existing capability checks for baseline capture, baseline compare, and tenant review continue to govern launch access. Existing `OperationRun` policies remain the only visibility gate for phase/composite progress feedback.
|
||||
|
||||
For canonical-view behavior:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: unchanged. The shell continues to show only the current tenant's visible runs, and canonical Operations collection/detail access remains tenant-aware when tenant context is active.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: unchanged. Non-members remain `404`, member-but-missing-capability remains `403`, and no selected run family may emit phase/composite copy for a run the actor cannot already view.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, activity feedback, execution-truth summaries, and canonical operation guidance
|
||||
- **Systems touched**: `OperationRunProgressContract`, `OperationUxPresenter`, `OperationStatusNormalizer`, current shell activity feedback, baseline capture/compare jobs, tenant-review composition start/queue path, and `docs/ui/tenantpilot-enterprise-ui-standards.md`
|
||||
- **Existing pattern(s) to extend**: Spec 270 shared progress contract, current Ops-UX 3-surface lifecycle rules, current shell feedback host, and current baseline/tenant-review execution truth
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\OpsUx\OperationRunProgressContract`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\OperationStatusNormalizer`, `App\Services\OperationRunService`, and the existing shell activity feedback surface
|
||||
- **Why the existing shared path is sufficient or insufficient**: the repo already has one shared progress contract and one shared presenter path, but `phased` and `composite` currently stop at generic placeholder labels. The gap is not classification; it is the lack of canonical operator-safe detail for selected non-counted run families.
|
||||
- **Allowed deviation and why**: none planned. The slice must extend the shared contract and shared presenter path rather than introducing a baseline-local or tenant-review-local progress helper.
|
||||
- **Consistency impact**: `preparing`, `fetching`, `processing`, `persisting`, and `finalizing` must remain operator-safe labels rather than raw internals, and composite summaries must never imply a counted percentage unless `processed` and `total` separately satisfy the counted contract.
|
||||
- **Review focus**: reviewers must block any implementation that leaks raw technical phase names, invents fake percentages, reorders the current progress-contract precedence, or quietly widens the feature to unrelated run families.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: existing OperationRun Start UX Contract plus `App\Support\OpsUx\OperationRunProgressContract` and `App\Support\OpsUx\OperationUxPresenter`
|
||||
- **Delegated start/completion UX behaviors**: queued toast wording, canonical `View operation` links, tenant-safe URL resolution, current `run-enqueued` browser events, and existing terminal notifications remain delegated to the current shared path and are unchanged in this slice
|
||||
- **Local surface-owned behavior that remains**: current baseline and tenant-review launch inputs plus current run-detail diagnostics stay local to their existing surfaces; non-counted progress semantics do not
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Boundary classification**: `N/A`
|
||||
- **Seams affected**: `N/A`
|
||||
- **Neutral platform terms preserved or introduced**: `Operation`, `progress`, `phase progress`, `composite progress`, `activity`, `terminal outcome`
|
||||
- **Provider-specific semantics retained and why**: none
|
||||
- **Why this does not deepen provider coupling accidentally**: the feature only clarifies shared execution truth over existing platform-owned `OperationRun` data and existing domain jobs
|
||||
- **Follow-up path**: none
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun activity feedback on the tenant shell | yes | Native Filament + existing Livewire/Blade surface | Ops-UX activity feedback and execution-truth summaries | shell | no | No new surface is introduced; selected runs gain truthful phase/composite detail within the existing host |
|
||||
|
||||
## Decision-First Surface Role *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun activity feedback on the tenant shell | Primary Decision Surface | Decide whether current work simply needs time, is in a meaningful phase, or needs review because aggregate child work is failing | operation label, lifecycle state, one truthful non-counted phase/composite label when available, and canonical `View operation` action | full run detail, logs, evidence, and diagnostics stay on Operations detail | Primary because it is the current visible progress host and the first place operators check after starting long-running work | Follows the existing start-surface workflow | Replaces vague generic fallback copy without creating another widget family |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun activity feedback on the tenant shell | operator-MSP | operation label, lifecycle state, operator-safe phase/composite label, and canonical open link | one concise guidance line only when it changes the next action | raw payloads, failure internals, evidence details, and unrestricted diagnostics | `View operation` | raw/support detail stays on Operations detail | the shell shows only one progress meaning derived from the shared contract rather than per-run-family ad hoc labels |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun activity feedback on the tenant shell | Monitoring hint | Activity shell hint | Open the most relevant operation if follow-up is needed | explicit `View operation` link | forbidden | overflow navigation only | none | `/admin/operations?tenant_id={currentTenant}` | `/admin/operations/{run}` | current tenant context from the shell | Operation | lifecycle state plus one truthful non-counted progress label | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Existing OperationRun activity feedback on the tenant shell | Tenant operator | Decide whether active work is in a meaningful phase, an aggregate child-review state, or merely generic activity | Start-surface hint | What is this operation doing right now, and do I need to act? | operation label, lifecycle state, operator-safe phase/composite label, canonical open link | detailed run diagnostics and evidence on Operations pages | lifecycle, progress capability | none | `View operation`, `Show all operations` | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no new top-level abstraction by default; the slice extends the existing progress contract and presenter path only
|
||||
- **New enum/state/reason family?**: yes - one derived phase-step vocabulary and one bounded composite-summary shape, both kept in code and docs only
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: selected non-countable or aggregate runs already carry more truthful execution detail than the product surfaces today, but that truth is trapped in raw context or generic placeholders
|
||||
- **Existing structure is insufficient because**: the current generic phased/composite fallback tells operators only that some truth exists, not what is actually happening or why a run still deserves patience versus review
|
||||
- **Narrowest correct implementation**: extend the existing shared progress contract and selected existing jobs/services so only repo-real phase/composite families gain operator-safe non-counted detail
|
||||
- **Ownership cost**: selected context metadata writes in current jobs/services, focused Unit plus Feature coverage, and one UI standards update
|
||||
- **Alternative intentionally rejected**: forcing these runs into counted percentages or introducing a workflow engine was rejected because both options would be either dishonest or structurally disproportionate
|
||||
- **Release truth**: current-release truth. The repo already contains phase-shaped baseline context and aggregate tenant-review operation truth that justify this bounded extension 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 of vague phase/composite fallback copy is preferred over preserving duplicate local semantics.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: the slice changes shared render semantics plus current job/service metadata writes. One focused Unit suite can prove capability precedence and label derivation cheaply, while focused Feature suites can prove the selected baseline and tenant-review seams persist truthful non-counted detail and the existing shell host renders it without inventing a percentage.
|
||||
- **New or expanded test families**: extend `tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`, `tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`, `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/Baselines/BaselineCaptureTest.php`, `tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php`, `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`, `tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php`, and `tests/Feature/TenantReview/TenantReviewOperationsUxTest.php`; extend `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/TenantReview/TenantReviewRbacTest.php`, and `tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` only as needed when presenter or authorization compatibility proof changes require it
|
||||
- **Fixture / helper cost impact**: low to moderate. Reuse existing `OperationRun` factories, baseline helpers, tenant-review fixtures, and current tenant context helpers; do not add a new browser family or provider-heavy test harness
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: global-context-shell
|
||||
- **Standard-native relief or required special coverage**: ordinary Unit plus Feature coverage only. Browser proof remains out of scope because layout and clickability are already owned by prior Ops-UX specs.
|
||||
- **Reviewer handoff**: reviewers must confirm that phase/composite labels are operator-safe, the full precedence chain `phased > composite > counted > activity` still holds, counted progress remains gated strictly by `processed` plus `total`, malformed or missing metadata degrades safely, current `404`/`403` visibility semantics remain intact, and excluded run families remain excluded
|
||||
- **Budget / baseline / trend impact**: small feature-local increase only
|
||||
- **Escalation needed**: `reject-or-split` if implementation widens into provider health or support-diagnostics rollout, dashboard work, child-run graph persistence, or a new workflow engine
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/TenantReview/TenantReviewOperationsUxTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/TenantReview/TenantReviewRbacTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See truthful phase progress for baseline capture (Priority: P1)
|
||||
|
||||
As a tenant operator, I need baseline capture to tell me which major phase is active without showing a fake percentage, so I can tell whether the run is still preparing, capturing evidence, persisting a snapshot, or truly stuck.
|
||||
|
||||
**Why this priority**: baseline capture is already a repo-real phase candidate because it persists preflight/runtime eligibility context and full-content evidence-capture stats today.
|
||||
|
||||
**Independent Test**: start a baseline capture through the current service and job path, drive both standard and full-content paths, and verify that active runs expose truthful operator-safe phase labels without rendering a determinate progress bar.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline capture run is queued and the runtime eligibility recheck begins, **When** the run becomes active, **Then** the shared contract exposes an operator-safe preparation or fetch phase instead of generic pending copy.
|
||||
2. **Given** full-content evidence capture is underway, **When** the run reports evidence-capture stats, **Then** the shell shows a phase label for the current work and does not render a counted progress bar unless `processed` and `total` separately satisfy counted semantics.
|
||||
3. **Given** the run blocks, fails, or completes, **When** the run becomes terminal, **Then** terminal outcome remains authoritative and the progress host no longer behaves like active phase progress.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - See truthful phase progress for baseline compare (Priority: P1)
|
||||
|
||||
As a tenant operator, I need baseline compare to expose which major phase is active, so I can distinguish scope preparation, evidence capture, compare execution, and finalization without guessing from a generic running label.
|
||||
|
||||
**Why this priority**: baseline compare already carries repo-real evidence-capture phase stats and resume-token context, which makes it the second clearest non-counted rollout target.
|
||||
|
||||
**Independent Test**: queue a baseline compare that exercises preparation, optional evidence capture, and completion or partial-completion paths, then verify that the run exposes operator-safe phase labels and still avoids counted rendering when only phase truth exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline compare begins with full-content capture enabled, **When** evidence capture or resume processing is active, **Then** the shared contract exposes an operator-safe phase label rather than a generic composite or counted label.
|
||||
2. **Given** compare execution moves from preparation to drift evaluation and persistence, **When** the job updates `OperationRun.context`, **Then** the run shows the current phase truthfully and remains explicitly non-counted.
|
||||
3. **Given** the compare ends partially succeeded, blocked, or failed, **When** the run becomes terminal, **Then** the product drops active phase treatment and keeps terminal outcome plus artifact-truth semantics separate.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - See truthful composite progress for tenant review compose (Priority: P2)
|
||||
|
||||
As a tenant operator, I need tenant review composition to summarize the child operation posture without pretending it is percentage-complete, so I can tell whether the review is aggregating healthy or problematic recent operations before I drill into details.
|
||||
|
||||
**Why this priority**: `tenant.review.compose` already has a canonical run type and repo-real aggregate operation truth through evidence-snapshot sections and review summary data, so it is a bounded composite target without inventing a child-run graph.
|
||||
|
||||
**Independent Test**: create or refresh a tenant review from an evidence snapshot with known operations summary counts, then verify that the active run exposes a composite summary with child counts and failure hints while staying non-counted.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tenant review composition is queued from an evidence snapshot with recent operations summary counts, **When** the run starts, **Then** the shared contract exposes a composite summary that includes the aggregate operation count without rendering a progress bar.
|
||||
2. **Given** the current evidence basis already indicates failed or partial recent operations, **When** the run is active, **Then** the composite summary includes an operator-safe child-state or next-step hint rather than generic `Composite progress pending.` copy.
|
||||
3. **Given** the review composition finishes, **When** the run becomes terminal, **Then** terminal review outcome remains separate from composite progress and no active composite treatment remains.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Preserve non-counted progress boundaries in docs and review artifacts (Priority: P2)
|
||||
|
||||
As a maintainer, I need the package and UI standards to say exactly which runs can claim phase or composite progress and which cannot, so later work does not reopen fake percentages or broaden this slice implicitly.
|
||||
|
||||
**Why this priority**: the value of phase/composite v1 depends on keeping the counted, phased, and composite boundaries explicit after Specs 270 and 271.
|
||||
|
||||
**Independent Test**: review the updated package and UI standards together, then confirm that excluded candidate ideas remain named follow-ups instead of hidden implementation obligations.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a maintainer reads this package after implementation, **When** they review the standards and requirements, **Then** they can tell that `processed` and `total` remain the only determinate progress source and that phase/composite v1 is explicitly non-counted.
|
||||
2. **Given** the original candidate wording mentioned provider health, support diagnostics, review packs, and evidence snapshots, **When** a maintainer reviews the final package, **Then** they can see why those areas stayed outside this slice under current repo truth.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Missing or malformed phase/composite metadata must degrade safely to the current generic `phased`, `composite`, or `activity` behavior rather than failing the host surface.
|
||||
- Counted truth must remain subordinate to current precedence: when selected phase or composite metadata is present, the host must not fall back to a percentage just because `summary_counts` also contains numbers.
|
||||
- Terminal runs may retain phase/composite context for diagnostics, but active progress treatment must stop as soon as the run is no longer currently active.
|
||||
- `tenant.review.compose` with zero or missing operations summary data must degrade to generic activity or minimal composite disclosure rather than inventing a child-state summary.
|
||||
- Provider health and support diagnostics remain out of scope unless a later feature establishes repo-real queued progress truth for those surfaces.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment summary**: This feature adds no new Graph contract, no new persisted entity, no new `OperationRun` lifecycle, and no new notification path. It only extends the existing shared progress contract and selected current job/service writers so non-counted runs can expose truthful phase or composite detail.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The implementation MUST reuse `App\Support\OpsUx\OperationRunProgressContract`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\OperationStatusNormalizer`, and `App\Services\OperationRunService` as the only non-counted progress contract and shared presentation path.
|
||||
- **FR-002**: The in-scope phase run families for v1 are limited to `baseline_capture` and `baseline_compare` under current repo truth.
|
||||
- **FR-003**: The in-scope composite run family for v1 is limited to `tenant.review.compose` under current repo truth.
|
||||
- **FR-004**: Selected phase run families MUST persist canonical phase metadata inside the existing `OperationRun.context` payload with a stable machine-friendly phase key and an operator-safe label. Raw implementation internals MUST NOT be surfaced directly to the host surface.
|
||||
- **FR-005**: The derived phase vocabulary for v1 is limited to operator-safe step families such as `preparing`, `fetching`, `processing`, `persisting`, and `finalizing`. Individual run families may skip phases that do not materially exist in their real workflow, but they MUST NOT invent additional percentage-bearing states.
|
||||
- **FR-006**: Baseline capture MUST surface truthful phase progress for its current lifecycle boundaries, including current eligibility or preparation work, current subject or snapshot work, optional evidence capture when enabled, persistence, and finalization.
|
||||
- **FR-007**: Baseline compare MUST surface truthful phase progress for its current lifecycle boundaries, including current coverage or preparation work, optional evidence capture or resume work, compare execution, persistence, and finalization.
|
||||
- **FR-008**: `tenant.review.compose` MUST surface composite progress from repo-real aggregate truth already available from the current evidence basis or current review summary, including aggregate child count and any current failed or partial child summary or next-step hint that can be derived honestly.
|
||||
- **FR-009**: Composite progress MUST remain explicitly non-counted. Aggregate child counts, failed-child counts, and partial-child counts MUST NOT become a back-door determinate progress bar unless `processed` and `total` separately satisfy the counted contract.
|
||||
- **FR-010**: When selected phase/composite metadata is missing or malformed, the shared contract MUST degrade safely to the current generic `phased`, `composite`, or `activity` behavior instead of failing closed or inventing a new state.
|
||||
- **FR-011**: Current precedence remains unchanged: active `phased` truth wins over `composite`, `composite` wins over counted, counted wins over generic activity, and terminal runs render no active progress treatment.
|
||||
- **FR-012**: This slice MUST NOT add new `summary_counts` keys, new progress capabilities, a new dashboard card, a child-run graph, a workflow engine, or provider health/support-diagnostics progress behavior.
|
||||
- **FR-013**: This slice MUST NOT reopen review-pack or evidence-snapshot progress semantics owned by Spec 271 unless current repo truth proves they still need non-counted treatment in a later follow-up spec.
|
||||
- **FR-014**: The feature MUST update `docs/ui/tenantpilot-enterprise-ui-standards.md` so maintainers can see which current run families may claim phase/composite progress and which families remain activity-only, counted, or deferred.
|
||||
|
||||
### Authorization and Safety Requirements
|
||||
|
||||
- **AR-001**: Existing tenant/admin-plane authorization remains unchanged: non-members and out-of-scope actors stay `404`, while member-but-missing-capability actors stay `403`.
|
||||
- **AR-002**: No in-scope surface may reveal phase/composite progress for a run the current actor cannot already view through existing `OperationRun` policy checks.
|
||||
- **AR-003**: No new destructive or mutating UI action is introduced. Existing baseline and tenant-review start surfaces keep their current authorization and confirmation rules.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: Filament remains v5 on Livewire v4. No panel-provider registration change is allowed; `apps/platform/bootstrap/providers.php` remains authoritative.
|
||||
- **NFR-002**: No new panel, globally searchable resource, or asset-registration strategy is allowed.
|
||||
- **NFR-003**: No new parallel polling loop is allowed. Existing shell and monitoring pollers remain unchanged.
|
||||
- **NFR-004**: Operator-safe phase/composite copy must remain concise, domain-safe, and compatible with the current shell density. It must not become a raw technical trace.
|
||||
- **NFR-005**: The rollout must stay bounded enough that all changed behavior can be proved with focused Unit plus Feature commands rather than a new heavy-governance or browser family.
|
||||
|
||||
## Deferred Follow-Ups / Explicit Non-Goals
|
||||
|
||||
- provider health or support-diagnostics progress rollout once those surfaces have repo-real queued phase/composite truth
|
||||
- review-pack or evidence-snapshot non-counted progress treatment if Spec 271 later proves insufficient
|
||||
- child-run graph persistence through `child_run_ids` or `operation_run_ids`
|
||||
- `273 - Tenant Dashboard Active Operations Summary Card`
|
||||
- any full workflow engine, tray redesign, or AI-generated progress explanation layer
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **Phase Progress Hint**: the canonical operator-safe non-counted progress payload derived from `OperationRun.context` for selected phase-shaped run families.
|
||||
- **Composite Progress Summary**: the canonical operator-safe aggregate child summary derived from existing current tenant-review operation truth without implying a percentage.
|
||||
- **Selected Phase Run Family**: a currently repo-real run family, such as baseline capture or baseline compare, whose execution truth is best described by current phases rather than determinate counts.
|
||||
- **Selected Composite Run Family**: a currently repo-real run family, such as tenant review compose, whose execution truth is best described by aggregate child state rather than determinate counts.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
- **SC-001**: Focused Unit coverage proves that selected phase and composite metadata produce operator-safe non-counted labels while current counted precedence still works only when truthful `processed` and `total` exist.
|
||||
- **SC-002**: Focused shell Feature coverage proves that baseline capture and baseline compare show meaningful non-counted phase labels without rendering a progress bar in the covered active scenarios.
|
||||
- **SC-003**: Focused Feature coverage proves that `tenant.review.compose` can expose a truthful composite summary from current aggregate operation truth without rendering a counted percentage.
|
||||
- **SC-004**: Missing or malformed metadata degrades safely to current generic fallback behavior in 100% of covered scenarios.
|
||||
- **SC-005**: The UI standards and Spec Kit artifacts document one canonical non-counted progress contract and explicitly keep provider health, support diagnostics, review-pack overlap, dashboard work, and workflow-engine ideas out of this slice.
|
||||
|
||||
## Candidate Selection Rationale
|
||||
|
||||
- **Selected candidate**: OperationRun Phase & Composite Progress v1
|
||||
- **Source locations**:
|
||||
- `docs/product/spec-candidates.md`
|
||||
- `docs/product/roadmap.md`
|
||||
- `specs/270-operationrun-progress-contract/spec.md`
|
||||
- `specs/271-counted-progress-rollout/spec.md`
|
||||
- `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`
|
||||
- `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- `apps/platform/app/Jobs/ComposeTenantReviewJob.php`
|
||||
- `apps/platform/app/Services/TenantReviews/TenantReviewService.php`
|
||||
- **Why selected**: the active automatic prep queue is intentionally empty, the user explicitly chose to promote `272`, and the repo already contains the exact truth gap named by the candidate: real phase-shaped baseline context and real tenant-review aggregate operation counts currently terminate in generic fallback labels.
|
||||
- **Why this is the smallest viable implementation slice**: v1 stays on the existing shared progress contract, selected current run families, and one standards update. It explicitly avoids a workflow engine, dashboard work, provider health/support rollout, and child-run persistence.
|
||||
- **Why close alternatives were deferred**:
|
||||
- review-pack and evidence-snapshot progress remain owned by `specs/271-counted-progress-rollout/` because those families already have deterministic counted work units under current repo truth
|
||||
- provider health and support diagnostics do not currently expose equivalent queued phase/composite progress truth through `OperationRun`
|
||||
- `273 - Tenant Dashboard Active Operations Summary Card` remains a separate dashboard concern and should not be reopened here
|
||||
|
||||
## Related-Spec Guardrail Check
|
||||
|
||||
- `specs/270-operationrun-progress-contract/`: immediate prerequisite. This package extends the shared contract rather than replacing it.
|
||||
- `specs/271-counted-progress-rollout/`: adjacent counted-rollout package. This package must not reopen stable-unit counted families or change the existing precedence between counted and non-counted modes.
|
||||
- `specs/268-operationrun-activity-feedback/`: existing shell feedback owner. This package may refine the meaning of active progress labels but must not reopen the shell's terminal outcome, dismissal, or browser-surface contract.
|
||||
- `specs/159-baseline-snapshot-truth/` and `specs/164-run-detail-hardening/`: inherited baseline truth only. This package may not blur artifact truth and execution truth or create a second run-detail semantics layer.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 270 remains the authoritative shared progress contract and keeps current precedence intact.
|
||||
- Current baseline capture and compare jobs already own the real lifecycle boundaries that phase v1 should describe; the slice only makes those boundaries operator-safe and canonical.
|
||||
- Current evidence snapshot operations summary is available early enough in tenant-review queueing to seed a bounded composite summary without inventing a child-run graph.
|
||||
|
||||
## Risks
|
||||
|
||||
- Baseline phase labels can become noisy or technical if the implementation leaks raw internal step names instead of the bounded operator-safe vocabulary.
|
||||
- `tenant.review.compose` may not always have enough early aggregate truth to justify a composite summary; that path must degrade safely instead of guessing.
|
||||
- Touching both baseline jobs and tenant-review composition raises the risk of accidental scope widening into dashboard, report, or provider-specific progress work.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking safe implementation. If a selected run family cannot produce stable operator-safe non-counted detail without speculative copy, that family must drop back to current generic fallback behavior rather than widening the slice.
|
||||
192
specs/272-operationrun-phase-composite-progress/tasks.md
Normal file
192
specs/272-operationrun-phase-composite-progress/tasks.md
Normal file
@ -0,0 +1,192 @@
|
||||
---
|
||||
description: "Task list for OperationRun Phase & Composite Progress v1"
|
||||
---
|
||||
|
||||
# Tasks: OperationRun Phase & Composite Progress v1
|
||||
|
||||
**Input**: Design documents from `specs/272-operationrun-phase-composite-progress/`
|
||||
**Prerequisites**: `specs/272-operationrun-phase-composite-progress/spec.md`, `specs/272-operationrun-phase-composite-progress/plan.md`, `specs/272-operationrun-phase-composite-progress/checklists/requirements.md`
|
||||
|
||||
**Review Artifact**: `specs/272-operationrun-phase-composite-progress/checklists/requirements.md` is the outcome-of-record for the review outcome class, workflow outcome, and test-governance outcome. If implementation widens into provider health or support-diagnostics rollout, review-pack overlap, dashboard work, child-run graph persistence, or a workflow engine, update that artifact before continuing.
|
||||
|
||||
**Tests**: REQUIRED (Pest). Keep proof bounded to existing Unit plus Feature suites for Ops-UX, Baselines, TenantReview, and current run-detail compatibility. Browser coverage remains owned by prior Ops-UX specs and must not become a hidden requirement here.
|
||||
**Operations**: No new `OperationRun` type, no new lifecycle family, no fake percentages, no workflow engine, no new `summary_counts` keys, no dashboard card, no provider health or support-diagnostics rollout, and no child-run graph persistence.
|
||||
**RBAC**: Reuse current `OperationRun` policies, baseline capabilities, and tenant-review capabilities. No tenantless leakage from tenant surfaces; non-counted progress must remain invisible for inaccessible runs.
|
||||
**Shared Pattern Reuse**: Reuse `OperationRunProgressContract`, `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunService`, and `docs/ui/tenantpilot-enterprise-ui-standards.md`. Do not create a second local progress translator in Blade, Livewire, or domain jobs.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, resource, global-search behavior, or asset strategy is allowed. This slice changes current progress truth only.
|
||||
**Organization**: Tasks are grouped by user story so baseline capture, baseline compare, tenant-review composite progress, and the future-boundary documentation remain independently reviewable.
|
||||
|
||||
## Test Governance Notes
|
||||
|
||||
- Lane mix stays Unit plus Feature.
|
||||
- Prefer extending current Ops-UX, Baselines, and TenantReview suites before creating a new family.
|
||||
- Browser proof stays with prior Ops-UX specs and must not become a hidden requirement here.
|
||||
- Validation commands must stay file-scoped and run through Sail.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: confirm the bounded manual-promotion slice, the inherited progress-contract rules, and the repo-real phase/composite seams before runtime edits begin.
|
||||
|
||||
- [x] T001 Review `specs/272-operationrun-phase-composite-progress/spec.md`, `specs/272-operationrun-phase-composite-progress/plan.md`, `specs/272-operationrun-phase-composite-progress/checklists/requirements.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/270-operationrun-progress-contract/spec.md`, `specs/271-counted-progress-rollout/spec.md`, `specs/268-operationrun-activity-feedback/spec.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, and `.specify/memory/constitution.md` together so the slice stays on repo-real non-counted progress and keeps counted overlap plus dashboard work explicitly out of scope.
|
||||
- [x] T002 [P] Confirm the current shared contract and host seams in `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php`, and the current shell progress host proofs in `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php` and `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`.
|
||||
- [x] T003 [P] Confirm the current baseline phase-writer seams in `apps/platform/app/Services/Baselines/BaselineCaptureService.php`, `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`, `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, and `apps/platform/app/Services/Baselines/BaselineContentCapturePhase.php`.
|
||||
- [x] T004 [P] Confirm the current tenant-review composite seams and explicit exclusions in `apps/platform/app/Services/TenantReviews/TenantReviewService.php`, `apps/platform/app/Jobs/ComposeTenantReviewJob.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and the related proof owner `apps/platform/tests/Feature/TenantReview/TenantReviewOperationsUxTest.php`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: settle the shared contract boundary, the selected non-counted vocabulary, and the focused proof owners before user-story runtime work begins.
|
||||
|
||||
**Critical**: no user-story runtime work should begin until this phase is complete.
|
||||
|
||||
- [x] T005 [P] Create or extend failing coverage in `apps/platform/tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php` for operator-safe phase/composite label derivation, malformed-metadata fallback, and the rule that current counted progress remains subordinate to truthful phase/composite precedence.
|
||||
- [x] T006 [P] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` and `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` only as needed so the shell proves selected phase/composite labels render without a progress bar and the full precedence chain `phased > composite > counted > activity` still holds, with counted mode available only when truthful `processed` and `total` exist.
|
||||
- [x] T007 [P] Extend `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` only if presenter or run-detail disclosure changes require proof that operator-safe phase/composite copy stays separate from terminal artifact truth and detailed diagnostics.
|
||||
- [x] T008 [P] Review or extend `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewRbacTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` so current `404`/`403` semantics, inaccessible-run invisibility, and unchanged confirmation behavior remain explicit proof owners for this slice.
|
||||
|
||||
**Checkpoint**: the shared contract boundary and focused proof owners are settled before implementation begins.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See truthful phase progress for baseline capture (Priority: P1)
|
||||
|
||||
**Goal**: baseline capture exposes operator-safe phase truth for current lifecycle boundaries without inventing a percentage.
|
||||
|
||||
**Independent Test**: start baseline capture through the current service/job path, exercise current standard and full-content branches, and verify the active run exposes truthful phase labels while remaining explicitly non-counted.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T009 [P] [US1] Extend `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php` and `apps/platform/tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php` for phase metadata seeding at preflight or runtime-recheck, current subject or snapshot work, optional evidence capture, persistence, and finalization.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T010 [US1] Update `apps/platform/app/Services/Baselines/BaselineCaptureService.php` and `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` only as needed so `baseline_capture` writes canonical phase key and operator-safe label details into existing `OperationRun.context` as work moves through current lifecycle boundaries.
|
||||
- [x] T011 [US1] Review blocked, resumed, failed, and completed capture paths so terminal runs retain diagnostic context without leaving stale active-phase truth and so missing phase metadata degrades safely to the current generic fallback.
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when baseline capture can describe its current phase truthfully without ever rendering fake counted progress.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - See truthful phase progress for baseline compare (Priority: P1)
|
||||
|
||||
**Goal**: baseline compare exposes operator-safe phase truth for current preparation, evidence-capture, compare, persistence, and finalization boundaries without inventing a percentage.
|
||||
|
||||
**Independent Test**: queue a baseline compare that exercises current preparation and optional evidence-capture branches, then verify the run exposes truthful phase labels and remains explicitly non-counted.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T012 [P] [US2] Extend `apps/platform/tests/Feature/Baselines/BaselineCompareResumeTokenTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php` only as needed for phase metadata around current preparation, evidence capture or resume, compare execution, persistence, and finalization.
|
||||
- [x] T013 [P] [US2] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` or `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` only as needed so baseline compare phase labels remain operator-safe and explicitly non-counted on visible surfaces.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T014 [US2] Update `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` and any immediately adjacent baseline helper only as needed so `baseline_compare` publishes canonical phase key and operator-safe label detail for current preparation, evidence capture or resume, compare execution, persistence, and finalization.
|
||||
- [x] T015 [US2] Review partial, blocked, failed, and malformed-context compare paths so phase detail degrades safely when metadata is absent and never overrides terminal outcome or artifact-truth semantics.
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when baseline compare can describe its current phase truthfully without falling back to fake counted progress.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - See truthful composite progress for tenant review compose (Priority: P2)
|
||||
|
||||
**Goal**: `tenant.review.compose` exposes a bounded composite summary from current aggregate operation truth without pretending it is percentage progress.
|
||||
|
||||
**Independent Test**: create or refresh a tenant review from an evidence snapshot with known operations summary counts, then verify the active run exposes a truthful composite label with aggregate child hints while remaining non-counted.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T016 [P] [US3] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewOperationsUxTest.php` and current shell proof owners only as needed for composite summary labels derived from evidence-basis operation counts plus current failed or partial child hints, including safe fallback when aggregate truth is zero, missing, or too weak for composite disclosure.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T017 [US3] Update `apps/platform/app/Services/TenantReviews/TenantReviewService.php`, `apps/platform/app/Jobs/ComposeTenantReviewJob.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` only as needed so `tenant.review.compose` seeds bounded composite summary metadata from the current evidence snapshot operations summary before running work and finalizes it after composition completes, while degrading safely to generic activity/composite fallback when that aggregate truth is absent.
|
||||
- [x] T018 [US3] Keep composite v1 bounded to `tenant.review.compose` and current aggregate summary fields; record provider health/support diagnostics, review-pack or evidence-snapshot overlap, and child-run link expansion as follow-up explicitly instead of widening the implementation slice.
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when `tenant.review.compose` can summarize current aggregate operation posture truthfully without rendering counted progress.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Preserve non-counted progress boundaries in docs and review artifacts (Priority: P2)
|
||||
|
||||
**Goal**: future contributors can extend non-counted progress without silently reopening fake percentages, workflow-engine drift, or excluded run families.
|
||||
|
||||
**Independent Test**: review the standards update and the completed proof list together, then confirm that counted overlap, provider health/support diagnostics, dashboard work, and child-run graph persistence remain named follow-ups rather than hidden scope here.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T019 [US4] Update `docs/ui/tenantpilot-enterprise-ui-standards.md` with the current phase/composite rules: selected families only, operator-safe non-technical labels, counted precedence unchanged, composite summaries explicitly non-counted, and excluded candidate ideas named as later follow-ups.
|
||||
- [x] T020 [US4] Review the resulting package and touched code to confirm there is still no workflow engine, no dashboard card, no provider health/support-diagnostics rollout, no review-pack or evidence-snapshot overlap, no child-run graph persistence, and no new `summary_counts` key.
|
||||
|
||||
**Checkpoint**: User Story 4 is independently functional when future-extension boundaries are explicit in both docs and the feature package.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: validate the bounded slice, stop drift, and hand off a clean implementation path.
|
||||
|
||||
- [x] T021 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/OperationRunProgressContractTest.php`.
|
||||
- [x] T022 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/TenantReview/TenantReviewOperationsUxTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/TenantReview/TenantReviewRbacTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`.
|
||||
- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
|
||||
- [x] T024 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no new assets were registered, no new globally searchable resource behavior was introduced, no new parallel polling loop was added, and the full precedence chain `phased > composite > counted > activity` still holds with counted mode requiring truthful `processed` plus `total`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks user-story work.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the clearest repo-real phase rollout target.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should land after or alongside US1 so both baseline phase families share one operator-safe vocabulary.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and should land after the baseline phase path so composite v1 reuses the same non-counted contract discipline.
|
||||
- **Phase 6 (US4)**: depends on Phases 3 through 5 so the documented boundary matches the implemented slice.
|
||||
- **Phase 7 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and delivers the clearest immediate value because baseline capture already persists repo-real phase truth.
|
||||
- **US2 (P1)**: independently testable after Phase 2 and completes the baseline non-counted execution pair.
|
||||
- **US3 (P2)**: independently testable after Phase 2 and completes the approved composite v1 slice for current aggregate tenant-review work.
|
||||
- **US4 (P2)**: independently testable after Phases 3 through 5 and is required for package completion because the narrowed `271`/`272` boundary is part of the approved scope.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or extend the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Land the writer-side metadata changes before adjusting any shared host assertion that depends on that new truth.
|
||||
- Re-run the narrowest affected validation command after each story checkpoint before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2**, because the first enterprise-visible value arrives once the current baseline capture and compare families can show truthful non-counted phase progress through the shared contract.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1.
|
||||
3. Deliver US2.
|
||||
4. Deliver US3.
|
||||
5. Land US4 documentation and boundary hardening.
|
||||
6. Finish with focused validation and formatting.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the shared contract boundary and proof owners first.
|
||||
2. Keep baseline capture, baseline compare, and tenant-review composite edits serialized per family.
|
||||
3. Do not widen into provider health/support progress, review-pack overlap, dashboard work, or a workflow engine while implementing this package.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- provider health or support-diagnostics progress rollout
|
||||
- review-pack or evidence-snapshot non-counted progress overlap with Spec 271
|
||||
- child-run graph persistence through `child_run_ids` or `operation_run_ids`
|
||||
- `273 - Tenant Dashboard Active Operations Summary Card`
|
||||
- any workflow-engine or AI-generated progress explanation layer
|
||||
@ -0,0 +1,54 @@
|
||||
# Specification Quality Checklist: Billing & Subscription Truth Layer v1
|
||||
|
||||
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||
**Created**: 2026-05-04
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The package stays on one bounded subscription truth follow-through over Specs 247 and 251 instead of inventing a billing engine, invoice layer, or customer portal.
|
||||
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
|
||||
- [x] The package explicitly names the repo-real anchors it builds on: `WorkspaceEntitlementResolver`, `WorkspaceCommercialLifecycleResolver`, `ViewWorkspace`, `WorkspaceSettings`, onboarding activation, and review-pack generation.
|
||||
- [x] Mandatory repo sections for scope, RBAC, shared-pattern reuse, testing, proportionality, and candidate rationale are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No `[NEEDS CLARIFICATION]` markers remain.
|
||||
- [x] Requirements are testable and bounded to one current subscription record, one resolver, existing lifecycle gating, one system mutation surface, and one read-only admin summary.
|
||||
- [x] The package makes fallback behavior explicit for workspaces without a subscription record.
|
||||
- [x] The package forbids a second runtime gate and keeps onboarding or review-pack behavior on the existing lifecycle resolver.
|
||||
- [x] Canonical proof commands match across `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and `docs/product/roadmap.md` as `Billing & Subscription Truth Layer v1`.
|
||||
- [x] Related anchor specs were checked for completion or close-out signals and treated as context only: Specs 247 and 251 are already repo-real and implemented.
|
||||
- [x] The chosen slice is smaller and more bounded than deferred alternatives such as provider sync, invoices, or a customer billing portal.
|
||||
- [x] The selected slice explicitly closes the remaining manual-promotion gap between plan or lifecycle truth and durable subscription truth.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The package justifies a new persisted entity and explains why more workspace-setting keys are insufficient.
|
||||
- [x] The package keeps Filament on Livewire v4, provider registration unchanged in `apps/platform/bootstrap/providers.php`, global search unchanged, and assets unchanged.
|
||||
- [x] The package keeps `/system` as the mutation plane and `/admin` read-only for subscription truth.
|
||||
- [x] The package forbids provider-specific billing semantics, invoices, checkout, and portal scope.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned proof stays bounded to one new `Unit` family plus focused extensions to existing `Feature` suites.
|
||||
- [x] No new heavy-governance or browser family is introduced by default.
|
||||
- [x] Fixture growth remains bounded to one new factory plus existing workspace, onboarding, and review-pack helpers.
|
||||
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into `plan.md` and `tasks.md`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/247-plans-entitlements-billing-readiness/spec.md`, `specs/251-commercial-entitlements-billing-state/spec.md`, current entitlement and lifecycle code under `apps/platform`, and the active 274 prep artifacts on 2026-05-04.
|
||||
- No application implementation was performed while preparing this package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Reason**: The package promotes the remaining commercial manual-promotion gap as one bounded source-of-truth follow-through. It keeps lifecycle as the only runtime gate, adds only one current subscription entity, and stays off provider, invoice, and portal scope.
|
||||
- **Workflow result**: Ready for implementation.
|
||||
@ -0,0 +1,481 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Admin/System - Workspace Billing & Subscription Truth (Conceptual)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual contract for the bounded billing and subscription truth follow-through
|
||||
over existing entitlement and commercial lifecycle foundations.
|
||||
|
||||
NOTE: These routes are implemented as existing Filament pages, resources,
|
||||
widgets, and Livewire-backed actions. Exact Livewire payload shapes are not
|
||||
part of this contract. This file captures logical route boundaries, plane
|
||||
separation, and the requirement that runtime gating still flows through the
|
||||
shared commercial lifecycle resolver.
|
||||
paths:
|
||||
/directory/workspaces/{workspace}:
|
||||
servers:
|
||||
- url: /system
|
||||
get:
|
||||
summary: View current subscription truth and derived commercial posture in the system plane
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
responses:
|
||||
'200':
|
||||
description: System workspace detail rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/SystemWorkspaceSubscriptionView'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
/directory/workspaces/{workspace}/actions/update-subscription-truth:
|
||||
servers:
|
||||
- url: /system
|
||||
post:
|
||||
summary: Create or update the current workspace subscription truth from the system plane
|
||||
description: |
|
||||
Conceptual contract for the confirmation-protected system action that
|
||||
creates or updates one current workspace subscription record.
|
||||
|
||||
Behavior:
|
||||
- Platform user with directory visibility but without the dedicated
|
||||
commercial-management capability: 403
|
||||
- Wrong plane or non-platform actor: 404 semantics at the panel boundary
|
||||
- Authorized platform user: writes the current subscription record and
|
||||
audit trail, then updates the derived lifecycle source used by current
|
||||
onboarding and review-pack flows
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateSubscriptionTruthCommand'
|
||||
responses:
|
||||
'204':
|
||||
description: Current subscription truth updated successfully
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
/directory/workspaces/{workspace}/actions/change-commercial-state:
|
||||
servers:
|
||||
- url: /system
|
||||
post:
|
||||
summary: Change the fallback manual commercial lifecycle state when no subscription record exists
|
||||
description: |
|
||||
Conceptual contract for the existing manual lifecycle action after this
|
||||
slice lands.
|
||||
|
||||
Behavior:
|
||||
- available only when the workspace has no current subscription record
|
||||
- remains confirmation-protected
|
||||
- preserves the current settings-backed fallback semantics from Spec 251
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- reason
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Fallback lifecycle state changed successfully
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
description: Current subscription truth exists, so fallback mutation is unavailable
|
||||
/settings/workspace:
|
||||
servers:
|
||||
- url: /admin
|
||||
get:
|
||||
summary: View a read-only subscription-backed commercial summary on the admin workspace settings page
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace settings page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/SettingsSubscriptionSummaryView'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
/onboarding/{onboardingDraft}:
|
||||
servers:
|
||||
- url: /admin
|
||||
get:
|
||||
summary: View onboarding workflow with subscription-backed lifecycle gating when a subscription exists
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OnboardingDraftId'
|
||||
responses:
|
||||
'200':
|
||||
description: Onboarding wizard rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/LifecycleDecisionView'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
/onboarding/{onboardingDraft}/actions/complete:
|
||||
servers:
|
||||
- url: /admin
|
||||
post:
|
||||
summary: Complete onboarding when entitlement substrate and derived lifecycle both allow it
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OnboardingDraftId'
|
||||
responses:
|
||||
'204':
|
||||
description: Onboarding completed
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/BusinessStateBlocked'
|
||||
/review-packs/actions/generate:
|
||||
servers:
|
||||
- url: /admin
|
||||
post:
|
||||
summary: Generate a review pack through the existing shared lifecycle gate
|
||||
description: |
|
||||
Behavior ordering:
|
||||
1. authorization
|
||||
2. underlying entitlement substrate decision
|
||||
3. derived lifecycle decision, potentially sourced from current subscription truth
|
||||
4. existing dedupe or queued-start flow when allowed
|
||||
|
||||
A blocked attempt creates no new `ReviewPack`, creates no new
|
||||
`OperationRun`, and emits no queued or terminal review-pack notification.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReviewPackGenerationCommand'
|
||||
responses:
|
||||
'202':
|
||||
description: Generation accepted or deduped through the existing flow
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/BusinessStateBlocked'
|
||||
/tenant-reviews/{tenantReview}/actions/export-executive-pack:
|
||||
servers:
|
||||
- url: /admin
|
||||
post:
|
||||
summary: Export an executive pack through the existing shared lifecycle gate
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantReviewId'
|
||||
responses:
|
||||
'202':
|
||||
description: Export accepted or deduped through the existing flow
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/BusinessStateBlocked'
|
||||
/review-packs/{reviewPack}/actions/regenerate:
|
||||
servers:
|
||||
- url: /admin
|
||||
post:
|
||||
summary: Regenerate a review pack through the existing shared lifecycle gate
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ReviewPackId'
|
||||
responses:
|
||||
'202':
|
||||
description: Regeneration accepted or deduped through the existing flow
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/BusinessStateBlocked'
|
||||
components:
|
||||
parameters:
|
||||
WorkspaceId:
|
||||
name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
OnboardingDraftId:
|
||||
name: onboardingDraft
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
TenantReviewId:
|
||||
name: tenantReview
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
ReviewPackId:
|
||||
name: reviewPack
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
Forbidden:
|
||||
description: Actor is in-scope but missing the required capability
|
||||
NotFound:
|
||||
description: Wrong plane or non-member access is hidden as not found
|
||||
ValidationError:
|
||||
description: Submitted subscription truth is invalid for the chosen state
|
||||
BusinessStateBlocked:
|
||||
description: The actor is otherwise authorized, but the derived lifecycle blocks the action
|
||||
schemas:
|
||||
UpdateSubscriptionTruthCommand:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/TrialSubscriptionCommand'
|
||||
- $ref: '#/components/schemas/ActiveSubscriptionCommand'
|
||||
- $ref: '#/components/schemas/PastDueSubscriptionCommand'
|
||||
- $ref: '#/components/schemas/CancelAtPeriodEndSubscriptionCommand'
|
||||
- $ref: '#/components/schemas/EndedSubscriptionCommand'
|
||||
TrialSubscriptionCommand:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- trial_ends_at
|
||||
- status_reason
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- trial
|
||||
billing_reference:
|
||||
type: string
|
||||
nullable: true
|
||||
trial_ends_at:
|
||||
type: string
|
||||
format: date-time
|
||||
status_reason:
|
||||
type: string
|
||||
ActiveSubscriptionCommand:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- current_period_starts_at
|
||||
- current_period_ends_at
|
||||
- status_reason
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- active
|
||||
billing_reference:
|
||||
type: string
|
||||
nullable: true
|
||||
current_period_starts_at:
|
||||
type: string
|
||||
format: date-time
|
||||
current_period_ends_at:
|
||||
type: string
|
||||
format: date-time
|
||||
status_reason:
|
||||
type: string
|
||||
PastDueSubscriptionCommand:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- current_period_starts_at
|
||||
- current_period_ends_at
|
||||
- status_reason
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- past_due
|
||||
billing_reference:
|
||||
type: string
|
||||
nullable: true
|
||||
current_period_starts_at:
|
||||
type: string
|
||||
format: date-time
|
||||
current_period_ends_at:
|
||||
type: string
|
||||
format: date-time
|
||||
status_reason:
|
||||
type: string
|
||||
CancelAtPeriodEndSubscriptionCommand:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- current_period_starts_at
|
||||
- current_period_ends_at
|
||||
- status_reason
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- cancel_at_period_end
|
||||
billing_reference:
|
||||
type: string
|
||||
nullable: true
|
||||
current_period_starts_at:
|
||||
type: string
|
||||
format: date-time
|
||||
current_period_ends_at:
|
||||
type: string
|
||||
format: date-time
|
||||
status_reason:
|
||||
type: string
|
||||
EndedSubscriptionCommand:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- current_period_ends_at
|
||||
- status_reason
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- ended
|
||||
billing_reference:
|
||||
type: string
|
||||
nullable: true
|
||||
current_period_ends_at:
|
||||
type: string
|
||||
format: date-time
|
||||
status_reason:
|
||||
type: string
|
||||
WorkspaceSubscriptionSummary:
|
||||
type: object
|
||||
required:
|
||||
- source
|
||||
- fallback_status
|
||||
- subscription_present
|
||||
- derived_lifecycle_state
|
||||
- needs_review
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- workspace_subscription
|
||||
- workspace_setting
|
||||
- default_active_paid
|
||||
fallback_status:
|
||||
type: boolean
|
||||
subscription_present:
|
||||
type: boolean
|
||||
state:
|
||||
type: string
|
||||
nullable: true
|
||||
label:
|
||||
type: string
|
||||
nullable: true
|
||||
billing_reference:
|
||||
type: string
|
||||
nullable: true
|
||||
status_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
key_date_label:
|
||||
type: string
|
||||
nullable: true
|
||||
key_date:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
needs_review:
|
||||
type: boolean
|
||||
derived_lifecycle_state:
|
||||
type: string
|
||||
SystemWorkspaceSubscriptionView:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/WorkspaceSubscriptionSummary'
|
||||
- type: object
|
||||
properties:
|
||||
last_changed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_changed_by:
|
||||
type: string
|
||||
nullable: true
|
||||
SettingsSubscriptionSummaryView:
|
||||
type: object
|
||||
required:
|
||||
- source
|
||||
- derived_lifecycle_state
|
||||
- fallback_status
|
||||
- state_label
|
||||
- explanation
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- workspace_subscription
|
||||
- workspace_setting
|
||||
- default_active_paid
|
||||
derived_lifecycle_state:
|
||||
type: string
|
||||
state_label:
|
||||
type: string
|
||||
explanation:
|
||||
type: string
|
||||
fallback_status:
|
||||
type: boolean
|
||||
key_date_label:
|
||||
type: string
|
||||
nullable: true
|
||||
key_date:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
LifecycleDecisionView:
|
||||
type: object
|
||||
required:
|
||||
- lifecycle_state
|
||||
- source
|
||||
- outcome
|
||||
- explanation
|
||||
properties:
|
||||
lifecycle_state:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- workspace_subscription
|
||||
- workspace_setting
|
||||
- default_active_paid
|
||||
outcome:
|
||||
type: string
|
||||
enum:
|
||||
- allow
|
||||
- warn
|
||||
- block
|
||||
explanation:
|
||||
type: string
|
||||
ReviewPackGenerationCommand:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
172
specs/274-billing-subscription-truth/data-model.md
Normal file
172
specs/274-billing-subscription-truth/data-model.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Data Model: Billing & Subscription Truth Layer v1
|
||||
|
||||
**Date**: 2026-05-04
|
||||
**Branch**: `274-billing-subscription-truth`
|
||||
|
||||
## Overview
|
||||
|
||||
This slice adds one new workspace-owned source of truth: a current subscription record. Existing plan profiles, entitlement overrides, and manual commercial lifecycle fallback remain in their current storage. The new record feeds the existing commercial lifecycle resolver when present.
|
||||
|
||||
## Persisted Truth
|
||||
|
||||
### 1. Workspace Subscription Aggregate
|
||||
|
||||
**Persistence**: New `workspace_subscriptions` table
|
||||
**Ownership**: Workspace-owned
|
||||
**Scope**: One current record per workspace
|
||||
|
||||
| Field | Type | Nullable | Validation | Notes |
|
||||
|-------|------|----------|------------|-------|
|
||||
| `id` | bigint | no | primary key | Internal record id |
|
||||
| `workspace_id` | bigint | no | foreign key, unique | Enforces one current subscription record per workspace |
|
||||
| `state` | string | no | must be one of `trial`, `active`, `past_due`, `cancel_at_period_end`, `ended` | Current subscription posture |
|
||||
| `billing_reference` | string | yes | trimmed, max 191 chars | Optional contract, subscription, or invoice reference label |
|
||||
| `trial_ends_at` | datetime | yes | required when `state=trial` | Current trial end date |
|
||||
| `current_period_starts_at` | datetime | yes | required when `state` is `active`, `past_due`, or `cancel_at_period_end` | Current commercial period start |
|
||||
| `current_period_ends_at` | datetime | yes | required when `state` is `active`, `past_due`, `cancel_at_period_end`, or `ended` | Current commercial period end or ended-on boundary |
|
||||
| `status_reason` | text | no | required on every explicit mutation path | Operator-visible explanation |
|
||||
| `created_at` | datetime | no | standard timestamps | Creation time |
|
||||
| `updated_at` | datetime | no | standard timestamps | Latest mutation time |
|
||||
|
||||
**Write rules**:
|
||||
|
||||
- Mutation happens from the system plane only.
|
||||
- `workspace_id` is immutable once the row exists.
|
||||
- The record is updated in place in v1; no historical row chain is created.
|
||||
- Audit history captures before and after values and actor attribution.
|
||||
|
||||
**Relationships**:
|
||||
|
||||
- `workspace_subscriptions.workspace_id` references `workspaces.id`.
|
||||
- `Workspace` gains a singular subscription relationship.
|
||||
|
||||
## Existing Persisted Truth Reused
|
||||
|
||||
### 2. Workspace Entitlement Substrate
|
||||
|
||||
**Persistence**: Existing `workspace_settings` rows plus code-owned plan catalog
|
||||
**Owner**: `WorkspaceEntitlementResolver`
|
||||
|
||||
This slice does not remodel:
|
||||
|
||||
- plan profile selection
|
||||
- first-slice entitlement overrides
|
||||
- first-slice entitlement usage summaries
|
||||
|
||||
These remain the substrate that lifecycle may restrict after subscription mapping.
|
||||
|
||||
### 3. Manual Lifecycle Fallback
|
||||
|
||||
**Persistence**: Existing `workspace_settings` rows from Spec 251
|
||||
**Owner**: `WorkspaceCommercialLifecycleResolver`
|
||||
|
||||
Manual lifecycle state remains valid only as fallback when a workspace does not yet have a current subscription record.
|
||||
|
||||
## Code-Owned Truth
|
||||
|
||||
### 4. Subscription State Catalog Entry
|
||||
|
||||
**Persistence**: none, code-owned
|
||||
**Ownership**: product runtime configuration
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `id` | string | yes | Stable internal identifier |
|
||||
| `label` | string | yes | Operator-facing label |
|
||||
| `description` | string | yes | Short explanation for system and settings summaries |
|
||||
| `derived_lifecycle_state` | string | yes | One of the existing Spec 251 lifecycle states |
|
||||
| `needs_review_when_past_date` | bool | yes | Whether the record should surface explicit review-required wording when its key date is in the past |
|
||||
|
||||
**Behavior matrix**:
|
||||
|
||||
| Subscription state | Derived lifecycle state | Key date surfaced | Notes |
|
||||
|--------------------|-------------------------|-------------------|-------|
|
||||
| `trial` | `trial` | `trial_ends_at` | Current trial posture |
|
||||
| `active` | `active_paid` | `current_period_ends_at` | Current paid period |
|
||||
| `past_due` | `grace` | `current_period_ends_at` | Commercial grace posture |
|
||||
| `cancel_at_period_end` | `active_paid` | `current_period_ends_at` | Still active, but cancellation is pending |
|
||||
| `ended` | `suspended_read_only` | `current_period_ends_at` | Commercial access has ended |
|
||||
|
||||
## Derived Truth
|
||||
|
||||
### 5. Workspace Subscription Summary
|
||||
|
||||
**Persistence**: none, derived at runtime
|
||||
**Owner**: `WorkspaceSubscriptionResolver`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `workspace_id` | int | yes | Workspace being evaluated |
|
||||
| `subscription_present` | bool | yes | Whether a current record exists |
|
||||
| `state` | string | no | Current subscription state when present |
|
||||
| `label` | string | no | Operator-facing state label |
|
||||
| `billing_reference` | string | no | Optional reference |
|
||||
| `status_reason` | string | no | Operator-visible explanation |
|
||||
| `key_date_label` | string | no | `Trial ends` or `Current period ends` |
|
||||
| `key_date` | datetime | no | Current relevant date |
|
||||
| `needs_review` | bool | yes | True when a date-sensitive state is past its visible date |
|
||||
| `source` | string | yes | One of `workspace_subscription`, `workspace_setting`, or `default_active_paid` |
|
||||
| `fallback_status` | bool | yes | True when the summary is not backed by a current subscription record |
|
||||
| `derived_lifecycle_state` | string | yes | Existing lifecycle state consumed downstream |
|
||||
|
||||
### 6. Effective Commercial Lifecycle Decision
|
||||
|
||||
**Persistence**: none, derived at runtime
|
||||
**Owner**: existing `WorkspaceCommercialLifecycleResolver`
|
||||
|
||||
The lifecycle decision remains the shared gate shape from Spec 251, but its source changes:
|
||||
|
||||
- If a subscription record exists, the lifecycle source becomes `workspace_subscription`.
|
||||
- If no subscription record exists, the current `workspace_setting` or `default_active_paid` source remains.
|
||||
|
||||
**Ordering rules**:
|
||||
|
||||
1. Resolve the underlying entitlement substrate.
|
||||
2. Resolve the lifecycle source from subscription truth when present, otherwise from fallback manual lifecycle truth.
|
||||
3. If the substrate already blocks the action, keep the substrate block.
|
||||
4. If the substrate allows the action, apply the lifecycle outcome from the resolved lifecycle state.
|
||||
|
||||
## Supporting Derived View Models
|
||||
|
||||
### 7. System Workspace Subscription View Model
|
||||
|
||||
**Consumer**: `ViewWorkspace`
|
||||
|
||||
Contains:
|
||||
|
||||
- current subscription summary
|
||||
- derived lifecycle summary
|
||||
- fallback indicator when no subscription exists
|
||||
- last-change attribution
|
||||
- mutation affordance metadata for `Update subscription truth`
|
||||
|
||||
### 8. Workspace Settings Subscription Summary View Model
|
||||
|
||||
**Consumer**: `WorkspaceSettings`
|
||||
|
||||
Contains:
|
||||
|
||||
- current commercial posture
|
||||
- whether it is subscription-backed or fallback-backed
|
||||
- next relevant date
|
||||
- concise explanation only
|
||||
|
||||
## State Transitions
|
||||
|
||||
There is no multi-row ledger in v1. State changes are explicit updates to the current workspace subscription record plus audit entries.
|
||||
|
||||
| From | To | Trigger | Consequence |
|
||||
|------|----|---------|-------------|
|
||||
| no record | any valid state | platform operator creates current subscription truth | workspace becomes subscription-backed |
|
||||
| `trial` | `active` | platform operator transition | derived lifecycle moves from `trial` to `active_paid` |
|
||||
| `active` | `past_due` | platform operator transition | derived lifecycle moves to `grace` |
|
||||
| `active` | `cancel_at_period_end` | platform operator transition | derived lifecycle stays `active_paid`, but period end becomes important context |
|
||||
| `past_due` | `ended` | platform operator transition | derived lifecycle moves to `suspended_read_only` |
|
||||
| any state | any other valid state | platform operator update | current subscription truth changes in place and is auditable |
|
||||
|
||||
## Boundaries Explicitly Preserved
|
||||
|
||||
- No invoice, payment, or provider-sync persistence exists in this slice.
|
||||
- No multi-record historical subscription ledger exists in this slice.
|
||||
- No direct subscription gate shape exists on onboarding or review-pack surfaces; lifecycle remains the only gate.
|
||||
- Existing view and download access to already-generated review packs, evidence, and review history stays governed by the current lifecycle and RBAC rules.
|
||||
309
specs/274-billing-subscription-truth/plan.md
Normal file
309
specs/274-billing-subscription-truth/plan.md
Normal file
@ -0,0 +1,309 @@
|
||||
# Implementation Plan: Billing & Subscription Truth Layer v1
|
||||
|
||||
**Branch**: `274-billing-subscription-truth` | **Date**: 2026-05-04 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `specs/274-billing-subscription-truth/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Prepare one bounded billing and subscription truth follow-through on top of the already-real Specs 247 and 251. The narrow implementation path is to introduce one workspace-owned current subscription record, feed it into the existing commercial lifecycle resolver when present, keep the current lifecycle resolver as the only runtime gate, and surface the resulting truth on the existing system workspace detail page plus a read-only summary on workspace settings.
|
||||
|
||||
This slice stays explicitly narrow. Filament remains v5 on Livewire v4, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no asset registration change is expected. The plan does not reopen plan-profile or lifecycle foundations, does not introduce payment providers, invoices, checkout, a customer portal, or a second runtime gate, and does not add a new `OperationRun` family.
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `WorkspacePlanProfileCatalog` and `WorkspaceEntitlementResolver` already provide bounded plan defaults, override logic, and the current managed-tenant or review-pack entitlement truth from Spec 247.
|
||||
- `WorkspaceCommercialLifecycleResolver` already provides the current shared lifecycle decision, mapping into onboarding activation and review-pack start behavior from Spec 251.
|
||||
- `ViewWorkspace` already exposes the current commercial and entitlement truth on the system workspace detail page and already owns the current bounded commercial mutation surface.
|
||||
- `WorkspaceSettings` already provides the singleton admin-plane settings surface where commercial truth can be summarized without introducing a second management plane.
|
||||
- `ManagedTenantOnboardingWizard` and `ReviewPackService` already consult shared commercial lifecycle truth before their high-impact start or activation flows.
|
||||
- Existing tests under `tests/Unit/Entitlements/`, `tests/Feature/System/`, `tests/Feature/Filament/Settings/`, `tests/Feature/Onboarding/`, and `tests/Feature/ReviewPack/` already prove the current entitlement and lifecycle seams.
|
||||
|
||||
### Explicit delta in this plan
|
||||
|
||||
- Add one workspace-owned current subscription record with bounded state and current-period fields.
|
||||
- Add one bounded `WorkspaceSubscriptionResolver` that exposes current subscription truth, next relevant date, fallback status, and lifecycle mapping.
|
||||
- Refactor `WorkspaceCommercialLifecycleResolver` to prefer subscription truth when a subscription record exists and to preserve the current fallback path when it does not.
|
||||
- Replace or narrow the current system detail mutation action so platform operators manage subscription truth rather than relying on manual lifecycle posture for subscription-backed workspaces.
|
||||
- Add a read-only subscription summary to `WorkspaceSettings` so workspace operators can see the current commercial posture without gaining billing controls.
|
||||
- Keep onboarding and review-pack surfaces on the existing shared lifecycle gate and only change the upstream source behind that decision.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `WorkspaceEntitlementResolver`, existing `WorkspaceCommercialLifecycleResolver`, existing workspace audit foundation, existing onboarding and review-pack flows
|
||||
**Storage**: PostgreSQL via a new workspace-owned `workspace_subscriptions` table plus existing `workspace_settings`, `audit_logs`, and tenant-owned artifact tables
|
||||
**Testing**: Pest v4 `Unit` plus focused `Feature` coverage
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Laravel monolith in `apps/platform`, reusing the existing system and admin Filament pages
|
||||
**Project Type**: Web application (Laravel monolith with Filament panels)
|
||||
**Performance Goals**: no new queue family, no new Graph calls, and DB-only lifecycle or subscription resolution for current workspace pages
|
||||
**Constraints**: no payment provider integration, no invoice or checkout flow, no customer portal, no second gate outside `WorkspaceCommercialLifecycleResolver`, no new panel, no global-search change, and no asset-registration change
|
||||
**Scale/Scope**: 1 new workspace-owned entity, 1 new resolver, 2 existing page surfaces, and focused extensions to 4 existing runtime gate families
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- `apps/platform/app/Models/Workspace.php` for the new workspace-owned subscription relationship.
|
||||
- `apps/platform/app/Models/WorkspaceSubscription.php` as the new current subscription model.
|
||||
- `apps/platform/database/migrations/*_create_workspace_subscriptions_table.php` for new persistence.
|
||||
- `apps/platform/database/factories/WorkspaceSubscriptionFactory.php` for bounded test setup.
|
||||
- `apps/platform/app/Services/Entitlements/WorkspaceSubscriptionResolver.php` as the new bounded commercial source resolver.
|
||||
- `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` for subscription-first precedence and fallback retention.
|
||||
- `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` for subscription detail rendering and the bounded mutation action.
|
||||
- `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` for the read-only subscription summary.
|
||||
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and current audit action IDs or metadata for subscription-change attribution.
|
||||
- Existing tests under `apps/platform/tests/Unit/Entitlements/`, `apps/platform/tests/Feature/System/`, `apps/platform/tests/Feature/Filament/Settings/`, `apps/platform/tests/Feature/Onboarding/`, and `apps/platform/tests/Feature/ReviewPack/`.
|
||||
|
||||
## Commercial Truth Fit
|
||||
|
||||
- Treat the subscription record as a distinct current-release source of truth, not as more workspace-setting keys.
|
||||
- Keep exactly one current record per workspace in v1. Historical change tracking comes from audit rather than a multi-row subscription ledger.
|
||||
- Keep subscription state and lifecycle state distinct:
|
||||
- subscription state answers the current commercial source truth
|
||||
- lifecycle state remains the shared runtime gate for onboarding and review-pack flows
|
||||
- Use subscription truth to derive lifecycle when present; otherwise preserve the current manual lifecycle fallback.
|
||||
- Keep period-end and trial-end dates visible and explicit, but do not add timer-driven or webhook-driven automation in this slice.
|
||||
|
||||
## UI / Filament & Livewire Fit
|
||||
|
||||
- Existing operator-facing surfaces remain native Filament surfaces under Livewire v4, and this slice should stay inside those surfaces instead of introducing a new billing console.
|
||||
- No new Filament resource or globally searchable resource is required; the current system workspace detail page and workspace settings singleton page are sufficient.
|
||||
- The system page keeps one dominant action: update current subscription truth, and that action remains confirmation-protected. The current manual lifecycle action stays available only for fallback-backed workspaces and must remain explicitly secondary and bounded.
|
||||
- Workspace settings gains a read-only summary only. No subscription mutation controls appear on `/admin`.
|
||||
- Existing onboarding and review-pack action families remain in place and only change their shared upstream lifecycle source.
|
||||
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no new asset strategy is planned. If any shared asset later becomes necessary, deployment remains the normal `cd apps/platform && php artisan filament:assets` path.
|
||||
|
||||
## RBAC / Policy Fit
|
||||
|
||||
- Workspace and tenant membership remain isolation boundaries. Wrong-plane or non-member access stays 404; in-scope actors missing capability stay 403.
|
||||
- Platform-only subscription mutation should reuse the current dedicated commercial-management capability rather than adding a new capability family unless implementation proves a narrower rename is required.
|
||||
- Workspace settings visibility remains read-only on the admin plane and reuses existing settings-view capability checks.
|
||||
- Onboarding and review-pack surfaces keep their existing capability checks. Subscription truth must never bypass or replace those RBAC rules.
|
||||
- Business-state blocking stays inside the lifecycle decision path and must remain distinguishable from authorization failure.
|
||||
|
||||
## Audit / Logging Fit
|
||||
|
||||
- Every subscription-truth create or update must write an audit event with old state, new state, actor, and status reason.
|
||||
- Existing audit infrastructure should be reused rather than introducing a second billing audit subsystem.
|
||||
- The system detail summary should derive last-change attribution from the current record plus audit truth.
|
||||
- Blocked onboarding or review-pack attempts still do not need a new blocked-attempt audit family in this slice; existing behavior remains authoritative.
|
||||
|
||||
## Data & Query Fit
|
||||
|
||||
- `workspace_subscriptions.workspace_id` must be NOT NULL and unique so the slice stays on one current record per workspace.
|
||||
- Keep the new table small and bounded. Current-release fields should be limited to:
|
||||
- `state`
|
||||
- `billing_reference`
|
||||
- `trial_ends_at`
|
||||
- `current_period_starts_at`
|
||||
- `current_period_ends_at`
|
||||
- `status_reason`
|
||||
- timestamps
|
||||
- Do not add invoice rows, event logs, sync cursors, provider account IDs, or multi-subscription history in v1.
|
||||
- `WorkspaceCommercialLifecycleResolver` remains the single consumer-facing gate and should depend on the new resolver instead of scattering query logic into onboarding or review-pack code.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament
|
||||
- **Shared-family relevance**: system detail summaries, settings summaries, lifecycle messaging, action gating, audit-backed commercial truth
|
||||
- **State layers in scope**: page, detail
|
||||
- **Audience modes in scope**: operator-MSP, support-platform
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
|
||||
- **Raw/support gating plan**: raw reference detail remains secondary and platform-only
|
||||
- **One-primary-action / duplicate-truth control**: system detail keeps one dominant commercial mutation action; settings remains read-only; onboarding and review-pack surfaces show only the lifecycle result needed for the immediate action
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if a second billing surface or gate appears
|
||||
- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none planned; any broader portal, provider sync, or browser-heavy proof demand is out-of-scope drift
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `WorkspaceCommercialLifecycleResolver`, `WorkspaceEntitlementResolver`, `ViewWorkspace`, `WorkspaceSettings`, onboarding, review-pack start paths, and audit logging
|
||||
- **Shared abstractions reused**: current lifecycle resolver, current entitlement resolver, current audit path, current Filament action surfaces, and current review-pack start UX
|
||||
- **New abstraction introduced? why?**: one bounded `WorkspaceSubscriptionResolver`, because current repo truth has no distinct subscription source yet and several existing surfaces need the same mapping or fallback semantics
|
||||
- **Why the existing abstraction was sufficient or insufficient**: current lifecycle and entitlement abstractions are sufficient for runtime gating but insufficient for current subscription truth, period dates, fallback signaling, and future sync readiness
|
||||
- **Bounded deviation / spread control**: no local or second subscription decision helper is allowed outside the shared resolver path
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, by reuse only
|
||||
- **Central contract reused**: current review-pack start UX remains authoritative
|
||||
- **Delegated UX behaviors**: queued toast, dedupe behavior, and canonical run links stay unchanged when the derived lifecycle allows the start action
|
||||
- **Surface-owned behavior kept local**: the system page owns subscription edit inputs; workspace settings owns the read-only summary; onboarding and review-pack surfaces own only the immediate lifecycle explanation
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: none in this slice
|
||||
- **Platform-core seams**: subscription truth, lifecycle mapping, fallback visibility, and commercial audit
|
||||
- **Neutral platform terms / contracts preserved**: `subscription`, `commercial posture`, `trial`, `past due`, `cancel at period end`, `ended`, `fallback lifecycle`
|
||||
- **Retained provider-specific semantics and why**: none
|
||||
- **Bounded extraction or follow-up path**: future provider sync only if later explicitly specced
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins and again before merge.*
|
||||
|
||||
- Inventory-first / snapshot truth: PASS. This slice adds workspace-owned commercial truth only and does not reinterpret tenant inventory or snapshot semantics.
|
||||
- Read/write separation: PASS. The only mutation is bounded commercial metadata on the current system workspace detail surface.
|
||||
- Graph contract path: PASS. No Graph calls or provider contract changes are introduced.
|
||||
- Deterministic capabilities: PASS. Existing capability registries remain authoritative.
|
||||
- Workspace and tenant isolation: PASS. Existing 404 and 403 semantics remain unchanged.
|
||||
- RBAC-UX plane separation: PASS. Mutation stays on `/system`; `/admin` remains read-only or contextual.
|
||||
- Destructive action discipline: PASS. No new destructive action is planned; any high-impact mutation stays confirmation-protected.
|
||||
- Global search safety: PASS. No new searchable resource is introduced.
|
||||
- OperationRun / Ops-UX: PASS by reuse only. No new run family or new run-triggering surface is added.
|
||||
- Data minimization: PASS. Current subscription truth stays bounded and no provider payloads or payment data are stored.
|
||||
- Test governance: PASS. Proof remains in focused unit plus feature lanes.
|
||||
- Proportionality / no premature abstraction: PASS. One new entity and one new resolver are the narrowest path that avoids further truth duplication.
|
||||
- Persisted truth: PASS. The new table represents real product truth with an independent lifecycle and audit need.
|
||||
- Behavioral state: PASS. Subscription states change derived lifecycle and operator behavior.
|
||||
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing Filament surfaces and the current lifecycle gate remain the shared path.
|
||||
- Provider boundary: PASS. No provider-specific vocabulary is introduced.
|
||||
- Filament / Laravel planning contract: PASS. Filament stays v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, and no asset registration change is planned.
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
**Post-design re-check**: PASS. `research.md`, `data-model.md`, `quickstart.md`, `contracts/workspace-billing-subscription-truth.logical.openapi.yaml`, `checklists/requirements.md`, and `tasks.md` are present and aligned with the package.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: `Unit` for subscription validation, mapping, and fallback precedence; `Feature` for system detail, settings summary, onboarding, and review-pack runtime continuity
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the slice reuses native Filament and current gate seams, so focused unit and feature tests can prove the new truth without browser automation
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; one new factory is needed, but current workspace, onboarding, review-pack, and system-user fixtures can stay reused
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none planned
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief for system and settings surfaces; monitoring-state-page coverage for review-pack blocked-start semantics
|
||||
- **Closing validation and reviewer handoff**: reviewers should rely on the exact commands above, confirm that no second runtime gate or second management plane appears, verify that blocked review-pack starts still create no run, and verify that existing review-pack view or download access remains unchanged
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
|
||||
- **Review-stop questions**: did the slice add a second gate, did it widen into provider sync or invoices, did it leave fallback behavior ambiguous, and did it preserve 404 or 403 semantics
|
||||
- **Escalation path**: `document-in-feature` for contained naming drift; `reject-or-split` if the slice widens into a billing engine, portal, or second gate
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: routine subscription-truth upkeep should stay inside this feature unless future provider sync or invoice history is explicitly promoted as a separate slice
|
||||
- **Test-governance outcome**: keep
|
||||
|
||||
## Review Checklist Status
|
||||
|
||||
- **Review checklist artifact**: `checklists/requirements.md`
|
||||
- **Review outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Escalation rule**: if implementation adds provider sync, invoice persistence, or a second runtime gate, flip the workflow outcome to `split` before continuing
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Land the new entity and resolver first, then refactor lifecycle precedence, then update the system detail page, and only then add the read-only settings summary.
|
||||
- Keep the current manual lifecycle path explicit as fallback-backed so workspaces without a subscription record do not regress.
|
||||
- Keep onboarding and review-pack behavior untouched except for the new upstream lifecycle source.
|
||||
- Keep Filament v5 on Livewire v4, keep provider registration in `apps/platform/bootstrap/providers.php`, keep global search unchanged, and keep assets unchanged.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Reject any implementation that adds payment-provider adapters, webhook handlers, invoice rows, or a customer billing portal.
|
||||
- Reject any implementation that adds direct subscription checks to onboarding or review-pack surfaces instead of routing through lifecycle.
|
||||
- Reject any implementation that makes `/admin` a second commercial mutation plane.
|
||||
- Reject any implementation that turns one current subscription record into a broad ledger or history console in this slice.
|
||||
- Reject browser-heavy proof as the default validation lane.
|
||||
|
||||
## Research & Design Outputs
|
||||
|
||||
- `research.md` should resolve the key design choices: dedicated table versus settings keys, subscription-first lifecycle precedence, read-only admin summary, and strict non-goals around providers or invoices.
|
||||
- `data-model.md` should record the new entity, field validation, state mapping, uniqueness constraints, and derived lifecycle summary shape.
|
||||
- `quickstart.md` should provide the bounded implementation order, reviewer scenarios, explicit confirmation expectations for the system mutation, and focused validation commands.
|
||||
- `contracts/workspace-billing-subscription-truth.logical.openapi.yaml` should capture the logical route and action boundaries for the current system and admin surfaces plus the unchanged downstream gate semantics.
|
||||
- `checklists/requirements.md` should record the prep-time review outcome, workflow outcome, and test-governance outcome.
|
||||
- `tasks.md` should keep the implementation bounded to the current surfaces and the current lifecycle gate.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/274-billing-subscription-truth/
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── contracts/
|
||||
│ └── workspace-billing-subscription-truth.logical.openapi.yaml
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (expected implementation surfaces)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ └── Settings/
|
||||
│ │ │ └── WorkspaceSettings.php
|
||||
│ │ ├── System/Pages/Directory/
|
||||
│ │ │ └── ViewWorkspace.php
|
||||
│ │ └── Pages/Workspaces/
|
||||
│ │ └── ManagedTenantOnboardingWizard.php
|
||||
│ ├── Models/
|
||||
│ │ ├── Workspace.php
|
||||
│ │ └── WorkspaceSubscription.php
|
||||
│ ├── Services/
|
||||
│ │ ├── Audit/WorkspaceAuditLogger.php
|
||||
│ │ └── Entitlements/
|
||||
│ │ ├── WorkspaceCommercialLifecycleResolver.php
|
||||
│ │ └── WorkspaceSubscriptionResolver.php
|
||||
│ └── Support/
|
||||
│ └── Auth/
|
||||
│ └── PlatformCapabilities.php
|
||||
├── database/
|
||||
│ ├── factories/
|
||||
│ │ └── WorkspaceSubscriptionFactory.php
|
||||
│ └── migrations/
|
||||
│ └── *_create_workspace_subscriptions_table.php
|
||||
└── tests/
|
||||
├── Unit/Entitlements/
|
||||
│ └── WorkspaceSubscriptionResolverTest.php
|
||||
└── Feature/
|
||||
├── Filament/Settings/
|
||||
│ └── WorkspaceEntitlementsSettingsPageTest.php
|
||||
├── Onboarding/
|
||||
│ └── ManagedTenantOnboardingEntitlementTest.php
|
||||
├── ReviewPack/
|
||||
│ ├── ReviewPackEntitlementEnforcementTest.php
|
||||
│ ├── ReviewPackGenerationTest.php
|
||||
│ └── ReviewPackDownloadTest.php
|
||||
├── Reviews/
|
||||
│ └── CustomerReviewWorkspacePackAccessTest.php
|
||||
└── System/
|
||||
├── Spec113/AuthorizationSemanticsTest.php
|
||||
└── ViewWorkspaceEntitlementsTest.php
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| New persisted subscription entity | Current commercial truth now needs its own lifecycle, dates, and audit trail | More settings keys would keep subscription truth blurred with fallback lifecycle and plan settings |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: support and commercial operators still cannot point to one durable source that explains why a workspace is trial, paid, overdue, cancellation-pending, or ended
|
||||
- **Existing structure is insufficient because**: plan settings and manual lifecycle state together still do not provide a distinct subscription record with its own lifecycle and current-period fields
|
||||
- **Narrowest correct implementation**: one current subscription record plus one resolver layered into the current lifecycle path
|
||||
- **Ownership cost**: one new entity, one migration, one resolver, one read-only admin summary, one system mutation surface, and focused tests
|
||||
- **Alternative intentionally rejected**: more workspace settings keys or a broad billing engine
|
||||
- **Release truth**: current-release truth only; provider sync, invoices, and portal work remain follow-ups
|
||||
120
specs/274-billing-subscription-truth/quickstart.md
Normal file
120
specs/274-billing-subscription-truth/quickstart.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Quickstart: Billing & Subscription Truth Layer v1
|
||||
|
||||
**Date**: 2026-05-04
|
||||
**Branch**: `274-billing-subscription-truth`
|
||||
|
||||
This quickstart is the intended reviewer flow after implementation. It stays bounded to current subscription truth, lifecycle derivation, and the existing onboarding or review-pack gates.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the local platform stack.
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
|
||||
2. Ensure one platform user has directory visibility plus the current dedicated commercial-management capability.
|
||||
3. Ensure one workspace member can view workspace settings, one operator can complete onboarding, and one reporting operator can manage review-pack generation.
|
||||
4. Seed or factory-create:
|
||||
- one workspace with no subscription record and no explicit manual lifecycle override
|
||||
- one workspace with a current subscription record in `trial`
|
||||
- one workspace with a current subscription record in `past_due`
|
||||
- one onboarding draft in a subscription-backed workspace
|
||||
- one tenant with an existing review and generated review pack in a subscription-backed workspace
|
||||
|
||||
## Scenario 1: Record or update current subscription truth from the system plane
|
||||
|
||||
1. Open `/system/directory/workspaces/{workspace}` as the authorized platform user.
|
||||
2. Confirm the page shows:
|
||||
- current subscription status or an explicit fallback indicator when no subscription record exists
|
||||
- derived commercial posture
|
||||
- next relevant date
|
||||
- reference and last changed attribution
|
||||
3. Use `Update subscription truth` to save a `trial` record with `trial_ends_at` and status reason.
|
||||
4. Confirm the page updates immediately and the derived lifecycle resolves to `trial`.
|
||||
5. Repeat with `active`, `past_due`, `cancel_at_period_end`, and `ended`.
|
||||
6. Backdate a `trial` or `cancel_at_period_end` date and confirm the system page surfaces that the subscription record needs review without auto-transitioning state.
|
||||
7. Confirm every explicit update requires status reason, explicit confirmation, and remains auditable.
|
||||
|
||||
## Scenario 2: Preserve fallback behavior when no subscription record exists
|
||||
|
||||
1. Use a workspace with no current subscription record.
|
||||
2. Confirm the system detail page marks the commercial posture as fallback-backed.
|
||||
3. If the workspace has an explicit manual lifecycle state from current repo truth, confirm that state still drives the derived lifecycle.
|
||||
4. If the workspace has no explicit lifecycle state either, confirm the workspace still resolves to the current default posture of `active_paid`.
|
||||
5. Confirm the fallback `Change commercial state` action remains available only while no subscription record exists.
|
||||
6. Confirm no subscription-specific mutation or admin-plane readout leaks outside the current workspace and platform scopes.
|
||||
|
||||
## Scenario 3: Keep onboarding activation on the existing lifecycle gate
|
||||
|
||||
1. Open `/admin/onboarding/{onboardingDraft}` for a workspace with an `active` subscription and an allowed entitlement substrate.
|
||||
2. Confirm the completion step allows `Complete onboarding` and that the commercial explanation is subscription-backed.
|
||||
3. Switch the same workspace to `past_due` from the system plane.
|
||||
4. Refresh the onboarding draft and confirm:
|
||||
- the action remains visible for an otherwise authorized actor
|
||||
- the step explains the grace posture as a business-state block
|
||||
- no tenant activation occurs
|
||||
5. Repeat with a workspace that has no subscription record but does have a fallback manual lifecycle block and confirm the step explains the fallback source rather than pretending a subscription exists.
|
||||
|
||||
## Scenario 4: Keep review-pack generation on the existing lifecycle gate
|
||||
|
||||
1. Use a workspace with an `active` subscription where the underlying review-pack entitlement allows generation.
|
||||
2. Trigger the current start family from:
|
||||
- the tenant dashboard review-pack card
|
||||
- the review register export action
|
||||
- the tenant review detail export action
|
||||
- the review-pack detail regenerate action
|
||||
3. Confirm the existing queued-start UX remains unchanged when allowed.
|
||||
4. Change the same workspace to `ended` from the system plane.
|
||||
5. Repeat the same start actions and confirm:
|
||||
- each surface shows the same lifecycle-based reason
|
||||
- no new `ReviewPack` row is created
|
||||
- no new `OperationRun` row is created
|
||||
- no queued or terminal review-pack notification is emitted for the blocked attempt
|
||||
6. Confirm a workspace without a subscription record still follows its fallback lifecycle state exactly as before.
|
||||
|
||||
## Scenario 5: Preserve current review-pack and customer-workspace access
|
||||
|
||||
1. Keep a workspace in a state where review-pack generation is blocked.
|
||||
2. Open an already-generated review pack through the current detail or download path.
|
||||
3. Confirm the artifact is still viewable or downloadable under current RBAC.
|
||||
4. Open the current customer-workspace pack access path for the same workspace.
|
||||
5. Confirm current read-only pack access remains unchanged by the new subscription truth layer.
|
||||
|
||||
## Scenario 6: Show a read-only commercial summary on workspace settings
|
||||
|
||||
1. Open `/admin/settings/workspace` as an authorized workspace manager.
|
||||
2. Confirm the page shows:
|
||||
- the current commercial posture
|
||||
- whether the posture is subscription-backed or fallback-backed
|
||||
- the next relevant date when present
|
||||
- a concise explanation without mutation controls
|
||||
3. Switch the current subscription state from the system plane and refresh the settings page.
|
||||
4. Confirm the read-only summary updates accordingly.
|
||||
|
||||
## RBAC and Plane Semantics Checks
|
||||
|
||||
1. Access subscription mutation from `/admin` and confirm there is no self-service control surface.
|
||||
2. Access `/system/directory/workspaces/{workspace}` as a platform user lacking the dedicated capability and confirm authorization is enforced without leaking admin-plane truth.
|
||||
3. Access onboarding or review-pack surfaces as a non-member or wrong-plane actor and confirm 404.
|
||||
4. Access the same surfaces as an established-scope actor lacking the relevant capability and confirm 403.
|
||||
5. Access the action as an otherwise authorized actor whose workspace is blocked by the derived lifecycle and confirm a truthful business-state block instead of 403 or 404.
|
||||
|
||||
## Targeted Validation Commands
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Out of Scope Confirmations
|
||||
|
||||
While validating this slice, confirm that the implementation does not add or imply:
|
||||
|
||||
- payment-provider credentials, invoices, checkout, taxes, or public pricing UI
|
||||
- customer-account, contract, or CRM models
|
||||
- webhook or schedule-driven state transitions
|
||||
- a second admin-plane commercial management surface
|
||||
- a second runtime gate outside `WorkspaceCommercialLifecycleResolver`
|
||||
- a multi-row subscription ledger or history browser
|
||||
59
specs/274-billing-subscription-truth/research.md
Normal file
59
specs/274-billing-subscription-truth/research.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Research: Billing & Subscription Truth Layer v1
|
||||
|
||||
**Date**: 2026-05-04
|
||||
**Branch**: `274-billing-subscription-truth`
|
||||
|
||||
## Decision 1: Use a dedicated workspace-owned subscription table, not more workspace-setting keys
|
||||
|
||||
- **Decision**: Persist current subscription truth in a new `workspace_subscriptions` table with one current row per workspace.
|
||||
- **Rationale**: Subscription truth now has its own lifecycle, date fields, audit trail, and future sync seam. Keeping it inside `WorkspaceSetting` would continue to blur fallback lifecycle truth, plan settings, and current subscription truth into one settings bucket.
|
||||
- **Alternatives considered**:
|
||||
- More keys under `entitlements.*`: rejected because the record now has independent lifecycle meaning and no longer behaves like a small plan override.
|
||||
- Broad customer-account or billing model: rejected because the current release needs one current subscription record only, not a full billing domain.
|
||||
|
||||
## Decision 2: Keep `WorkspaceCommercialLifecycleResolver` as the only runtime gate
|
||||
|
||||
- **Decision**: `WorkspaceCommercialLifecycleResolver` remains the one gate consulted by onboarding and review-pack surfaces.
|
||||
- **Rationale**: Specs 247 and 251 already proved the current gate shape. Introducing direct subscription checks on onboarding or review-pack surfaces would create a second gate and immediate drift.
|
||||
- **Alternatives considered**:
|
||||
- Direct subscription checks on each surface: rejected because that would duplicate lifecycle logic and blur business-state versus entitlement-state reasoning.
|
||||
- Leaving lifecycle fully manual even after adding a subscription record: rejected because it would leave two competing commercial truths alive at once.
|
||||
|
||||
## Decision 3: Subscription truth becomes the upstream lifecycle source when present
|
||||
|
||||
- **Decision**: When a workspace has a current subscription record, lifecycle state is derived from that record. When no subscription record exists, the current manual lifecycle overlay remains the fallback source.
|
||||
- **Rationale**: This keeps current behavior stable for untouched workspaces while letting new subscription-backed workspaces stop relying on manual lifecycle state.
|
||||
- **Alternatives considered**:
|
||||
- Force every workspace to get a subscription record immediately: rejected because the repo already has live manual lifecycle semantics and the narrow slice should not require a bulk migration workflow.
|
||||
- Add subscription truth as read-only evidence without feeding lifecycle: rejected because it would preserve duplicated truths and force operators to reconcile them manually.
|
||||
|
||||
## Decision 4: Use one current record and audit as history
|
||||
|
||||
- **Decision**: V1 stores one current subscription record per workspace and relies on `AuditLog` for change history.
|
||||
- **Rationale**: The current operator problem is understanding the current posture and driving the current lifecycle gate, not browsing historical subscription revisions in-product.
|
||||
- **Alternatives considered**:
|
||||
- Multi-row history table or event ledger: rejected because the current slice would become a billing-history feature rather than a source-of-truth follow-through.
|
||||
- No history beyond the row itself: rejected because auditability is required for commercial truth changes.
|
||||
|
||||
## Decision 5: Keep the current mutation surface on the system workspace detail page
|
||||
|
||||
- **Decision**: Reuse `ViewWorkspace` for subscription mutation and keep `WorkspaceSettings` read-only.
|
||||
- **Rationale**: Specs 247 and 251 already separated admin-plane self-understanding from platform-plane commercial control. This slice should preserve that separation.
|
||||
- **Alternatives considered**:
|
||||
- Add an admin-plane subscription form: rejected because it would create a second commercial control plane.
|
||||
- Add a new system billing page: rejected because the existing workspace detail page is already the commercial-truth drilldown.
|
||||
|
||||
## Decision 6: No automatic timers, provider sync, or invoice logic in v1
|
||||
|
||||
- **Decision**: Period dates are visible and testable, but the product does not auto-transition subscription state, sync from an external system, or introduce invoices in this slice.
|
||||
- **Rationale**: The current need is durable commercial truth and one shared runtime source, not an automation engine.
|
||||
- **Alternatives considered**:
|
||||
- Webhook or provider sync placeholders: rejected because they would widen the slice into provider-boundary work.
|
||||
- Automatic transitions when trial or period end passes: rejected because the repo has no current scheduling or operator-review contract for that behavior.
|
||||
|
||||
## Final Research Outcome
|
||||
|
||||
- Current-release truth requires a dedicated current subscription record.
|
||||
- Runtime gating remains on the existing lifecycle resolver.
|
||||
- The implementation should stay on the current system and admin surfaces.
|
||||
- No provider, invoice, portal, or automation work is needed for this slice.
|
||||
333
specs/274-billing-subscription-truth/spec.md
Normal file
333
specs/274-billing-subscription-truth/spec.md
Normal file
@ -0,0 +1,333 @@
|
||||
# Feature Specification: Billing & Subscription Truth Layer v1
|
||||
|
||||
**Feature Branch**: `274-billing-subscription-truth`
|
||||
**Created**: 2026-05-04
|
||||
**Status**: Ready for implementation
|
||||
**Input**: User description: "Promote the remaining commercial follow-through beyond Specs 247 and 251 into one bounded billing/subscription truth layer. Keep current plan profiles, entitlement gating, and commercial lifecycle UX intact where already real. Add one durable subscription source of truth that can explain why a workspace is trial, paid, overdue, cancellation-pending, or ended without introducing a billing engine, payment-provider integration, or customer portal."
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot already has plan profiles, entitlement gates, and a manual commercial lifecycle overlay, but it still has no durable subscription record that explains why a workspace is trial, active paid, in grace, or suspended read-only.
|
||||
- **Today's failure**: Platform operators can manually set lifecycle posture on the system workspace page, yet the product still cannot answer whether that posture is backed by a current subscription, what billing period is active, when trial ends, or when cancellation should take effect.
|
||||
- **User-visible improvement**: Platform operators can record one current subscription truth once, workspace operators can inspect a calm read-only summary, and the existing onboarding and review-pack gates become subscription-backed where a subscription exists instead of relying only on manual lifecycle overlays.
|
||||
- **Smallest enterprise-capable version**: Introduce one workspace-owned current subscription record, one bounded subscription-state family plus current-period fields, one shared resolver that maps subscription truth into the existing commercial lifecycle resolver, one platform mutation surface on the existing system workspace page, and one read-only workspace-settings summary.
|
||||
- **Explicit non-goals**: No payment-provider integration, no invoices, no checkout, no taxes, no seat billing, no public pricing, no website work, no customer billing portal, no webhook automation, no recurring reminder engine, no multi-subscription history browser, no CRM/account domain, and no second runtime gate outside the existing lifecycle resolver.
|
||||
- **Permanent complexity imported**: One new workspace-owned subscription entity, one bounded resolver layered into the current commercial lifecycle path, one migration, one system detail mutation action, one read-only admin summary, and focused unit plus feature coverage.
|
||||
- **Why now**: Specs 247 and 251 are already repo-real and deliberately left billing/subscription truth open. Without this follow-through, later commercial work will either duplicate manual lifecycle state or keep living in founder memory and external notes.
|
||||
- **Why not local**: The same subscription truth must inform the system directory, workspace admin understanding, and the current lifecycle-backed onboarding and review-pack decisions. Local notes or page-level fields would drift immediately.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: New persisted truth, new state family, and multi-surface touchpoint. Defense: the slice stays on one current subscription record per workspace, keeps lifecycle as the only runtime gate, and avoids any billing engine, payment provider, or customer-account expansion.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/system/directory/workspaces/{workspace}` on `App\Filament\System\Pages\Directory\ViewWorkspace`
|
||||
- `/admin/settings/workspace` on `App\Filament\Pages\Settings\WorkspaceSettings`
|
||||
- `/admin/onboarding/{onboardingDraft}` on `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`
|
||||
- current review-pack generation entry surfaces backed by `App\Services\ReviewPackService`
|
||||
- **Data Ownership**: Subscription truth becomes one workspace-owned persisted record with its own lifecycle in a new `workspace_subscriptions` table. Existing plan profiles, entitlement overrides, and manual commercial lifecycle fallback remain workspace-owned through existing settings infrastructure. Tenant-owned review packs, evidence snapshots, and onboarding records stay unchanged.
|
||||
- **RBAC**: Platform users with current directory visibility plus the existing dedicated commercial-management capability may inspect and update subscription truth on `/system`. Workspace members with `Capabilities::WORKSPACE_SETTINGS_VIEW` may inspect the read-only subscription summary on `/admin/settings/workspace`. Existing onboarding and review-pack capability checks remain authoritative. Non-members and wrong-plane actors continue to receive 404. In-scope actors missing capability continue to receive 403. Business-state blocking remains a product-state response for otherwise authorized actors.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: N/A - this slice adds no tenantless collection and no canonical tenant list.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. Subscription truth is workspace-owned and must never reveal tenant-owned records outside existing authorized scope.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: system detail summaries, bounded header actions, settings summaries, status messaging, onboarding and review-pack action gating helper text
|
||||
- **Systems touched**: `WorkspaceCommercialLifecycleResolver`, `WorkspaceEntitlementResolver`, `ViewWorkspace`, `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, `WorkspaceAuditLogger`, and the shared badge or status vocabulary
|
||||
- **Existing pattern(s) to extend**: existing system workspace detail summary and bounded mutation pattern, existing workspace settings read-only summary pattern, existing lifecycle-gated onboarding and review-pack paths, and existing audit-backed commercial change semantics
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: the current commercial lifecycle decision path remains the shared runtime gate; this slice adds one bounded `WorkspaceSubscriptionResolver` and extends the current `WorkspaceCommercialLifecycleResolver` so downstream surfaces keep consuming one lifecycle decision instead of a second gate
|
||||
- **Why the existing shared path is sufficient or insufficient**: The existing lifecycle resolver is sufficient as the one gate surface. It is insufficient as source truth because it currently relies on manual lifecycle state with no durable subscription record, no current billing-period context, and no bounded subscription-state vocabulary.
|
||||
- **Allowed deviation and why**: none. The feature must not create a second billing panel, page-local subscription copy, or direct subscription gate checks on onboarding or review-pack surfaces.
|
||||
- **Consistency impact**: Subscription state labels, lifecycle labels, source labels, fallback wording, and date wording must stay aligned across the system detail surface, workspace settings summary, and the existing lifecycle-backed action surfaces.
|
||||
- **Review focus**: Reviewers must verify that subscription truth feeds the existing lifecycle resolver, that downstream surfaces still consume lifecycle rather than local subscription checks, and that fallback wording remains explicit when no subscription record exists.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: Existing review-pack generation continues to reuse `App\Services\ReviewPackService`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`. Subscription truth adds no new run family.
|
||||
- **Delegated start/completion UX behaviors**: When the derived lifecycle allows review-pack generation, queued toast, run link, dedupe handling, and terminal notifications stay on the existing review-pack path. When derived lifecycle blocks, no new `OperationRun` is created and no queued or terminal lifecycle feedback is emitted.
|
||||
- **Local surface-owned behavior that remains**: The system detail surface owns subscription edit inputs. Workspace settings owns the read-only summary block. Onboarding and review-pack surfaces own only the immediate business-state message for the current action.
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
N/A - no shared provider/platform boundary is changed. External billing providers remain out of scope. Subscription truth is platform-core commercial metadata owned by TenantPilot.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Platform workspace subscription truth section | yes | Native Filament system detail page | detail summary, header action, status messaging | detail page, header action, summary cards | no | Extends the existing workspace detail page instead of adding a second system console |
|
||||
| Workspace settings read-only subscription summary | yes | Native Filament singleton settings page | settings summary, support text | page, section | no | Read-only only; no new admin-plane mutation surface |
|
||||
| Managed tenant onboarding completion gate | yes | Native Filament wizard | action gating, helper text | completion step, confirmation action | no | Reuses the existing lifecycle-aware completion step |
|
||||
| Review-pack generation entry family | yes | Native Filament widget/resource/page actions | operation-start gating, helper text, lifecycle messaging | widget action, detail action, list/header action | no | The slice keeps the existing start family and only changes the upstream lifecycle source |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Platform workspace subscription truth section | Primary Decision Surface | Platform operator decides what current subscription truth should back a workspace's commercial posture | Current subscription state, period dates, lifecycle mapping, fallback status, and last changed attribution | Existing entitlement summary and related workspace context remain secondary | Primary because this is the one commercial truth surface that changes the shared runtime source | Keeps commercial truth on one system page instead of support notes and ad hoc overrides | Removes guesswork about why a workspace is trial, overdue, or ending |
|
||||
| Workspace settings read-only subscription summary | Secondary Context Surface | Workspace operator inspects current commercial posture without mutating it | Current subscription-backed or fallback-backed state, next relevant date, and concise explanation | No additional diagnostics in this slice | Not primary because workspace users do not change subscription truth here | Keeps self-serve understanding on the current settings page | Reduces support questions caused by invisible commercial posture |
|
||||
| Managed tenant onboarding completion gate | Secondary Context Surface | Workspace operator decides whether onboarding may complete now | Current lifecycle outcome and why it is blocked or allowed | Broader subscription detail stays secondary | Not primary because onboarding still answers an activation question, not a billing-management question | Keeps the commercial explanation inside the current activation workflow | Prevents operators from confusing subscription-driven blocks with permission problems |
|
||||
| Review-pack generation entry family | Secondary Context Surface | Reporting operator decides whether generation may start now | Current lifecycle outcome and concise reason | Broader subscription detail stays secondary | Not primary because the family exists to continue reporting work, not to manage subscription truth | Keeps the explanation inside the current start workflow | Prevents separate support lookup when generation blocks for commercial reasons |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Platform workspace subscription truth section | support-platform, operator-platform | Current subscription state, derived lifecycle state, period dates, fallback indicator, and concise reason | Reference identifier, last changed attribution, and inherited entitlement summary | No raw provider payloads or external-billing diagnostics | `Update subscription truth` | Future external-sync detail and any raw billing payload remain out of scope | The page states the commercial posture once and uses secondary detail for reference and attribution |
|
||||
| Workspace settings read-only subscription summary | operator-MSP | Current commercial posture, whether it is subscription-backed or fallback-backed, next relevant date, and concise explanation | No additional diagnostics in this slice | none | none - read-only context only | Platform mutation controls and raw reference detail stay hidden | The settings page mirrors the derived truth and does not restate every downstream block reason |
|
||||
| Managed tenant onboarding completion gate | operator-MSP | Current lifecycle outcome and concise business-state reason for the activation decision | Existing readiness diagnostics remain secondary | none | `Complete onboarding` when allowed | Full subscription detail stays off the onboarding step | One lifecycle message is reused instead of separate subscription prose |
|
||||
| Review-pack generation entry family | operator-MSP | Current lifecycle outcome and concise business-state reason for the start decision | Existing run state and artifact status remain secondary | none | `Generate pack`, `Regenerate`, or `Export executive pack` when allowed | Full subscription detail stays off the start surfaces | The same lifecycle result is reused across current start-entry points |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Platform workspace subscription truth section | System / Detail / Diagnostics | Read-only detail with bounded mutation action | Update the current subscription truth | Dedicated workspace detail page | forbidden | Existing admin-workspace and related navigation stay secondary | None in this slice; update is high-impact but not destructive | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity plus current commercial posture | Subscription truth | Subscription state, derived lifecycle, next relevant date, and fallback status | Existing system-detail exception remains bounded to one platform mutation surface |
|
||||
| Workspace settings read-only subscription summary | Config / Settings / Singleton | Workspace settings page | Inspect the current commercial posture | In-page read-only section | forbidden | Existing settings navigation remains secondary | none | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Subscription summary | Subscription-backed or fallback-backed commercial posture and next relevant date | Existing singleton-settings exception remains valid |
|
||||
| Managed tenant onboarding completion gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because commercial posture blocks activation | In-page completion section | forbidden | Existing back-navigation and tenant links remain secondary | Existing draft-cancel and draft-delete actions remain where they are today | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Active workspace plus current tenant | Onboarding commercial state | Activation allowed or blocked and why | Existing wizard exception remains valid |
|
||||
| Review-pack generation entry family | Contextual action family | Widget/resource/page start actions | Start, retry, or export a review pack when allowed | Explicit action on the current tenant or review context | mixed - existing registry rows may still open detail, but start actions remain explicit | Existing `View` and `Download` remain secondary and stay outside the start gate | Existing destructive actions remain out of scope and keep their current placement | current tenant dashboard, `/admin/reviews`, and current review-pack collection surfaces | current tenant review and review-pack detail routes | Active workspace, active tenant, review or pack context | Review-pack generation | Start allowed or blocked and why | Existing grouped-action family remains authoritative |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Platform workspace subscription truth section | Platform support or commercial operator | Record and inspect the current subscription truth for a workspace | System detail page | What subscription-backed commercial posture should this workspace be in now? | Subscription state, derived lifecycle, period dates, fallback status, and concise reason | Reference identifier, attribution, and inherited entitlement summary | subscription state, lifecycle state, fallback status | TenantPilot only | Update subscription truth | none |
|
||||
| Workspace settings read-only subscription summary | Workspace owner or manager | Understand the current commercial posture without changing it | Singleton settings page | What commercial state is currently active for this workspace, and when does it change next? | Subscription-backed or fallback-backed state, next relevant date, and concise explanation | No additional diagnostics in this slice | subscription state, lifecycle state, fallback status | none | none | none |
|
||||
| Managed tenant onboarding completion gate | Workspace owner or manager completing onboarding | Decide whether the current tenant may be activated now | Guided workflow step | Can I activate this tenant under the current commercial posture? | Current lifecycle outcome and concise reason | Existing readiness and verification detail | lifecycle state, entitlement availability | TenantPilot only for activation state | Complete onboarding | Cancel draft, Delete draft |
|
||||
| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether a new review-pack run may start now | Contextual start-action family | Can I start, retry, or export a pack from this context? | Current lifecycle outcome and concise reason | Existing run state and artifact status | lifecycle state, entitlement availability, run state | TenantPilot only until the current run starts | Generate pack, Regenerate, Export executive pack | Existing destructive actions remain unchanged and out of scope |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: yes - one workspace-owned current subscription record becomes the durable source behind commercial posture when present
|
||||
- **New persisted entity/table/artifact?**: yes - one current workspace subscription record per workspace
|
||||
- **New abstraction?**: yes - one bounded resolver that reads subscription truth and maps it into the existing lifecycle path
|
||||
- **New enum/state/reason family?**: yes - one bounded subscription-state family
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Operators can set lifecycle posture today, but they still cannot truthfully explain whether that posture is backed by a real subscription, what billing period is active, or when trial or cancellation should take effect.
|
||||
- **Existing structure is insufficient because**: Existing settings-backed plan profiles and manual lifecycle overlays do not provide a distinct subscription entity with its own lifecycle, dates, reference, or future sync seam.
|
||||
- **Narrowest correct implementation**: Introduce one current subscription record per workspace, map it into the existing lifecycle resolver, keep lifecycle as the only runtime gate, reuse current system and settings surfaces, and keep history in audit rather than adding a broader subscription ledger.
|
||||
- **Ownership cost**: One new table, one resolver, one migration, one system detail mutation surface, read-only summary upkeep, and focused tests for mapping plus fallback behavior.
|
||||
- **Alternative intentionally rejected**: More workspace-setting keys were rejected because subscription truth now has an independent lifecycle, date fields, and future sync seam that deserve explicit persistence. A broader customer-account or billing engine was rejected as beyond current-release truth.
|
||||
- **Release truth**: current-release truth with explicit follow-up room for later external billing sync and automation
|
||||
|
||||
### 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 state validation, lifecycle mapping, and fallback precedence. Focused feature coverage proves the existing system detail page, workspace settings summary, onboarding completion gate, and review-pack start gate without widening into browser or heavy-governance lanes.
|
||||
- **New or expanded test families**: one new `Entitlements` unit family for subscription truth plus focused extensions to the existing system, settings, onboarding, and review-pack feature families
|
||||
- **Fixture / helper cost impact**: Add only workspace, platform user, workspace member, onboarding draft, existing tenant review or review pack, and subscription-record fixtures required to prove current commercial decisions. Avoid browser harnesses, payment mocks, and external sync fixtures.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament, shared-detail-family, monitoring-state-page
|
||||
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the system detail mutation surface and workspace settings summary. Existing onboarding and review-pack feature families remain the proving lane for current runtime effects.
|
||||
- **Reviewer handoff**: Reviewers must confirm that subscription-backed workspaces now derive lifecycle from the new record, that fallback behavior remains explicit for workspaces without a subscription, that onboarding and review-pack surfaces still consult lifecycle rather than direct subscription checks, that blocked review-pack starts create no run, and that existing review-pack view or download access remains unchanged.
|
||||
- **Budget / baseline / trend impact**: low feature-local increase only
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Scope Boundaries *(required for this slice)*
|
||||
|
||||
### In Scope
|
||||
|
||||
- One current workspace-owned subscription record with a bounded state family and current-period context
|
||||
- Exactly five subscription states: `trial`, `active`, `past_due`, `cancel_at_period_end`, and `ended`
|
||||
- One shared resolver that maps subscription state into the existing commercial lifecycle decision when a subscription record exists
|
||||
- Explicit fallback to the existing manual lifecycle overlay when no subscription record exists for a workspace
|
||||
- One system-plane mutation surface on the existing workspace directory detail page
|
||||
- One read-only workspace-settings summary for current commercial posture
|
||||
- Audit logging for subscription truth changes
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Payment providers, invoices, taxes, checkout, public pricing, and website work
|
||||
- Customer-account, contract, CRM, or broader billing-domain models
|
||||
- Multiple current subscriptions per workspace or a historical subscription ledger UI
|
||||
- Webhook sync, scheduled renewal or expiry jobs, dunning, or reminder automation
|
||||
- Direct subscription checks on onboarding or review-pack surfaces outside the shared lifecycle resolver
|
||||
- A second admin-plane commercial-management surface
|
||||
|
||||
## Assumptions
|
||||
|
||||
- One current subscription record per workspace is the narrowest current-release truth; historical state changes can stay in `AuditLog` for v1.
|
||||
- Existing plan profile and entitlement override behavior from Spec 247 remains authoritative for what a workspace is allowed to do. Subscription truth only changes the upstream lifecycle source when present.
|
||||
- Existing manual commercial lifecycle state from Spec 251 remains the fallback source for workspaces that do not yet have a subscription record.
|
||||
- `cancel_at_period_end` does not auto-transition after the period-end date in v1. The date is visible so platform operators can perform an explicit review instead of relying on implicit automation.
|
||||
- Existing review-pack runs that were already created before a later subscription or lifecycle change may complete unchanged because this slice only changes future start decisions.
|
||||
|
||||
## Risks
|
||||
|
||||
- Subscription-backed lifecycle and fallback manual lifecycle can drift if fallback visibility is not explicit when no subscription record exists.
|
||||
- One current subscription record may be too narrow for later upsell or add-on scenarios, but it is the smallest correct current-release truth.
|
||||
- Later external billing sync could replace the v1 mutation path, but pre-production posture allows the source to evolve without long compatibility shims.
|
||||
- `cancel_at_period_end` can become stale if operators ignore the visible date, so the system detail surface must keep the date obvious even without automation.
|
||||
|
||||
## Deferred Adjacent Candidates
|
||||
|
||||
- External billing-provider sync and webhook-driven subscription updates
|
||||
- Invoice or receipt storage and payment-collection flows
|
||||
- Customer billing portal or workspace self-serve commercial controls
|
||||
- Broader demo or seeded trial automation beyond the current commercial truth layer
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Record one current workspace subscription truth centrally (Priority: P1)
|
||||
|
||||
As a platform support or commercial operator, I want to record one current subscription truth for a workspace so the product can explain commercial posture from one durable source instead of manual notes and free-form lifecycle overrides.
|
||||
|
||||
**Why this priority**: Without a durable subscription record, every later commercial or support surface must guess whether the current lifecycle posture is truly backed by a paid subscription, trial, overdue state, or pending cancellation.
|
||||
|
||||
**Independent Test**: Open the existing system workspace detail page, create or update subscription truth with state, dates, reference, and status reason, and confirm the page shows the new derived commercial posture plus audit attribution without touching onboarding or review-pack code paths.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace has no current subscription record, **When** an authorized platform operator saves a `trial` subscription with `trial_ends_at`, **Then** the workspace shows that subscription as the current commercial truth and the derived lifecycle resolves to `trial`.
|
||||
2. **Given** a workspace currently has an `active` subscription, **When** the same operator updates it to `past_due` with status reason and period dates, **Then** the record is updated, the change is auditable, and the derived lifecycle resolves to `grace`.
|
||||
3. **Given** a workspace currently has a subscription record, **When** an unauthorized or wrong-plane actor attempts to update it, **Then** authorization remains enforced through existing 404 and 403 rules and no subscription truth is changed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Keep current lifecycle-backed runtime gates but source them from subscription truth when present (Priority: P1)
|
||||
|
||||
As an authorized workspace operator, I want the current onboarding and review-pack decisions to continue using one shared lifecycle gate so the product remains consistent while subscription-backed workspaces stop relying on manual lifecycle state.
|
||||
|
||||
**Why this priority**: The repo already gates onboarding and review-pack starts through the lifecycle resolver. The value of this slice is not a new gate but a more truthful upstream source.
|
||||
|
||||
**Independent Test**: Seed one workspace with a subscription record and one without. Confirm that the subscription-backed workspace derives lifecycle from subscription state, the fallback workspace still uses manual lifecycle state, and the existing onboarding or review-pack behavior follows the resulting lifecycle outcome in both cases.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace has an `active` subscription and the underlying entitlement substrate allows activation, **When** an authorized operator reaches onboarding completion, **Then** the step allows completion and identifies the commercial posture as subscription-backed.
|
||||
2. **Given** a workspace has a `past_due` subscription, **When** the same operator attempts onboarding completion, **Then** the step blocks before any activation mutation and explains the current grace posture as derived from subscription truth.
|
||||
3. **Given** a workspace has no subscription record but does have an explicit manual lifecycle state of `suspended_read_only`, **When** an authorized operator attempts review-pack generation, **Then** the current lifecycle block still applies and the surface makes clear that the result comes from fallback lifecycle truth rather than a subscription record.
|
||||
4. **Given** a workspace has a `cancel_at_period_end` subscription and the current review-pack entitlement allows generation, **When** an authorized operator starts review-pack generation, **Then** the existing run flow continues through the current lifecycle gate and no second subscription gate is introduced.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Let workspace operators inspect the current commercial posture without gaining billing controls (Priority: P2)
|
||||
|
||||
As a workspace owner or manager, I want to inspect the current subscription-backed or fallback-backed commercial posture on the existing settings page so I can understand why current actions are allowed or blocked without opening the platform plane.
|
||||
|
||||
**Why this priority**: Customer-safe self-understanding matters, but it must not create a second mutation plane or a broader billing UI.
|
||||
|
||||
**Independent Test**: Open `/admin/settings/workspace` as a workspace manager, confirm the page shows the current commercial posture, next relevant date, and fallback indicator, and verify there are no edit controls for subscription truth.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace has a current `trial` subscription, **When** an authorized workspace manager opens the existing settings page, **Then** the page shows the current commercial posture, trial end date, and a concise explanation without exposing platform mutation controls.
|
||||
2. **Given** a workspace has no subscription record and is still using fallback lifecycle truth, **When** the same manager opens the settings page, **Then** the page clearly marks the posture as fallback-backed rather than subscription-backed.
|
||||
3. **Given** a non-member or wrong-plane actor attempts to inspect the same page, **When** the request is evaluated, **Then** existing isolation rules still apply and no workspace commercial truth is leaked.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A workspace with no subscription record must continue to behave exactly as current Specs 247 and 251 require until a platform operator records subscription truth.
|
||||
- A workspace with a subscription state of `cancel_at_period_end` and a past period-end date must remain explicitly review-required on the system detail surface without auto-transitioning in v1.
|
||||
- If a workspace has a subscription state that maps to an allowed lifecycle but the underlying entitlement substrate blocks the action, the substrate block still wins and must remain distinguishable from subscription-backed lifecycle decisions.
|
||||
- If a review-pack run was already created before a later subscription or lifecycle change, the existing run may complete; the new commercial posture affects future starts only.
|
||||
- A workspace member lacking onboarding or review-pack capability must still receive 403 even if subscription-backed lifecycle would otherwise allow the action.
|
||||
- A non-member or wrong-plane actor must not learn whether a workspace is trial, overdue, cancellation-pending, ended, or fallback-backed; those requests continue to resolve as 404.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature adds runtime-changing commercial truth and one new workspace-owned persisted entity, but it does not add Microsoft Graph calls, provider dispatch, payment-provider integrations, or a new queued workflow family. Existing review-pack `OperationRun` behavior is reused only when the current lifecycle gate allows the start action.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces a new persisted subscription record because the current-release operator problem now needs distinct commercial truth with its own lifecycle, dates, and audit trail rather than more settings keys. A narrower settings-only approach would continue to blur lifecycle fallback and subscription-backed truth.
|
||||
|
||||
**Constitution alignment (XCUT-001):** All in-scope surfaces must reuse the same derived lifecycle decision, whether it comes from a subscription record or fallback lifecycle state. No surface may invent local subscription-to-lifecycle rules or local block reasons.
|
||||
|
||||
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The system detail page shows the subscription-backed decision first and deeper reference detail second. Workspace settings shows only the calm read-only summary needed for self-understanding. Onboarding and review-pack surfaces show only the immediate lifecycle outcome required for the action.
|
||||
|
||||
**Constitution alignment (PROV-001):** Subscription truth remains provider-neutral and platform-core. The feature must not import Stripe-, payment-gateway-, or vendor-specific semantics into shared commercial vocabulary.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes. New fixtures remain limited to workspace, subscription record, platform operator, workspace member, onboarding draft, and review-pack-capable tenant context.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature adds no new run family. Existing review-pack generation keeps the current queued toast, operation link, and terminal notification path when lifecycle allows it. Blocked starts create no run and no run lifecycle feedback.
|
||||
|
||||
**Constitution alignment (OPS-UX-START-001):** Subscription truth sits upstream of `WorkspaceCommercialLifecycleResolver`. The lifecycle resolver remains the only gate that review-pack entry surfaces consult before run creation.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Two authorization planes are involved: platform `/system` for subscription mutation and admin `/admin` for read-only or contextual lifecycle effects. Wrong-plane or non-member requests remain 404. Members missing capability remain 403. Business-state blocking remains distinct from authorization failure.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If subscription state or derived lifecycle badges are rendered, their labels and visual semantics must come from one shared catalog or mapping rather than page-local color logic.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The slice extends existing native Filament detail, settings, wizard, widget, and resource surfaces only. No custom billing panel, independent status language, or new design system is allowed.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Primary operator labels stay product-facing and specific: `Subscription status`, `Derived commercial posture`, `Trial ends`, `Current period ends`, `Update subscription truth`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. Checkout or vendor terms remain out of scope.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The system workspace detail page is the one primary commercial-truth decision surface. Workspace settings remains a read-only context surface. Onboarding and review-pack surfaces remain contextual decision points that only expose the current lifecycle result.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing system detail, singleton settings, onboarding wizard, and grouped review-pack action patterns. It may not add a second admin-plane commercial management surface, redundant inspect actions, or mixed catch-all action groups.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Subscription mutation stays on the system workspace detail page. Workspace settings remains read-only. Onboarding completion stays the primary activation action. Review-pack generation stays the primary reporting mutation where already present.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin subscription resolver plus lifecycle mapping is justified because direct reads from settings and manual lifecycle state cannot express subscription-backed truth. Tests must prove business outcomes such as mapping, fallback, allowed versus blocked execution, and auditability rather than cosmetic rendering alone.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with the documented system detail exception for one bounded mutation action, the existing singleton settings exception for read-only context, the existing onboarding wizard exception, and the existing review-pack action family. No empty action groups or redundant inspect actions are introduced.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** The system detail page remains the one mutation surface, and workspace settings remains the read-only summary surface. The feature does not create a new commercial shell or duplicate summary page.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-274-001 Workspace-owned subscription truth**: The system MUST persist one current subscription record per workspace in a dedicated workspace-owned table rather than as additional workspace-setting keys.
|
||||
- **FR-274-002 Bounded subscription-state catalog**: The current subscription record MUST use exactly these state identifiers in v1: `trial`, `active`, `past_due`, `cancel_at_period_end`, and `ended`.
|
||||
- **FR-274-003 Required commercial context fields**: The current subscription record MUST support a bounded set of commercial context fields: `billing_reference` (optional), `trial_ends_at` (required for `trial`), `current_period_starts_at` and `current_period_ends_at` (required for `active`, `past_due`, and `cancel_at_period_end`), `current_period_ends_at` (required for `ended`), and `status_reason` (required on every explicit mutation).
|
||||
- **FR-274-004 Platform-managed mutation**: Only authorized platform users MAY create or update current subscription truth in this slice, the system-plane mutation MUST remain confirmation-protected, and workspace or tenant admin users MUST NOT gain self-service mutation controls.
|
||||
- **FR-274-005 Auditability**: Every create or update of current subscription truth MUST record old state, new state, actor, and status reason through the existing audit foundation.
|
||||
- **FR-274-006 Lifecycle derivation precedence**: `WorkspaceCommercialLifecycleResolver` MUST derive the effective lifecycle state from current subscription truth when a subscription record exists for the workspace.
|
||||
- **FR-274-007 Lifecycle fallback**: If no current subscription record exists for a workspace, `WorkspaceCommercialLifecycleResolver` MUST preserve the current Spec 251 fallback behavior by using existing manual lifecycle state or default `active_paid`, and the existing system-plane `Change commercial state` action remains available only in that fallback condition.
|
||||
- **FR-274-008 Deterministic lifecycle mapping**: The lifecycle resolver MUST map subscription states as follows: `trial` -> `trial`, `active` -> `active_paid`, `past_due` -> `grace`, `cancel_at_period_end` -> `active_paid`, and `ended` -> `suspended_read_only`.
|
||||
- **FR-274-009 No second runtime gate**: Onboarding activation and review-pack generation MUST continue consulting the shared lifecycle resolver only. Subscription truth MUST NOT create a second direct gate on those surfaces.
|
||||
- **FR-274-010 Stale review-required visibility**: If a `trial` record has a past `trial_ends_at` date or a `cancel_at_period_end` record has a past `current_period_ends_at` date, the system workspace detail page MUST surface that the subscription record needs review, but v1 MUST NOT auto-transition the state.
|
||||
- **FR-274-011 System workspace visibility**: The existing system workspace detail page MUST show current subscription state, derived lifecycle state, next relevant date, fallback status, reference, and last changed attribution to authorized platform users.
|
||||
- **FR-274-012 Workspace settings visibility**: The existing workspace settings page MUST show a read-only summary of the current commercial posture, explicitly indicating whether it is subscription-backed or fallback-backed.
|
||||
- **FR-274-013 Onboarding activation continuity**: Managed-tenant onboarding activation MUST keep using the lifecycle decision after subscription mapping, stopping before tenant activation when the lifecycle outcome blocks the action.
|
||||
- **FR-274-014 Review-pack start continuity**: `Generate pack`, `Regenerate`, and `Export executive pack` MUST keep using the lifecycle decision after subscription mapping, stopping before any new `ReviewPack` or `OperationRun` is created when the lifecycle outcome blocks the action.
|
||||
- **FR-274-015 Existing artifact access unchanged**: This slice MUST NOT change existing view or download access to already-generated review packs, evidence, or review history that remain accessible under current RBAC and lifecycle rules.
|
||||
- **FR-274-016 One current record only**: V1 MUST support exactly one current subscription record per workspace and rely on audit history rather than a multi-row subscription ledger or browsing surface.
|
||||
- **FR-274-017 Bounded non-goals**: This slice MUST NOT introduce invoices, payment collection, taxes, checkout, website pricing, vendor-specific billing adapters, webhook automation, customer portals, or a second commercial control plane.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Platform workspace subscription truth section | `app/Filament/System/Pages/Directory/ViewWorkspace.php` | none on collection | dedicated detail route only | none | none | N/A | `Update subscription truth` and, only when no subscription record exists, fallback `Change commercial state`; both remain confirmation-protected | N/A | yes | Existing system-detail exception remains bounded to one platform mutation surface |
|
||||
| Workspace settings read-only subscription summary | `app/Filament/Pages/Settings/WorkspaceSettings.php` | `Save` remains owned by settings, but subscription summary is read-only | N/A - singleton settings page | none | none | N/A | none | existing settings save or cancel behavior unchanged; no subscription edit controls | no new audit event; read-only only | Existing singleton-page exception remains valid |
|
||||
| Managed tenant onboarding completion gate | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | existing back-navigation and tenant links | N/A - guided workflow | none | none | existing onboarding start state unchanged | `Complete onboarding` remains the primary action and stays lifecycle-gated | N/A | yes - existing onboarding audit semantics remain | Existing wizard exception remains valid |
|
||||
| Review-pack generation entry family | current tenant dashboard, review register, tenant review detail, and review-pack detail or registry surfaces | existing `Generate pack`, `Regenerate`, and `Export executive pack` actions stay primary where already present | existing registry/detail affordances remain unchanged | existing `View` and `Download` shortcuts remain secondary where already present | none | existing `Generate` CTA remains where already present | existing start actions remain lifecycle-gated; `View` and `Download` stay outside the start gate | N/A | no new audit requirement for blocked attempts | Existing grouped action family remains authoritative |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **WorkspaceSubscription**: One workspace-owned current subscription record containing bounded subscription state, current commercial dates, optional reference, and status reason.
|
||||
- **WorkspaceSubscriptionSummary**: A derived read model that combines current subscription truth, fallback status, and next relevant date for system and admin surfaces.
|
||||
- **EffectiveCommercialLifecycleDecision**: The existing shared lifecycle decision, now potentially sourced from current subscription truth before it reaches onboarding and review-pack entry surfaces.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Authorized platform operators can inspect and update a workspace's current subscription truth from one system workspace detail surface and see the updated derived commercial posture immediately afterward.
|
||||
- **SC-002**: Authorized workspace operators can determine from the settings page in under 30 seconds whether the current commercial posture is subscription-backed or fallback-backed and what the next relevant date is.
|
||||
- **SC-003**: 100% of onboarding and review-pack decisions for workspaces with a current subscription record follow the derived lifecycle mapping from that record rather than stale manual lifecycle state.
|
||||
- **SC-004**: The feature ships without adding a second runtime gate, a new run family, a payment-provider integration, or a customer-facing billing surface.
|
||||
184
specs/274-billing-subscription-truth/tasks.md
Normal file
184
specs/274-billing-subscription-truth/tasks.md
Normal file
@ -0,0 +1,184 @@
|
||||
---
|
||||
description: "Task list for Billing & Subscription Truth Layer v1"
|
||||
---
|
||||
|
||||
# Tasks: Billing & Subscription Truth Layer v1
|
||||
|
||||
**Input**: Design documents from `specs/274-billing-subscription-truth/`
|
||||
**Prerequisites**: `specs/274-billing-subscription-truth/spec.md`, `specs/274-billing-subscription-truth/plan.md`, `specs/274-billing-subscription-truth/checklists/requirements.md`, `specs/274-billing-subscription-truth/research.md`, `specs/274-billing-subscription-truth/data-model.md`, `specs/274-billing-subscription-truth/quickstart.md`, `specs/274-billing-subscription-truth/contracts/workspace-billing-subscription-truth.logical.openapi.yaml`
|
||||
|
||||
**Tests**: REQUIRED (Pest). Keep proof bounded to one new `Unit` family under `tests/Unit/Entitlements/` plus focused extensions to current `Feature` families for system, settings, onboarding, and review-pack behavior.
|
||||
**Operations**: Reuse the existing `WorkspaceCommercialLifecycleResolver` and current review-pack `OperationRun` path. No new run type, no queue family, and no direct subscription gate outside lifecycle are allowed.
|
||||
**RBAC**: Non-members and wrong-plane actors remain `404`; in-scope actors missing capability remain `403`. `/system` owns mutation; `/admin` remains read-only or contextual for subscription truth.
|
||||
**Shared Pattern Reuse**: Reuse `WorkspaceEntitlementResolver`, `WorkspaceCommercialLifecycleResolver`, `ViewWorkspace`, `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, and the current audit foundation. Do not create a second commercial control plane.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed.
|
||||
**Organization**: Tasks are grouped by user story so the source-of-truth entity, lifecycle continuity, and read-only admin summary remain independently implementable and testable. This package is a bounded follow-through over Specs 247 and 251, not a billing-engine rewrite.
|
||||
**Review Outcome**: `acceptable-special-case`
|
||||
**Workflow Outcome**: `keep`
|
||||
**Test-governance Outcome**: `keep`
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment stays `fast-feedback` and `confidence` and remains the narrowest sufficient proof.
|
||||
- [x] New or changed tests stay in the existing `apps/platform/tests/Unit/Entitlements/`, `apps/platform/tests/Feature/System/`, `apps/platform/tests/Feature/Filament/Settings/`, `apps/platform/tests/Feature/Onboarding/`, and `apps/platform/tests/Feature/ReviewPack/` families.
|
||||
- [x] Shared helpers stay cheap by default; only one new factory is expected.
|
||||
- [x] Planned validation commands cover subscription truth, lifecycle precedence, admin summary, and gate continuity without widening into browser or heavy-governance lanes.
|
||||
- [x] The declared surface test profile remains `standard-native-filament`, `shared-detail-family`, and `monitoring-state-page` only.
|
||||
- [x] Any drift toward providers, invoices, a portal, or a second runtime gate resolves as `reject-or-split`, not hidden scope.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Confirm the current commercial truth seams before any implementation change.
|
||||
|
||||
- [x] T001 Review `specs/274-billing-subscription-truth/spec.md`, `specs/274-billing-subscription-truth/plan.md`, `specs/274-billing-subscription-truth/checklists/requirements.md`, `specs/274-billing-subscription-truth/research.md`, `specs/274-billing-subscription-truth/data-model.md`, `specs/274-billing-subscription-truth/quickstart.md`, `specs/247-plans-entitlements-billing-readiness/spec.md`, and `specs/251-commercial-entitlements-billing-state/spec.md` together so the slice stays on the current commercial foundations.
|
||||
- [x] T002 [P] Confirm the current lifecycle and admin/system surface seams in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`.
|
||||
- [x] T003 [P] Confirm the current runtime gate seams in `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `apps/platform/app/Services/ReviewPackService.php`.
|
||||
- [x] T004 [P] Confirm the current audit and authorization seams in `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, and `apps/platform/app/Services/Settings/SettingsWriter.php`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Lock the new persisted source of truth and shared mapping before surface changes begin.
|
||||
|
||||
**Critical**: No user-story work should begin until this phase is complete.
|
||||
|
||||
- [x] T005 [P] Add failing unit coverage in `apps/platform/tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php` and extend `apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` to prove state validation, lifecycle mapping, fallback precedence, and review-required date behavior.
|
||||
- [x] T006 [P] Extend `apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` to lock the system mutation surface, admin read-only summary, and plane semantics.
|
||||
- [x] T007 [P] Extend `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, and `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php` to prove subscription-backed lifecycle continuity and no-run behavior on blocked starts.
|
||||
- [x] T008 Create `apps/platform/database/migrations/*_create_workspace_subscriptions_table.php`, `apps/platform/app/Models/WorkspaceSubscription.php`, `apps/platform/database/factories/WorkspaceSubscriptionFactory.php`, and the `Workspace` relation so one current subscription record exists per workspace.
|
||||
- [x] T009 Implement `apps/platform/app/Services/Entitlements/WorkspaceSubscriptionResolver.php` to expose current subscription summary, fallback status, next relevant date, and derived lifecycle mapping.
|
||||
- [x] T010 Refactor `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` so subscription truth becomes the upstream lifecycle source when present and existing manual lifecycle state remains the fallback when absent.
|
||||
|
||||
**Checkpoint**: One persisted current subscription source exists and the shared lifecycle resolver can consume it without changing any surface yet.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Record one current workspace subscription truth centrally (Priority: P1)
|
||||
|
||||
**Goal**: Authorized platform users can create or update one current subscription record from the existing system workspace detail page.
|
||||
|
||||
**Independent Test**: Open the system workspace detail page, save subscription truth, and verify summary plus audit output update without touching onboarding or review-pack flows.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php` to prove create, update, validation, explicit confirmation, fallback visibility, and derived lifecycle rendering on the system page.
|
||||
- [x] T012 [P] [US1] Extend `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` to prove wrong-plane or non-member requests remain `404` and in-scope actors missing the dedicated capability remain `403`.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T013 [US1] Update `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` so the page renders current subscription truth, derived lifecycle, next relevant date, stale-date needs-review visibility, and one bounded confirmation-protected `Update subscription truth` action.
|
||||
- [x] T014 [US1] Extend `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and related audit metadata so subscription changes are attributable with old state, new state, actor, and status reason.
|
||||
- [x] T015 [US1] Narrow or hide the existing manual lifecycle mutation affordance on the system page whenever a current subscription record exists, preserving the existing confirmation-protected `Change commercial state` action only as explicit fallback behavior when no subscription record is present.
|
||||
|
||||
**Checkpoint**: The system workspace detail page becomes the one durable commercial source surface.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Keep current runtime gates but source them from subscription truth when present (Priority: P1)
|
||||
|
||||
**Goal**: Existing onboarding and review-pack behavior remains on one lifecycle gate while subscription-backed workspaces stop relying on manual lifecycle state.
|
||||
|
||||
**Independent Test**: Seed one subscription-backed workspace and one fallback workspace, then confirm both current gate families still use one lifecycle decision with the correct source and no second gate.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T016 [P] [US2] Extend `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php` to prove subscription-backed lifecycle mapping, fallback continuity, and business-state messaging on the completion step.
|
||||
- [x] T017 [P] [US2] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` to prove subscription-backed lifecycle mapping, no-run behavior on blocked starts, suppressed queued or terminal notifications when blocked, unchanged queued-start UX when allowed, and unchanged current pack view or download access.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T018 [US2] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` so the current completion summary identifies subscription-backed versus fallback lifecycle truth without adding direct subscription checks.
|
||||
- [x] T019 [US2] Update `apps/platform/app/Services/ReviewPackService.php` and any current review-pack start helpers so the existing lifecycle gate consumes the new source but keeps current start semantics unchanged.
|
||||
- [x] T020 [US2] Review all in-scope gate surfaces and remove any local or duplicate subscription-state checks so lifecycle remains the only runtime gate.
|
||||
|
||||
**Checkpoint**: Subscription truth changes the upstream lifecycle source only; runtime gates stay singular and unchanged in shape.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Show a read-only commercial summary on workspace settings (Priority: P2)
|
||||
|
||||
**Goal**: Workspace operators can inspect the current commercial posture without gaining billing controls.
|
||||
|
||||
**Independent Test**: Open workspace settings as an authorized member and verify the summary shows subscription-backed or fallback-backed truth, the next relevant date, and no mutation controls.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T021 [P] [US3] Extend `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` to prove the new read-only subscription summary, fallback indicator, and absence of admin-plane mutation controls.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T022 [US3] Update `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` so the existing page renders the read-only subscription summary using the shared resolver output.
|
||||
- [x] T023 [US3] Confirm the admin-plane summary stays read-only and does not create a second commercial mutation surface or local commercial vocabulary.
|
||||
|
||||
**Checkpoint**: Workspace operators can understand the current posture without inheriting a new billing UI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: Validate the bounded slice and stop without widening scope.
|
||||
|
||||
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`.
|
||||
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`.
|
||||
- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`.
|
||||
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [x] T028 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, no asset strategy changes appear, and no second runtime gate slipped in.
|
||||
- [x] T029 [P] Record the final guardrail and test-governance outcome in the active feature close-out without reopening provider sync, invoices, portal work, or a second control plane.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the durable source of truth.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should land with US1 so the new source and current runtime gates stay aligned.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and should land after US1 so the admin summary consumes the finished shared resolver.
|
||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and delivers the central source-of-truth surface.
|
||||
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so subscription truth actually changes runtime source.
|
||||
- **US3 (P2)**: independently testable after Phase 2 and can follow US1 once the shared resolver output is stable.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Keep implementation inside the current model, resolver, system page, settings page, onboarding, review-pack, and audit seams named above.
|
||||
- Re-run the narrowest relevant validation command after each story checkpoint before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2 together**. The feature is only useful when a current subscription source exists and the existing lifecycle gate actually consumes it.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1 so the durable source and system mutation surface exist.
|
||||
3. Deliver US2 so current gates become subscription-backed where appropriate.
|
||||
4. Add US3 as the read-only admin summary.
|
||||
5. Finish with the focused validation and drift-review tasks in Phase 6.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle persistence and resolver shape first.
|
||||
2. Parallelize failing tests within each story before runtime edits.
|
||||
3. Serialize merges around `ViewWorkspace`, `WorkspaceCommercialLifecycleResolver`, and `WorkspaceSettings` so the commercial vocabulary stays coherent.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- payment-provider or webhook synchronization
|
||||
- invoice or payment ledger persistence
|
||||
- customer-facing billing portal or workspace self-serve billing controls
|
||||
- schedule-driven trial or period-end transitions
|
||||
- a historical subscription browser or multi-record billing timeline
|
||||
@ -0,0 +1,54 @@
|
||||
# Specification Quality Checklist: Customer-Facing Localization Adoption v1
|
||||
|
||||
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
|
||||
**Created**: 2026-05-04
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The package stays on one bounded customer-facing localization adoption pass over existing localization and customer-review foundations instead of inventing a new locale system, panel, or auth plane.
|
||||
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
|
||||
- [x] The package explicitly names the repo-real anchors it builds on: `LocaleResolver`, `LocalizationController`, the existing EN/DE catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, and the current review-pack and evidence paths.
|
||||
- [x] Mandatory repo sections for scope, RBAC, shared-pattern reuse, testing, proportionality, and candidate rationale are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No `[NEEDS CLARIFICATION]` markers remain.
|
||||
- [x] Requirements are testable and bounded to existing customer review workspace and released-review detail adoption, glossary completion, localized access messaging, and machine-artifact invariance.
|
||||
- [x] The package makes English fallback and no-raw-key behavior explicit.
|
||||
- [x] The package forbids website localization, export or audit localization, new locale infrastructure, and panel or auth-plane expansion.
|
||||
- [x] Canonical proof commands match across `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and `docs/product/implementation-ledger.md` as `Customer-Facing Localization Adoption v1`.
|
||||
- [x] Related anchor specs were checked for completion or close-out signals and treated as context only: Specs 252 and 258 are completed context, and Spec 260 is related context only.
|
||||
- [x] The chosen slice is smaller and more bounded than deferred alternatives such as Enterprise Access Boundary & Support Access Governance v1, Stored Reports Surface v1, Workspace & Tenant Closure Lifecycle v1, or a broader operator-wide localization program.
|
||||
- [x] The selected slice explicitly closes the remaining customer-facing localization productization gap without reopening the completed localization foundation.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The package keeps Filament on Livewire v4, provider registration unchanged in `apps/platform/bootstrap/providers.php`, global search unchanged, and assets unchanged.
|
||||
- [x] The package keeps the current admin-plane workspace page and tenant-scoped released-review detail as the only in-scope surfaces.
|
||||
- [x] The package keeps review-pack downloads, evidence proof paths, audit rows, JSON payloads, and other machine artifacts unlocalized.
|
||||
- [x] The package explicitly defers broader operator-wide localization, website localization, localized artifact contents, and new locale infrastructure.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned proof stays bounded to focused `Feature` coverage plus one existing `Browser` smoke.
|
||||
- [x] No new heavy-governance family or new browser family is introduced by default.
|
||||
- [x] Fixture growth remains bounded to existing workspace membership, tenant entitlement, released review, current review-pack, and locale helper families.
|
||||
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into `plan.md` and `tasks.md`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/product/implementation-ledger.md`, `specs/252-platform-localization-v1/spec.md`, `specs/258-customer-review-productization/spec.md`, `specs/260-governance-service-packaging/spec.md`, and the active 275 prep artifacts on 2026-05-04.
|
||||
- No application implementation was performed while preparing this package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Reason**: The package promotes the remaining customer-facing localization productization gap as one bounded follow-through over repo-real localization and review foundations. It keeps the current locale chain, current customer-safe review surfaces, and current artifact truth, and it explicitly blocks website localization, export or audit localization, and new locale infrastructure.
|
||||
- **Workflow result**: Ready for implementation.
|
||||
@ -0,0 +1,471 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: TenantPilot Customer-Facing Localization Adoption v1 (Conceptual)
|
||||
version: 0.1.0
|
||||
summary: Conceptual contract for localized customer-review surface adoption over existing locale and review foundations.
|
||||
description: |
|
||||
These paths document the existing locale endpoints and current customer-review
|
||||
surfaces reused by the implementation. The schemas describe the expected
|
||||
localized view behavior and invariance boundaries for planning purposes only;
|
||||
they do not introduce a new public API or a new artifact family.
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/localization/context:
|
||||
get:
|
||||
summary: Resolve the effective locale for the current request.
|
||||
operationId: resolveLocalizationContext
|
||||
description: |
|
||||
Reuses the existing locale precedence chain over explicit override,
|
||||
user preference, workspace default, and system default.
|
||||
responses:
|
||||
'200':
|
||||
description: Effective locale context for the current request.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResolvedLocaleContext'
|
||||
|
||||
/localization/override:
|
||||
post:
|
||||
summary: Set the explicit temporary locale override.
|
||||
operationId: updateExplicitLocaleOverride
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LocaleOverrideUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: Locale feedback result after the override becomes effective.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LocaleFeedbackResult'
|
||||
'400':
|
||||
description: Unsupported locale input was rejected.
|
||||
delete:
|
||||
summary: Clear the explicit temporary locale override.
|
||||
operationId: clearExplicitLocaleOverride
|
||||
responses:
|
||||
'200':
|
||||
description: Locale feedback result after the override is cleared.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LocaleFeedbackResult'
|
||||
|
||||
/users/me/locale-preference:
|
||||
post:
|
||||
summary: Persist the authenticated user's locale preference.
|
||||
operationId: updateUserLocalePreference
|
||||
description: |
|
||||
Reuses the existing personal preference path for the workspace-bound user.
|
||||
The slice does not add a second preference store or a new auth plane.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserLocalePreferenceUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: Locale feedback result after saving or clearing the preference.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LocaleFeedbackResult'
|
||||
'400':
|
||||
description: Unsupported locale input was rejected.
|
||||
'404':
|
||||
description: The preference path is unavailable outside the current authenticated workspace-user context.
|
||||
|
||||
/admin/reviews/workspace:
|
||||
get:
|
||||
summary: View the localized customer review workspace.
|
||||
operationId: viewLocalizedCustomerReviewWorkspace
|
||||
description: |
|
||||
Existing admin-plane workspace page reused as the primary decision
|
||||
surface for the customer-safe review flow. The route remains read-only
|
||||
and keeps the current tenant filter and row-action behavior.
|
||||
parameters:
|
||||
- in: query
|
||||
name: tenant
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Optional existing tenant prefilter using tenant id or external id.
|
||||
responses:
|
||||
'200':
|
||||
description: Localized workspace page rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LocalizedCustomerReviewWorkspaceModel'
|
||||
'404':
|
||||
description: Not found for non-members, actors without entitled tenants, or explicit out-of-scope tenant targeting.
|
||||
|
||||
/admin/t/{tenant}/reviews/{review}:
|
||||
get:
|
||||
summary: View the localized released-review detail from the customer workspace.
|
||||
operationId: viewLocalizedCustomerReviewDetail
|
||||
description: |
|
||||
Existing tenant-scoped review detail route reused as the secondary
|
||||
context surface. The existing `customer_workspace=1` query flag keeps the
|
||||
surface read-only and customer-safe.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: path
|
||||
name: review
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: customer_workspace
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
description: Existing query-context flag for customer-workspace mode.
|
||||
responses:
|
||||
'200':
|
||||
description: Localized released-review detail rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LocalizedCustomerReviewDetailModel'
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing the record-level review permission.
|
||||
'404':
|
||||
description: Not found for tenant mismatches, non-members, or out-of-scope review targets.
|
||||
|
||||
/admin/review-packs/{reviewPack}/download:
|
||||
get:
|
||||
summary: Download the current governance-package artifact.
|
||||
operationId: downloadCurrentGovernancePackage
|
||||
description: |
|
||||
Reuses the existing signed review-pack download path. Localization may
|
||||
change the surrounding UI labels and reasons, but the artifact bytes and
|
||||
machine-readable payload remain unchanged.
|
||||
parameters:
|
||||
- in: path
|
||||
name: reviewPack
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Current review-pack artifact streamed unchanged.
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'403':
|
||||
description: Forbidden because of invalid signature or inaccessible pack download permission.
|
||||
'404':
|
||||
description: Review pack not found, unavailable for the current review flow, expired, or outside the accessible tenant scope. Supporting UI reasons such as missing or not-ready remain derived under the existing unavailable state.
|
||||
|
||||
/admin/t/{tenant}/evidence/{evidenceSnapshot}:
|
||||
get:
|
||||
summary: Open supporting proof from the localized customer review flow.
|
||||
operationId: viewLocalizedCustomerReviewProof
|
||||
description: |
|
||||
Reuses the existing evidence detail route only after explicit drilldown.
|
||||
The supporting proof path stays secondary and capability-gated.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: path
|
||||
name: evidenceSnapshot
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Evidence proof detail rendered.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing the evidence capability.
|
||||
'404':
|
||||
description: Not found for non-members, mismatched tenant scope, or unavailable proof targets.
|
||||
|
||||
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
|
||||
LocaleOverrideUpdate:
|
||||
type: object
|
||||
required:
|
||||
- locale
|
||||
properties:
|
||||
locale:
|
||||
$ref: '#/components/schemas/SupportedLocale'
|
||||
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.
|
||||
LocaleFeedbackResult:
|
||||
type: object
|
||||
required:
|
||||
- resolved_locale
|
||||
- status_message
|
||||
- machine_artifacts_invariant
|
||||
properties:
|
||||
resolved_locale:
|
||||
$ref: '#/components/schemas/SupportedLocale'
|
||||
source:
|
||||
$ref: '#/components/schemas/LocaleSource'
|
||||
status_message:
|
||||
type: string
|
||||
machine_artifacts_invariant:
|
||||
type: boolean
|
||||
const: true
|
||||
LocalizedAccessMessage:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- label
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- available
|
||||
- partial
|
||||
- unavailable
|
||||
- blocked
|
||||
- expired
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
translation_key:
|
||||
type: string
|
||||
nullable: true
|
||||
CustomerFacingGlossaryBoundary:
|
||||
type: object
|
||||
required:
|
||||
- in_scope_terms
|
||||
- out_of_scope_artifacts
|
||||
- fallback_locale
|
||||
properties:
|
||||
in_scope_terms:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- customer review
|
||||
- governance package
|
||||
- current review pack
|
||||
- proof access
|
||||
- next step
|
||||
- review status
|
||||
- evidence
|
||||
- accepted risk
|
||||
out_of_scope_artifacts:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- exported review-pack file contents
|
||||
- raw JSON payloads
|
||||
- audit action ids and metadata
|
||||
fallback_locale:
|
||||
type: string
|
||||
const: en
|
||||
LocalizedCustomerReviewWorkspaceEntry:
|
||||
type: object
|
||||
required:
|
||||
- tenant_id
|
||||
- tenant_name
|
||||
- governance_package
|
||||
- proof_access
|
||||
- primary_action_label
|
||||
properties:
|
||||
tenant_id:
|
||||
type: integer
|
||||
tenant_name:
|
||||
type: string
|
||||
latest_published_review_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
review_status_label:
|
||||
type: string
|
||||
nullable: true
|
||||
accepted_risk_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
governance_package:
|
||||
$ref: '#/components/schemas/LocalizedAccessMessage'
|
||||
proof_access:
|
||||
$ref: '#/components/schemas/LocalizedAccessMessage'
|
||||
next_step_label:
|
||||
type: string
|
||||
nullable: true
|
||||
primary_action_label:
|
||||
type: string
|
||||
primary_action_url:
|
||||
type: string
|
||||
nullable: true
|
||||
glossary_terms_rendered:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
LocalizedCustomerReviewWorkspaceModel:
|
||||
type: object
|
||||
required:
|
||||
- workspace_id
|
||||
- resolved_locale
|
||||
- glossary_scope
|
||||
- heading_copy
|
||||
- intro_copy
|
||||
- disclosure_copy
|
||||
- entries
|
||||
- empty_state_copy
|
||||
- untranslated_keys_detected
|
||||
- machine_artifacts_invariant
|
||||
- glossary_boundary
|
||||
properties:
|
||||
workspace_id:
|
||||
type: integer
|
||||
tenant_filter_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
resolved_locale:
|
||||
$ref: '#/components/schemas/SupportedLocale'
|
||||
glossary_scope:
|
||||
type: string
|
||||
const: customer_review
|
||||
heading_copy:
|
||||
type: string
|
||||
intro_copy:
|
||||
type: string
|
||||
disclosure_copy:
|
||||
type: string
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LocalizedCustomerReviewWorkspaceEntry'
|
||||
empty_state_copy:
|
||||
type: string
|
||||
nullable: true
|
||||
untranslated_keys_detected:
|
||||
type: boolean
|
||||
const: false
|
||||
machine_artifacts_invariant:
|
||||
type: boolean
|
||||
const: true
|
||||
glossary_boundary:
|
||||
$ref: '#/components/schemas/CustomerFacingGlossaryBoundary'
|
||||
LocalizedCustomerReviewDetailModel:
|
||||
type: object
|
||||
required:
|
||||
- review_id
|
||||
- tenant_id
|
||||
- resolved_locale
|
||||
- launched_from_customer_workspace
|
||||
- section_labels
|
||||
- governance_package
|
||||
- proof_access
|
||||
- operator_actions_hidden
|
||||
- machine_artifacts_invariant
|
||||
- glossary_boundary
|
||||
properties:
|
||||
review_id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
resolved_locale:
|
||||
$ref: '#/components/schemas/SupportedLocale'
|
||||
launched_from_customer_workspace:
|
||||
type: boolean
|
||||
const: true
|
||||
dominant_action_label:
|
||||
type: string
|
||||
nullable: true
|
||||
section_labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
accepted_risk_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
governance_package:
|
||||
$ref: '#/components/schemas/LocalizedAccessMessage'
|
||||
proof_access:
|
||||
$ref: '#/components/schemas/LocalizedAccessMessage'
|
||||
helper_copy:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
glossary_terms_rendered:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
operator_actions_hidden:
|
||||
type: boolean
|
||||
const: true
|
||||
machine_artifacts_invariant:
|
||||
type: boolean
|
||||
const: true
|
||||
glossary_boundary:
|
||||
$ref: '#/components/schemas/CustomerFacingGlossaryBoundary'
|
||||
282
specs/275-customer-facing-localization-adoption/data-model.md
Normal file
282
specs/275-customer-facing-localization-adoption/data-model.md
Normal file
@ -0,0 +1,282 @@
|
||||
# Data Model — Customer-Facing Localization Adoption v1
|
||||
|
||||
**Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/spec.md`
|
||||
|
||||
No new persisted tables, locale stores, or glossary registries are required for this feature. The slice reuses existing locale truth, current review and artifact truth, and current translation catalogs, then tightens the derived localized view contracts for the customer-facing review flow.
|
||||
|
||||
## Supported Locale Set
|
||||
|
||||
| Value | Meaning | Notes |
|
||||
|---|---|---|
|
||||
| `en` | English | Controlled fallback locale for this slice |
|
||||
| `de` | German | Only additional supported locale in scope |
|
||||
|
||||
## Reused Source Truth
|
||||
|
||||
### Locale Context
|
||||
|
||||
**Purpose**: Resolve the effective locale for the current customer-facing review request.
|
||||
|
||||
**Reused carriers**:
|
||||
- session override `tenantpilot.locale_override`
|
||||
- `users.preferred_locale`
|
||||
- existing workspace setting `localization.default_locale`
|
||||
- `config('app.fallback_locale')`
|
||||
|
||||
**Relevant fields**:
|
||||
- `locale`
|
||||
- `source`
|
||||
- `fallback_locale`
|
||||
- `user_preference_locale`
|
||||
- `workspace_default_locale`
|
||||
- `machine_artifacts_invariant`
|
||||
|
||||
**Validation rules**:
|
||||
- the only supported values remain `en` and `de`
|
||||
- unsupported input falls back through the existing precedence chain until a supported locale is found
|
||||
- locale changes affect only rendered copy and feedback, not authorization or machine-readable artifacts
|
||||
|
||||
### Workspace / Tenant Entitlement Context
|
||||
|
||||
**Purpose**: Keep the current workspace boundary and entitled tenant set authoritative before any localized workspace row or released-review detail is composed.
|
||||
|
||||
**Reused carriers**:
|
||||
- existing workspace membership rows
|
||||
- existing tenant membership and role assignments
|
||||
- existing capability registry and role-capability map
|
||||
- current workspace and remembered-tenant session context
|
||||
|
||||
**Validation rules**:
|
||||
- non-members remain `404`
|
||||
- only entitled tenants in the active workspace may appear in the localized customer review workspace
|
||||
- locale changes must not widen tenant or artifact visibility
|
||||
|
||||
### TenantReview
|
||||
|
||||
**Purpose**: Canonical source for the released customer review detail, current package truth, proof pointers, and existing summary copy inputs.
|
||||
|
||||
**Reused carrier**: existing `tenant_reviews` rows via `App\Models\TenantReview`
|
||||
|
||||
**Relevant fields / relationships**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `status`
|
||||
- `published_at`
|
||||
- `summary`
|
||||
- `evidence_snapshot_id`
|
||||
- `current_export_review_pack_id`
|
||||
- `tenant`
|
||||
- `evidenceSnapshot`
|
||||
- `currentExportReviewPack`
|
||||
- `sections`
|
||||
|
||||
**Validation rules**:
|
||||
- the customer-safe path continues to use the latest published review per entitled tenant only
|
||||
- localized wording may change, but the underlying review truth and route ownership do not
|
||||
- the released-review detail remains the only secondary context surface in customer-workspace mode
|
||||
|
||||
### ReviewPack and EvidenceSnapshot
|
||||
|
||||
**Purpose**: Existing supporting artifact truth for package availability, proof availability, and blocked or expired reasons.
|
||||
|
||||
**Reused carriers**:
|
||||
- existing `review_packs` rows via `App\Models\ReviewPack`
|
||||
- existing `evidence_snapshots` rows via `App\Models\EvidenceSnapshot`
|
||||
|
||||
**Relevant fields / relationships**:
|
||||
- `ReviewPack.status`
|
||||
- `ReviewPack.expires_at`
|
||||
- `ReviewPack.file_path`
|
||||
- `EvidenceSnapshot.status`
|
||||
- `EvidenceSnapshot.completeness_state`
|
||||
- `EvidenceSnapshot.expires_at`
|
||||
|
||||
**Validation rules**:
|
||||
- localized package and proof reasons stay derived from existing truth
|
||||
- signed review-pack download behavior stays unchanged
|
||||
- secondary proof access remains capability-gated and lower priority than the review summary
|
||||
|
||||
### Translation Catalog Assets
|
||||
|
||||
**Purpose**: Derived presentation assets that hold the approved customer-facing glossary and locale feedback copy.
|
||||
|
||||
**Reused carriers**:
|
||||
- `apps/platform/lang/en/localization.php`
|
||||
- `apps/platform/lang/de/localization.php`
|
||||
|
||||
**Relevant key families**:
|
||||
- `review.*`
|
||||
- `notifications.*`
|
||||
- `validation.*`
|
||||
|
||||
**Validation rules**:
|
||||
- in-scope customer-facing terms must resolve to approved EN or DE copy
|
||||
- missing in-scope German lines must fall back to English
|
||||
- raw translation keys must not appear on the default customer-safe path
|
||||
- provider-specific artifact names stay secondary and already-gated
|
||||
|
||||
### Audit / Machine Artifact Boundary
|
||||
|
||||
**Purpose**: Preserve stable machine truth while customer-facing copy changes.
|
||||
|
||||
**Reused carriers**:
|
||||
- existing `audit_logs`
|
||||
- current review-pack files and signed download responses
|
||||
- raw JSON and other machine-readable artifact payloads already produced by the product
|
||||
|
||||
**Validation rules**:
|
||||
- audit action IDs and metadata remain untranslated
|
||||
- review-pack file bytes, hashes, identifiers, timestamps, and raw payloads remain unchanged
|
||||
- localization changes only the surrounding UI language and feedback text
|
||||
|
||||
## Derived View Contracts
|
||||
|
||||
### LocalizedCustomerReviewWorkspacePageModel
|
||||
|
||||
**Purpose**: Request-time localized page contract for `/admin/reviews/workspace`.
|
||||
|
||||
**Persistence**: none; derived at request or Livewire render time
|
||||
|
||||
**Fields**:
|
||||
- `workspace_id`
|
||||
- `tenant_filter_id` (nullable)
|
||||
- `resolved_locale`
|
||||
- `heading_copy`
|
||||
- `intro_copy`
|
||||
- `disclosure_copy`
|
||||
- `entries[]`
|
||||
- `empty_state_copy` (nullable)
|
||||
- `untranslated_keys_detected = false`
|
||||
- `machine_artifacts_invariant = true`
|
||||
|
||||
**Validation rules**:
|
||||
- locale changes must not reset tenant filters or remembered workspace context
|
||||
- all default-visible copy must resolve from approved EN or DE lines
|
||||
- the page keeps one dominant row action: `Open review`
|
||||
|
||||
### LocalizedAccessMessage
|
||||
|
||||
**Purpose**: Reused localized access-state message object for governance-package and proof availability on the current customer-safe review flow.
|
||||
|
||||
**Persistence**: none; derived from existing review-pack and evidence truth
|
||||
|
||||
**Fields**:
|
||||
- `state`
|
||||
- `label`
|
||||
- `description` (nullable)
|
||||
- `translation_key` (nullable)
|
||||
|
||||
**Validation rules**:
|
||||
- the only allowed states stay inside the existing derived disclosure set: `available`, `partial`, `unavailable`, `blocked`, and `expired`
|
||||
- any current not-ready wording remains a reason-level description under the existing `unavailable` state instead of becoming a new state family
|
||||
- the optional `translation_key` is trace-only and must never leak to the rendered customer-safe surface
|
||||
|
||||
### LocalizedCustomerReviewWorkspaceEntry
|
||||
|
||||
**Purpose**: Localized row-level summary for one entitled tenant in the customer review workspace.
|
||||
|
||||
**Persistence**: none; derived from existing tenant, review, pack, and proof truth
|
||||
|
||||
**Fields**:
|
||||
- `tenant_id`
|
||||
- `tenant_name`
|
||||
- `latest_published_review_id` (nullable)
|
||||
- `review_status_label`
|
||||
- `accepted_risk_summary` (nullable)
|
||||
- `governance_package` (`LocalizedAccessMessage`)
|
||||
- `proof_access` (`LocalizedAccessMessage`)
|
||||
- `next_step_label` (nullable)
|
||||
- `primary_action_label`
|
||||
- `primary_action_url` (nullable)
|
||||
|
||||
**Validation rules**:
|
||||
- optional package or proof restrictions must not hide the readable review row
|
||||
- accepted-risk summary copy must use the same approved glossary as the released-review detail
|
||||
- glossary terms must match the released-review detail surface
|
||||
- untranslated or mixed-language states are treated as defects, not acceptable partials
|
||||
|
||||
### LocalizedCustomerReviewDetailModel
|
||||
|
||||
**Purpose**: Request-time localized detail contract for `/admin/t/{tenant}/reviews/{review}` when launched from the customer review workspace.
|
||||
|
||||
**Persistence**: none; derived from the existing review record and current `customer_workspace` query flag
|
||||
|
||||
**Fields**:
|
||||
- `review_id`
|
||||
- `tenant_id`
|
||||
- `resolved_locale`
|
||||
- `launched_from_customer_workspace`
|
||||
- `dominant_action_label`
|
||||
- `section_labels[]`
|
||||
- `accepted_risk_summary` (nullable)
|
||||
- `governance_package` (`LocalizedAccessMessage`)
|
||||
- `proof_access` (`LocalizedAccessMessage`)
|
||||
- `helper_copy[]`
|
||||
- `operator_actions_hidden = true`
|
||||
- `machine_artifacts_invariant = true`
|
||||
|
||||
**Validation rules**:
|
||||
- customer-workspace mode remains read-only
|
||||
- `Download governance package` remains the single dominant header action when available
|
||||
- detail section labels and accepted-risk summary must align with the workspace glossary
|
||||
- localized helper text must not invent new artifact or workflow states
|
||||
|
||||
### LocalizedLocaleFeedbackMessage
|
||||
|
||||
**Purpose**: Existing session-backed feedback contract returned by locale override and personal preference updates.
|
||||
|
||||
**Persistence**: session flash only
|
||||
|
||||
**Fields**:
|
||||
- `route_name`
|
||||
- `resolved_locale`
|
||||
- `status_message`
|
||||
|
||||
**Validation rules**:
|
||||
- feedback renders in the newly effective locale
|
||||
- unsupported locale input uses the existing localized validation message path
|
||||
- no new notification framework is introduced
|
||||
|
||||
### CustomerFacingGlossaryInventory
|
||||
|
||||
**Purpose**: Planning-only inventory of the terms that must stay aligned across workspace, detail, and locale-feedback surfaces.
|
||||
|
||||
**Persistence**: none; maintained as catalog coverage, not a new table or registry
|
||||
|
||||
**Core terms**:
|
||||
- `customer review`
|
||||
- `governance package`
|
||||
- `current review pack`
|
||||
- `proof access`
|
||||
- `next step`
|
||||
- `review status`
|
||||
- `evidence`
|
||||
- `accepted risk`
|
||||
- `customer-safe detail`
|
||||
|
||||
**Validation rules**:
|
||||
- the same term should not appear with conflicting customer-facing synonyms across workspace and detail
|
||||
- out-of-scope artifacts such as raw JSON, audit payloads, and export file contents remain outside the glossary inventory
|
||||
|
||||
## Derived Disclosure States
|
||||
|
||||
This slice introduces no new persisted enum or state family. It reuses the current customer-facing labels and reasons as derived presentation only:
|
||||
|
||||
- `available`
|
||||
- `partial`
|
||||
- `unavailable`
|
||||
- `blocked`
|
||||
- `expired`
|
||||
|
||||
These remain localized labels and helper text over existing review-pack and proof truth. They do not become new stored lifecycle values.
|
||||
|
||||
## Invariance Boundaries
|
||||
|
||||
The following remain unlocalized in v1:
|
||||
|
||||
- review-pack file contents and hashes
|
||||
- raw JSON and machine-readable payloads
|
||||
- audit action identifiers and metadata keys or values
|
||||
- identifiers, slugs, route parameters, and query semantics
|
||||
- workspace and tenant entitlement outcomes
|
||||
276
specs/275-customer-facing-localization-adoption/plan.md
Normal file
276
specs/275-customer-facing-localization-adoption/plan.md
Normal file
@ -0,0 +1,276 @@
|
||||
# Implementation Plan: Customer-Facing Localization Adoption v1
|
||||
|
||||
**Branch**: `275-customer-facing-localization-adoption` | **Date**: 2026-05-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Complete one bounded customer-facing localization adoption pass over the existing customer review flow. The narrow implementation path is to reuse the repo-real locale precedence and update endpoints in `LocaleResolver` and `LocalizationController`, keep the current customer review workspace and released-review detail as the only in-scope read surfaces, fill the remaining EN/DE glossary and helper-copy gaps in the existing `localization.review.*` catalogs, and preserve current review-pack, proof, entitlement, audit, and machine-artifact behavior unchanged.
|
||||
|
||||
This is prep-only work over the foundations from Specs 252, 258, and 260. Filament remains v5 on Livewire v4, provider registration stays unchanged in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/providers.php`, global-search behavior remains unchanged, no destructive action is introduced, and no new panel, website-localization lane, export/audit localization lane, or generic locale/plugin framework is planned.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Laravel translator, existing `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, current `localization.review.*` and locale feedback catalogs, `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, current review-pack and evidence resource paths, shared RBAC and audit helpers
|
||||
**Storage**: PostgreSQL via existing `users.preferred_locale`, existing workspace localization setting, existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, memberships, and `audit_logs`; translation catalogs in `apps/platform/lang/en` and `apps/platform/lang/de`; no new persistence planned
|
||||
**Testing**: Pest v4 feature coverage plus one existing browser smoke on the current customer review flow
|
||||
**Validation Lanes**: confidence, browser
|
||||
**Target Platform**: Laravel monolith in `apps/platform`, existing admin plane (`/admin`) plus current tenant-scoped review detail reuse (`/admin/t/{tenant}/...`)
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: keep locale adoption request-local and DB-only, avoid extra remote calls, reuse current eager-loaded review/pack/evidence relations, and leave review-pack artifact generation and download payloads unchanged
|
||||
**Constraints**: scope stays on existing EN/DE customer-facing review surfaces only; no new panel/provider/auth plane, no website localization, no export/JSON/audit artifact localization, no generic locale framework, no new global-search behavior, and no new destructive or mutating customer-workspace action
|
||||
**Scale/Scope**: one locale foundation reuse path, one customer review workspace page, one released-review detail route in customer-workspace mode, existing review-pack and proof availability messaging, two existing EN/DE catalogs, and focused reuse of the current localization, reviews, tenant-review, and browser test families
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php` for the existing locale precedence contract reused by the customer-facing review flow.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/LocalizationController.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/routes/web.php` for the existing context, override, and personal preference endpoints that already format locale feedback.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` for localized row labels, empty-state copy, helper text, and glossary consistency on the canonical landing surface.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` for the current localized intro and disclosure copy rendered above the workspace table.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` for localized detail labels, package and proof availability wording, and customer-workspace summary copy already composed from existing review truth.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` for the read-only `customer_workspace` detail mode and the existing `Download governance package` dominant action.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` as reuse-only supporting seams for invariant pack and payload behavior and explicit proof-access messaging.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php` as the canonical glossary assets to complete in place.
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalizedNotificationFormattingTest.php`, planned `CustomerReviewSurfaceLocalizationTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` as the bounded proving path for locale adoption.
|
||||
|
||||
## Filament v5 / Panel Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The slice remains inside existing Filament v5 pages, resources, Blade views, and Livewire-backed request flows. No Livewire v3 behavior or compatibility work is introduced.
|
||||
- **Provider registration location**: No provider or panel registration change is planned. Existing provider registration remains in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/providers.php`.
|
||||
- **Global search**: No new globally searchable resource or search behavior is introduced. Existing review-related search posture remains unchanged by this slice.
|
||||
- **Destructive actions**: No destructive action enters scope. The customer-facing review flow stays read-only, and existing destructive or manage actions remain out of scope in customer-workspace mode.
|
||||
- **Asset strategy**: No new Filament asset registration or loading strategy is planned. Deployment expectations remain unchanged; if future work ever registers assets elsewhere, the existing `cd apps/platform && php artisan filament:assets` deploy step still applies.
|
||||
|
||||
## Localization / Glossary Fit
|
||||
|
||||
- Reuse the current `localization.review.*` keyspace as the single canonical customer-facing glossary rather than creating a second glossary file, terminology registry, or translation plugin framework.
|
||||
- Treat the current workspace intro and disclosure, workspace table labels, empty states, released-review detail labels, current governance-package action, package and proof availability reasons, next-step guidance, and locale-feedback notifications as the bounded v1 surface inventory.
|
||||
- Keep provider-specific artifact or report names secondary and already-gated. They may remain in deeper evidence or artifact contexts, but they must not become default customer-facing glossary terms.
|
||||
- Keep raw JSON, exported review-pack contents, audit action IDs and metadata, signed download semantics, identifiers, and timestamps invariant. Localization applies to chrome, helper copy, notifications, and access-state wording only.
|
||||
- Use English as the controlled fallback for missing in-scope German lines, and treat raw translation-key leakage on the customer-safe path as a blocker.
|
||||
|
||||
## RBAC / Isolation Fit
|
||||
|
||||
- Workspace membership remains the first isolation boundary through the existing `WorkspaceContext` and `TenantReviewRegisterService::canAccessWorkspace(...)` path.
|
||||
- `/admin/reviews/workspace` remains the canonical workspace-scoped landing route; `/admin/t/{tenant}/reviews/{review}` remains the tenant-scoped secondary context route reached through the existing `customer_workspace=1` query flag.
|
||||
- Locale changes must not alter current denial behavior: non-members and out-of-scope tenant or review targets remain `404`, while in-scope actors missing record-level review access keep existing `403` behavior.
|
||||
- Optional supporting access remains capability-gated: current review-pack download keeps the existing `REVIEW_PACK_VIEW` path, and evidence proof reuse remains secondary and gated by the current evidence capability model.
|
||||
- No new customer identity plane, no widened download entitlement, and no new role family are introduced.
|
||||
|
||||
## Data & Invariance Fit
|
||||
|
||||
- Existing `users.preferred_locale` and the existing workspace default-locale setting remain the only persisted locale truth.
|
||||
- Existing `TenantReview`, `ReviewPack`, and `EvidenceSnapshot` records remain the canonical source of review, package, and proof truth.
|
||||
- Translation catalogs remain derived presentation assets only; no new persisted glossary inventory, localization projection table, or review-localized artifact copy is created.
|
||||
- Existing audit rows remain stable machine truth. Locale adoption must not rename action IDs, mutate audit metadata keys or values, or localize stored audit payloads.
|
||||
- Existing signed review-pack download and evidence detail routes remain the only secondary artifact and proof seams reused by this slice.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: mixed
|
||||
- **Shared-family relevance**: locale controls, status messaging, action labels, helper copy, evidence/report viewer chrome, governance-package access labels, and proof-access reasons
|
||||
- **State layers in scope**: page, detail, URL-query/session
|
||||
- **Audience modes in scope**: customer/read-only, customer-admin, auditor-read-only, operator-MSP
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
|
||||
- **Raw/support gating plan**: collapsed and capability-gated on reused detail and proof routes only
|
||||
- **One-primary-action / duplicate-truth control**: the workspace keeps `Open review` as the one dominant row action; released-review detail keeps `Download governance package` as the one dominant header action in customer-workspace mode; localization should not create parallel summary blocks
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament, shared-detail-family
|
||||
- **Required tests or manual smoke**: functional-core, state-contract, bounded-browser-smoke
|
||||
- **Exception path and spread control**: none planned; any new locale framework, new customer shell, or localized export or audit artifact becomes exception-required drift
|
||||
- **Active feature PR close-out entry**: Smoke Coverage
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: locale resolution and feedback endpoints, current review localization catalogs, `CustomerReviewWorkspace`, existing workspace Blade intro, `TenantReviewResource`, `ViewTenantReview`, current governance-package download label, proof-access reasons, and localized status and helper copy
|
||||
- **Shared abstractions reused**: `LocaleResolver`, `LocalizationController`, existing `localization.review.*` and locale notification and validation strings, existing customer-workspace query flag behavior, current review-pack and evidence truth presenters, and current review and detail RBAC paths
|
||||
- **New abstraction introduced? why?**: none planned
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the locale foundation, update endpoints, review workspace, and released-review detail are already repo-real and are the correct shared path. What is insufficient today is glossary completion and consistent surface coverage on those existing seams.
|
||||
- **Bounded deviation / spread control**: none planned. If a future implementation claims the current keyspace or surfaces are insufficient, it must prove that a narrower in-place catalog or surface update cannot solve the current-release gap.
|
||||
|
||||
## 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 read surfaces and locale feedback responses
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Provider-owned seams**: raw provider report or artifact names in already-gated evidence or detail contexts only
|
||||
- **Platform-core seams**: `customer review`, `governance package`, `proof access`, `next step`, `review status`, `evidence`, and locale feedback wording
|
||||
- **Neutral platform terms / contracts preserved**: `customer review`, `governance package`, `current review pack`, `proof access`, `next step`, `accepted risk`, and `customer-safe detail`
|
||||
- **Retained provider-specific semantics and why**: provider-specific names may remain only when a deeper artifact or evidence surface already exposes them under existing entitlement. They do not become default-visible glossary terms.
|
||||
- **Bounded extraction or follow-up path**: none
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first / snapshot truth: PASS. The slice localizes presentation over existing review, pack, and evidence truth only.
|
||||
- Read/write separation: PASS. The only writes are the already-existing locale override and user preference flows; customer-facing review surfaces remain read-only.
|
||||
- Graph contract path: PASS. No new Graph call or provider contract work is introduced.
|
||||
- Deterministic capabilities: PASS. Existing workspace, tenant, and capability seams remain authoritative.
|
||||
- Workspace and tenant isolation: PASS. Locale changes do not relax current `404` workspace or tenant boundaries.
|
||||
- RBAC-UX plane separation: PASS. Everything stays in the existing admin plane and current tenant-scoped review routes; no new auth plane is added.
|
||||
- Destructive confirmation standard: PASS by non-use. No destructive action enters scope.
|
||||
- Global search safety: PASS. No new searchable resource or search behavior is added.
|
||||
- OperationRun / Ops-UX: PASS by non-use. No run start, lifecycle, or notification semantics change.
|
||||
- Data minimization and machine invariance: PASS. Exported review-pack contents, raw JSON, audit payloads, IDs, and other machine artifacts remain unchanged.
|
||||
- Test governance (TEST-GOV-001): PASS. Proof stays in focused feature coverage plus the existing single browser smoke.
|
||||
- Proportionality / no premature abstraction: PASS. The narrow path is catalog completion and surface adoption on existing seams, not a new locale or glossary framework.
|
||||
- Persisted truth (PERSIST-001): PASS. No new table, projection, artifact family, or locale store is planned.
|
||||
- Behavioral state (STATE-001): PASS. Access and availability labels remain derived presentation over current review-pack and evidence truth; no new persisted state family is added.
|
||||
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing Filament pages, resources, and the current localization keyspace remain the first reuse path.
|
||||
- Provider boundary (PROV-001): PASS. Default customer-facing vocabulary stays platform-owned and provider-neutral.
|
||||
- Filament / Laravel planning contract: PASS. Filament remains v5 on Livewire v4, provider registration remains in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/providers.php`, no panel or provider change is planned, no global-search change is planned, and no new assets are expected.
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
- The narrow implementation remains defensible if it reuses the current locale contract and current customer review flow instead of creating a second glossary or shell.
|
||||
- The gate fails if implementation drifts into website localization, export or audit localization, a new panel, provider, or auth plane, or a generic locale/plugin framework.
|
||||
|
||||
**Post-design re-check**: PASS once `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/contracts/customer-facing-localization-adoption.openapi.yaml` are present and the agent-context refresh step is executed.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature for locale preference and override reuse, localized feedback, customer review workspace and detail copy, and glossary consistency; Browser for one existing end-to-end smoke of the customer review flow
|
||||
- **Affected validation lanes**: confidence, browser
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the main risk is rendered customer-facing wording and existing access-state continuity on repo-real surfaces, not new backend workflow breadth or a new browser family
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse existing workspace membership, tenant entitlement, published review, current review-pack, and locale helpers instead of creating a new heavy fixture family
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any new helper should stay explicit and local to the localization or reviews family
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none beyond the already-existing single browser smoke
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief on the workspace page and shared-detail-family coverage on the customer-workspace review detail
|
||||
- **Closing validation and reviewer handoff**: rerun the exact commands above, verify no raw translation keys appear on the in-scope surfaces, verify workspace and detail copy use the same glossary in EN and DE, verify locale changes do not alter current `404` or `403` behavior, and verify download, audit, and machine artifacts stay unlocalized
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond contained feature-local assertions
|
||||
- **Review-stop questions**: lane fit, hidden fixture growth, glossary drift between workspace and detail, accidental machine-artifact localization, and scope creep into website or framework work
|
||||
- **Escalation path**: `document-in-feature` for contained glossary inventory notes; `reject-or-split` for any drift into new locale infrastructure or broader localization programs
|
||||
- **Active feature PR close-out entry**: Smoke Coverage
|
||||
- **Why no dedicated follow-up spec is needed**: this plan is already the bounded adoption follow-through on existing localization and customer-review foundations; broader operator-wide or website localization remains explicitly out of scope
|
||||
- **Test-governance outcome**: keep
|
||||
|
||||
## Review Checklist Status
|
||||
|
||||
- **Review checklist artifact**: `checklists/requirements.md`
|
||||
- **Review outcome class**: `acceptable-special-case`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Escalation rule**: if implementation widens into website localization, export or audit localization, a new locale framework, or a new panel or auth plane, flip the workflow outcome to `split` or `reject-or-split` before continuing
|
||||
|
||||
## Rollout & Risk Controls
|
||||
|
||||
- Keep the canonical entry surface on the existing customer review workspace and the canonical secondary surface on the existing released-review detail route.
|
||||
- Inventory the in-scope customer-facing glossary before code changes, then fill EN/DE catalog gaps in place so workspace and detail copy cannot drift independently.
|
||||
- Treat English fallback and raw-key avoidance as rollout blockers; a missing German string should render approved English, never the raw key.
|
||||
- Keep current signed review-pack download and evidence detail behavior unchanged. Localize only surrounding labels, tooltips, helper text, and blocked or unavailable reasons.
|
||||
- Preserve current tenant and workspace filters and customer-workspace launch context when locale changes mid-flow.
|
||||
- Keep browser validation bounded to the existing smoke harness before considering wider UI rollout or broader localization scope.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/275-customer-facing-localization-adoption/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── customer-facing-localization-adoption.openapi.yaml
|
||||
└── tasks.md # Created later by /speckit.tasks, not by this plan step
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/Reviews/
|
||||
│ │ │ └── CustomerReviewWorkspace.php
|
||||
│ │ └── Resources/
|
||||
│ │ ├── TenantReviewResource.php
|
||||
│ │ └── TenantReviewResource/Pages/ViewTenantReview.php
|
||||
│ ├── Http/
|
||||
│ │ └── Controllers/
|
||||
│ │ ├── LocalizationController.php
|
||||
│ │ └── ReviewPackDownloadController.php
|
||||
│ ├── Services/
|
||||
│ │ └── Localization/
|
||||
│ │ └── LocaleResolver.php
|
||||
│ └── Support/
|
||||
│ ├── Audit/
|
||||
│ └── Auth/
|
||||
├── lang/
|
||||
│ ├── en/localization.php
|
||||
│ └── de/localization.php
|
||||
├── resources/views/filament/pages/reviews/customer-review-workspace.blade.php
|
||||
├── routes/web.php
|
||||
└── tests/
|
||||
├── Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php
|
||||
├── Feature/Localization/
|
||||
├── Feature/Reviews/
|
||||
└── Feature/TenantReview/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith. The future implementation stays inside the existing `apps/platform` localization, customer-review, review-pack, and proof surfaces, with no new panel or provider location and no new persistence layer.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None expected at planning time | The plan reuses existing locale, review, and catalog seams instead of introducing new structural machinery | A new glossary framework, locale plugin system, or customer-facing mirror surface would add ownership cost without a new source-of-truth need |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: the repo already resolves locale and exposes customer-safe review surfaces, but the customer-facing review flow still contains mixed or incomplete EN or DE wording and uneven glossary coverage across workspace, detail, and access-state messaging.
|
||||
- **Existing structure is insufficient because**: the current locale foundation and customer-review productization seams exist, but they do not yet guarantee one completed customer-facing glossary and one bounded surface inventory for language adoption.
|
||||
- **Narrowest correct implementation**: reuse `LocaleResolver`, `LocalizationController`, current `localization.review.*` catalogs, current workspace and detail surfaces, and focused localization and review tests to complete the customer-facing EN or DE adoption pass in place.
|
||||
- **Ownership cost created**: ongoing maintenance of a bounded EN or DE customer-facing glossary and a small set of feature and browser assertions for those existing surfaces.
|
||||
- **Alternative intentionally rejected**: a generic locale or plugin framework, website localization program, export or audit artifact localization pass, or second customer-facing shell was rejected because the current release needs adoption on already-real surfaces, not new infrastructure.
|
||||
- **Release truth**: current-release follow-through on existing localization and customer-review foundations
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/research.md`
|
||||
|
||||
Goals:
|
||||
- Confirm the current locale resolution and feedback seams that must be reused rather than replaced.
|
||||
- Confirm the bounded customer-facing surface inventory and glossary scope for v1.
|
||||
- Confirm machine-artifact and audit invariance boundaries.
|
||||
- Confirm the narrowest proving lane that extends current localization and review tests without opening a broader browser or framework scope.
|
||||
|
||||
## Phase 1 — Design & Contracts (output: `data-model.md`, `quickstart.md`, `contracts/`)
|
||||
|
||||
See:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/quickstart.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/contracts/customer-facing-localization-adoption.openapi.yaml`
|
||||
|
||||
Design outputs capture:
|
||||
- the existing locale, review, pack, proof, and glossary truth reused by the slice
|
||||
- the derived localized workspace and detail view models and invariance rules
|
||||
- the conceptual contract for locale endpoints and reused customer-review surfaces
|
||||
- the exact validation commands and smoke focus that should remain canonical for this plan package
|
||||
|
||||
## Proposed Implementation Phases
|
||||
|
||||
1. **Glossary Inventory & Catalog Gap Audit**: enumerate the current in-scope customer-facing keys across workspace, detail, package and proof reasons, and locale feedback; mark any missing or mixed EN and DE lines without widening scope.
|
||||
2. **Workspace Localization Adoption**: align the customer review workspace intro, headings, table labels, empty states, and next-step copy with the approved glossary while preserving current tenant filter and row-action behavior.
|
||||
3. **Released-Review Detail Localization Adoption**: align `ViewTenantReview` customer-workspace mode, detail labels, dominant governance-package action label, helper text, and package and proof availability reasons with the same glossary while keeping the surface read-only.
|
||||
4. **Regression Proof & Rollout Guardrails**: add or expand the bounded feature test for customer-review surface localization, update the existing workspace and detail assertions as needed, and keep the existing browser smoke as the only browser proof for the slice.
|
||||
@ -0,0 +1,49 @@
|
||||
# Quickstart — Customer-Facing Localization Adoption v1
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Docker is running and the Sail stack for `apps/platform` is available.
|
||||
- The feature stays inside the existing Laravel monolith and current admin plane.
|
||||
- Filament remains v5 on Livewire v4.
|
||||
- Provider registration remains in `apps/platform/bootstrap/providers.php`; no provider or panel change is part of this work.
|
||||
- No website localization, export or JSON or audit artifact localization, generic locale framework, global-search change, or asset-strategy change is in scope.
|
||||
- The customer-facing flow must reuse the existing locale foundation, current customer review workspace, current released-review detail, current review-pack download, and current proof-routing seams.
|
||||
|
||||
## Intended Implementation Order
|
||||
|
||||
1. Review the current localization seams in `LocaleResolver`, `LocalizationController`, and `routes/web.php` so the implementation reuses the repo-real locale chain and feedback endpoints.
|
||||
2. Inventory the in-scope customer-facing glossary across `CustomerReviewWorkspace`, `customer-review-workspace.blade.php`, `TenantReviewResource`, and `ViewTenantReview`, then mark any missing or mixed EN and DE lines in `lang/en/localization.php` and `lang/de/localization.php`.
|
||||
3. Tighten the customer review workspace intro, headings, accepted-risk accountability summary, table labels, empty states, and next-step copy so they all resolve from the approved glossary without changing tenant filters or row-action behavior.
|
||||
4. Tighten the released-review detail in customer-workspace mode so section labels, accepted-risk status, package and proof helper text, availability reasons, and the dominant `Download governance package` action use the same glossary while the surface stays read-only.
|
||||
5. Reuse existing review-pack and evidence truth for localized partial, blocked, unavailable, and expired reasons instead of adding a new state family or localization-specific presenter.
|
||||
6. Keep all machine-readable artifacts unchanged: no localized review-pack contents, no localized raw JSON, and no localized audit payloads.
|
||||
7. Add or expand the bounded feature test for customer-review surface localization and update the existing workspace or detail assertions needed for glossary alignment.
|
||||
8. Reuse the existing browser smoke as the only browser proof for the slice.
|
||||
|
||||
## Targeted Validation Commands (after implementation)
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Planned Smoke Checklist (after implementation)
|
||||
|
||||
1. Sign in to `/admin` as an entitled customer-safe reader and open `/admin/reviews/workspace`.
|
||||
2. Verify the workspace intro, disclosure copy, table labels, package and proof states, and `Open review` action render approved English or German copy with no raw translation keys.
|
||||
3. Change locale through the existing override or personal preference path and confirm the same workspace keeps its current tenant filter and row set.
|
||||
4. Open a released review through the existing workspace handoff and confirm the detail remains read-only in customer-workspace mode.
|
||||
5. Verify `Download governance package` remains the one dominant header action and that blocked or unavailable reasons are localized without changing underlying entitlement behavior.
|
||||
6. Follow an optional proof path and confirm the route remains capability-gated and secondary.
|
||||
7. Confirm review-pack downloads, audit identifiers, and other machine-readable artifacts stay unchanged while the surrounding UI text is localized.
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a preparation-only artifact package. No application implementation or validation results belong in this planning output yet.
|
||||
- Filament remains v5 on Livewire v4.
|
||||
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
|
||||
- No new globally searchable resource or global-search behavior is planned.
|
||||
- No destructive or mutating customer-workspace action is introduced.
|
||||
- No website localization or localized export, JSON, or audit artifact contents belong in this slice.
|
||||
- No new Filament assets are expected. If later implementation unexpectedly registers assets, deployment still uses `cd apps/platform && php artisan filament:assets`, but no such change is planned here.
|
||||
58
specs/275-customer-facing-localization-adoption/research.md
Normal file
58
specs/275-customer-facing-localization-adoption/research.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Research — Customer-Facing Localization Adoption v1
|
||||
|
||||
**Date**: 2026-05-04
|
||||
**Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/spec.md`
|
||||
|
||||
## Decision 1 — Reuse the current locale precedence and update endpoints
|
||||
|
||||
- **Decision**: Reuse `App\Services\Localization\LocaleResolver`, `App\Http\Controllers\LocalizationController`, and the existing `/localization/context`, `/localization/override`, and `/users/me/locale-preference` routes as the only locale-resolution and feedback path for this slice.
|
||||
- **Rationale**: Repo truth already resolves `explicit override -> user preference -> workspace default -> system default`, and existing tests already cover preference updates, override behavior, and localized feedback formatting.
|
||||
- **Alternatives considered**: A page-local locale switch or a second localization service was rejected because it would duplicate the repo-real precedence chain and drift immediately from the established foundation.
|
||||
|
||||
## Decision 2 — Keep the v1 surface inventory bounded to the existing customer review flow
|
||||
|
||||
- **Decision**: Limit customer-facing localization adoption to `/admin/reviews/workspace`, `customer-review-workspace.blade.php`, the existing customer-workspace mode of `ViewTenantReview`, current governance-package and proof-access helper text, and locale-feedback messaging that supports this flow.
|
||||
- **Rationale**: These are the current customer-safe governance surfaces already present in the repo, while the user and spec explicitly exclude website localization, broad operator-wide translation, and localized artifact contents.
|
||||
- **Alternatives considered**: A wider operator-wide or website-localization pass was rejected because it broadens scope beyond the current productization blocker.
|
||||
|
||||
## Decision 3 — Use the existing `localization.review.*` keyspace as the canonical glossary home
|
||||
|
||||
- **Decision**: Fill and normalize the customer-facing glossary in place under the current `localization.review.*` keys, with supporting reuse of existing locale notification and validation keys.
|
||||
- **Rationale**: The current EN and DE catalogs already contain substantial customer-review vocabulary, including `customer_safe_review_workspace`, `download_governance_package`, package and proof availability labels, and blocked or expired state wording.
|
||||
- **Alternatives considered**: A new glossary registry, a second translation domain, or a generic terminology framework was rejected because the repo already has the right catalog boundary for this flow.
|
||||
|
||||
## Decision 4 — Preserve the current read-only customer-workspace drilldown seam
|
||||
|
||||
- **Decision**: Keep `CustomerReviewWorkspace` as the canonical landing surface and `ViewTenantReview` with `customer_workspace=1` as the only secondary context surface.
|
||||
- **Rationale**: The current detail page already suppresses lifecycle actions in customer-workspace mode, and current tests already assert the single dominant `Download governance package` action on that surface.
|
||||
- **Alternatives considered**: A new customer-only detail page or customer portal shell was rejected because it would duplicate review truth, routing, and policy or audit behavior.
|
||||
|
||||
## Decision 5 — Reuse current review-pack and evidence truth for localized access-state wording
|
||||
|
||||
- **Decision**: Localize labels and reasons around current package and proof availability without changing `ReviewPack`, `EvidenceSnapshot`, signed download semantics, or current audit behavior.
|
||||
- **Rationale**: `TenantReviewResource` already computes localized governance-package availability states, and `ViewTenantReview` already exposes the localized `Download governance package` dominant action. The gap is glossary consistency and coverage, not missing artifact truth.
|
||||
- **Alternatives considered**: Localizing review-pack contents, introducing a second package state family, or creating a new proof viewer was rejected because those would exceed the bounded presentation-only scope.
|
||||
|
||||
## Decision 6 — Keep machine-readable artifacts invariant
|
||||
|
||||
- **Decision**: Exported review-pack contents, raw JSON, audit action IDs and metadata, identifiers, timestamps, and other machine-readable payloads remain untranslated.
|
||||
- **Rationale**: Existing localization coverage already guards machine-value invariance in audit tests, and the spec explicitly forbids export, JSON, and audit localization.
|
||||
- **Alternatives considered**: Localized artifacts or localized audit payloads were rejected because they would blur product truth and introduce compatibility risk without current-release need.
|
||||
|
||||
## Decision 7 — Preserve current RBAC and isolation semantics during locale changes
|
||||
|
||||
- **Decision**: Locale adoption changes presentation only; existing workspace and tenant membership checks, capability gates, `404` and `403` behavior, and optional proof or download gating remain unchanged.
|
||||
- **Rationale**: The workspace already uses `TenantReviewRegisterService` to constrain visible tenants, and the detail route already scopes review access to the current tenant and record.
|
||||
- **Alternatives considered**: Customer-specific roles, locale-specific authorization branches, or widened artifact access were rejected because they are outside the scope of localization adoption.
|
||||
|
||||
## Decision 8 — Keep global search, provider registration, and asset strategy unchanged
|
||||
|
||||
- **Decision**: Do not change global-search behavior, provider registration, panel topology, or Filament asset strategy.
|
||||
- **Rationale**: This is a copy and glossary adoption pass on existing surfaces. The repo already keeps provider registration in `apps/platform/bootstrap/providers.php`, and the spec explicitly says global search and asset strategy stay unchanged unless proven otherwise.
|
||||
- **Alternatives considered**: Search expansion, provider changes, or new asset bundles were rejected because they are unrelated to the current productization need.
|
||||
|
||||
## Decision 9 — Prove adoption with focused feature tests plus the existing browser smoke
|
||||
|
||||
- **Decision**: Add one bounded customer-review surface localization feature test and reuse the current locale preference, localized feedback, workspace page, tenant review UI contract, and customer review workspace smoke tests as the proving lane.
|
||||
- **Rationale**: The repo already has the localization and review test foundations needed to prove glossary continuity and rendered copy behavior, while the current browser smoke already covers the handoff path that matters most.
|
||||
- **Alternatives considered**: A broader browser suite or full-product localization regression lane was rejected because it would add governance cost without better proof for this bounded slice.
|
||||
313
specs/275-customer-facing-localization-adoption/spec.md
Normal file
313
specs/275-customer-facing-localization-adoption/spec.md
Normal file
@ -0,0 +1,313 @@
|
||||
# Feature Specification: Customer-Facing Localization Adoption v1
|
||||
|
||||
**Feature Branch**: `275-customer-facing-localization-adoption`
|
||||
**Created**: 2026-05-04
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Customer-Facing Localization Adoption v1 as a prep-only, implementation-ready productization slice over the already repo-real localization and customer-review foundations, focused on customer-facing adoption, glossary completion, and productized surface coverage without reopening the localization foundation or broadening into website, export, or full-product translation work."
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot already has repo-real locale resolution, customer-safe review surfaces, and EN/DE translation assets, but customer-facing governance consumption still depends on incomplete glossary coverage and inconsistent language across the current review workspace, released review detail, and governance-package or proof access states.
|
||||
- **Today's failure**: Customer-safe readers in DACH or EU contexts can reach the right review truth, but they still have to infer meaning from mixed English and German wording, uneven surface coverage, and package or proof labels that feel foundation-ready rather than productized. That leaves the product claim ahead of the actual customer-facing experience.
|
||||
- **User-visible improvement**: An entitled reader can use the existing locale controls and read the current customer-safe review flow in consistent English or German, with one approved glossary for review status, governance package access, proof access, next-step guidance, and related customer-safe helper text.
|
||||
- **Smallest enterprise-capable version**: Reuse the existing locale foundation and current customer-safe review flow, then complete one bounded adoption pass over the customer review workspace, released review detail, governance-package or proof access states, and locale-related helper or notification copy needed to make that flow consistently customer-facing in EN and DE.
|
||||
- **Explicit non-goals**: No new locale or plugin system, no website localization, no new panel or auth plane, no localized export review-pack contents, no JSON or audit artifact localization, no broad full-product translation rewrite, no new persistence or abstraction layer, and no reopening of the completed localization foundation or completed customer-review productization work.
|
||||
- **Permanent complexity imported**: One bounded customer-facing glossary inventory for already-real surfaces, additional EN/DE catalog coverage for those in-scope keys, and focused regression coverage around customer-safe locale adoption. No new models, tables, state families, or platform-wide presentation framework are introduced.
|
||||
- **Why now**: `docs/product/roadmap.md`, `docs/product/spec-candidates.md`, and `docs/product/implementation-ledger.md` all keep customer-facing localization as an open productization blocker, while the technical foundation and customer-safe review surfaces are already repo-real. The next value is adoption, not more localization infrastructure.
|
||||
- **Why not local**: One-off copy fixes on a single page would leave package labels, proof access states, download actions, locale feedback, and released-review detail wording inconsistent. The smallest honest solution is one bounded cross-surface adoption pass over the existing customer-safe review flow.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: Foundation-sounding localization theme, multiple surfaces, and a shared glossary obligation. Defense: the slice stays on existing EN/DE locale support and existing customer-safe review surfaces, adds no new locale system, and explicitly forbids export or broad operator-UI localization.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: acceptable-special-case
|
||||
- **Workflow outcome**: keep
|
||||
- **Test-governance outcome**: keep
|
||||
- **Reason**: The package is a bounded productization pass over already-real localization and customer-review foundations. It keeps the current locale chain, current customer-safe review surfaces, and current artifact truth, while explicitly blocking website localization, export or audit localization, and any new locale framework.
|
||||
- **Workflow result**: Ready for implementation.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- existing admin-plane customer review workspace at `/admin/reviews/workspace`
|
||||
- existing tenant-scoped released review detail route at `/admin/t/{tenant}/reviews/{record}`
|
||||
- existing locale context and override endpoints reused by the current admin plane at `/localization/context` and `/localization/override`
|
||||
- existing authenticated user locale preference endpoint at `/users/me/locale-preference`
|
||||
- **Data Ownership**: Existing `preferred_locale` user preference and existing workspace default locale setting remain the only persisted locale truth. Existing tenant-owned review, evidence, and review-pack records remain authoritative for customer-safe review content. Translation catalogs remain derived presentation assets only. No new persisted truth is introduced.
|
||||
- **RBAC**:
|
||||
- this remains inside the existing admin plane and current tenant review access paths
|
||||
- workspace membership remains the first isolation boundary for the customer review workspace
|
||||
- tenant review visibility, review-pack access, and proof access keep the current capability and entitlement rules
|
||||
- locale preference updates remain bound to the authenticated user, and any existing workspace-default locale controls remain under the current workspace settings capability model
|
||||
- no new role family, customer identity plane, or widened download capability is introduced
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: The current tenant prefilter behavior of the customer review workspace remains unchanged. Language selection changes presentation only and MUST NOT reset or rewrite current tenant or workspace filter context.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Locale changes and localized copy MUST NOT reveal inaccessible tenants, reviews, review packs, or proof links. Non-members or out-of-scope tenant targets remain `404`, and member actors missing a capability keep the existing `403` behavior on gated download or proof actions.
|
||||
|
||||
## 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)**: locale controls, status messaging, evidence or report viewers, governance-package access labels, proof-access labels, helper copy, and locale-related notifications
|
||||
- **Systems touched**: current locale resolution and preference flow, existing `localization.review.*` catalogs, current customer review workspace page, current released review detail surface, and existing review-pack access or proof-access messaging
|
||||
- **Existing pattern(s) to extend**: the locale foundation from Spec 252, the customer review productization contract from Spec 258, and the governance-package delivery wording introduced as related context in Spec 260
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: existing locale resolution and preference flow, existing `localization.review.*` keyspace, current `CustomerReviewWorkspace` page, current `TenantReview` detail surface, and the current governance-package download action on the released review detail
|
||||
- **Why the existing shared path is sufficient or insufficient**: The resolver, preference flow, and customer-safe review surfaces already exist and are the correct reuse path. What remains insufficient is glossary completion and consistent customer-facing copy coverage across those already-real surfaces.
|
||||
- **Allowed deviation and why**: none. This slice must not introduce page-local translation behavior, new locale sources, or a second customer-facing glossary.
|
||||
- **Consistency impact**: Terms such as `review`, `customer review`, `governance package`, `current review pack`, `proof access`, `next step`, and customer-safe availability states must stay aligned across workspace list, released review detail, localized notifications, and helper copy.
|
||||
- **Review focus**: Reviewers must block any new locale infrastructure, export-localization attempt, page-local synonym drift, or provider-specific wording entering the default customer-facing glossary.
|
||||
|
||||
## 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 deep-link semantics are changed by this slice.
|
||||
|
||||
## 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?**: yes
|
||||
- **Boundary classification**: platform-core
|
||||
- **Seams affected**: customer-facing glossary terms, governance-package labels, proof-access labels, helper text, and availability-state wording
|
||||
- **Neutral platform terms preserved or introduced**: customer review, governance package, current review pack, proof access, next step, review status, and customer-safe detail
|
||||
- **Provider-specific semantics retained and why**: Any provider-specific report or artifact names may remain only in already-gated secondary evidence or artifact context where the underlying record is provider-owned. They are not promoted into the default customer-facing glossary.
|
||||
- **Why this does not deepen provider coupling accidentally**: The slice reuses platform-owned localization keys and current customer-safe review vocabulary rather than translating raw provider payload labels into the default visible path.
|
||||
- **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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | yes | Native Filament page plus existing customer-review primitives | status messaging, action labels, localized glossary, package and proof availability | page, filter, disclosure | no | Existing page remains the primary customer-safe index; no new page type is introduced |
|
||||
| Released Customer Review detail | yes | Native Filament resource detail plus existing review and artifact primitives | detail labels, helper text, governance-package access, proof-access states | detail, disclosure, access-state messaging | no | Existing detail remains the secondary context surface; no new report viewer or package registry is introduced |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | Primary Decision Surface | Reader decides which entitled tenant review to open and whether the current review appears ready for customer-safe follow-up | localized review state, governance-package availability, proof status, and next-step wording | released review detail, proof context, and gated artifact detail only after explicit open | Primary because it is the current customer-safe landing surface and should answer the first cross-tenant review question in the chosen language | Follows current review-consumption workflow instead of inventing a new localization dashboard | Removes manual interpretation of mixed-language labels before review drilldown |
|
||||
| Released Customer Review detail | Secondary Context Surface | Reader validates why the review says what it says and whether the current governance package or proof can be opened or downloaded | localized narrative labels, current governance-package action, localized access-state reasons, and customer-safe helper text | deeper proof context, gated artifact detail, and any provider-owned secondary references only after explicit drilldown | Secondary because it deepens one selected review rather than replacing the workspace entry surface | Keeps customer-facing localization inside the existing review detail route | Prevents repeating the same summary in multiple languages across parallel detail widgets |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | customer-read-only, customer-admin, auditor-read-only, operator-MSP | localized customer-safe review state, governance-package availability, proof availability, and next-step summary | deeper lineage, freshness, and secondary governance detail only after opening the review | raw payloads, provider IDs, and unrestricted support detail stay hidden or gated | `Open review` | raw or support detail, deeper diagnostics, and any out-of-scope operator controls remain hidden | The workspace states one localized summary per tenant row; the detail view owns deeper explanation |
|
||||
| Released Customer Review detail | customer-read-only, customer-admin, auditor-read-only, operator-MSP | localized section labels, governance-package action, localized package or proof availability reasons, and customer-safe helper text | release history, evidence freshness, and secondary context stay lower priority | raw evidence payloads, provider-debug detail, and unrestricted support detail remain hidden or gated | `Download governance package` | support-only or raw artifact context remains secondary and capability-gated | Detail adds explanation and access-state wording without duplicating the same overview cards as peer truth |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the released review for one entitled tenant | dedicated `Open review` link column | forbidden | contextual `Back to governance inbox` and `Clear filters` remain neutral header actions only | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` | workspace context, tenant filter, review state, governance-package availability | Customer review | localized review state, governance-package state, proof state, and next step | Existing inspect affordance stays a primary link column rather than row click |
|
||||
| Released Customer Review detail | Detail / Report / Evidence | Read-only detail report | Download the current governance package or inspect localized proof-access status | detail header action plus in-body secondary proof context | forbidden | proof and supporting artifact links remain secondary in-body actions | no new destructive action enters customer-workspace mode; existing destructive manage actions stay out of scope | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` | workspace, tenant, released-review context, package or proof state | Customer review | localized customer-safe labels and access-state explanations for the current released review | Existing customer-workspace mode keeps one dominant header action and no new action family |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | Customer-safe reader or MSP operator preparing customer consumption | Decide which entitled review to open in the chosen language | Read-only workspace review overview | Which current review matters for this tenant, and is the customer-safe package or proof ready? | localized review state, governance-package availability, proof status, and next-step guidance | deeper lineage, freshness, and supporting detail after drilldown only | review state, package availability, proof availability | none | Open review | none |
|
||||
| Released Customer Review detail | Customer-safe reader or MSP operator continuing the same review | Understand the current governance record and whether the current package or proof can be safely consumed | Read-only detail report | What does this review say in my chosen language, and what customer-safe artifact can I use now? | localized section labels, helper text, governance-package action label, and localized access-state reasons | deeper proof context, release history, and gated secondary artifact detail | review state, package availability, proof-access state | none | Download governance package | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: The current locale foundation exists, but customer-facing review consumption still presents incomplete or inconsistent translated terminology across the already-real customer-safe review flow.
|
||||
- **Existing structure is insufficient because**: The repo already has the right resolver and review surfaces, but those existing surfaces do not yet share one completed customer-facing glossary or one bounded surface inventory for locale adoption.
|
||||
- **Narrowest correct implementation**: Keep the current locale foundation, keep the current review and package flow, and complete a bounded EN/DE glossary and copy pass only on the customer-facing review surfaces that already exist.
|
||||
- **Ownership cost**: Ongoing maintenance of the in-scope glossary keys and focused regression coverage for those surfaces only.
|
||||
- **Alternative intentionally rejected**: A full product-wide translation rewrite, localized export program, or second locale framework was rejected because the current release only needs customer-facing adoption on already-real review surfaces.
|
||||
- **Release truth**: current-release productization blocker, not future-release preparation
|
||||
|
||||
### 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 reuse of the current locale foundation and customer-safe review flow is preferred over introducing any new compatibility layer.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature, Browser
|
||||
- **Validation lane(s)**: confidence, browser
|
||||
- **Why this classification and these lanes are sufficient**: Focused feature coverage is the narrowest sufficient proof for locale preference or override reuse, localized customer-safe review surfaces, access-state wording, glossary consistency, and unchanged artifact truth. One bounded browser smoke remains justified because this slice is primarily about rendered customer-facing copy and dominant-action clarity on existing review surfaces.
|
||||
- **New or expanded test families**: expand the existing localization feature family plus the current customer review workspace and tenant review UI contract tests; keep exactly one bounded browser smoke around the customer review workspace flow
|
||||
- **Fixture / helper cost impact**: low to moderate. Reuse existing workspace membership, tenant entitlement, customer review workspace, released review, review-pack, and localization helpers instead of introducing a new heavy fixture family.
|
||||
- **Heavy-family visibility / justification**: exactly one browser smoke stays explicit because this slice changes rendered customer-facing wording and access-state clarity, not backend workflow breadth.
|
||||
- **Special surface test profile**: shared-detail-family
|
||||
- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for route, entitlement, and localized label assertions. The bounded browser smoke is the only required rendered proof of customer-safe locale adoption.
|
||||
- **Reviewer handoff**: Reviewers must confirm that localized copy appears only on in-scope customer-safe surfaces, no raw translation keys leak, no export or audit artifact content changes, tenant or workspace scope remains intact, and glossary terms stay aligned between workspace and detail surfaces.
|
||||
- **Budget / baseline / trend impact**: low feature-local increase only
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- bounded customer-facing EN/DE glossary completion for the existing customer review workspace and released review detail
|
||||
- localized governance-package, current review-pack, and proof-access labels, tooltips, helper text, and access-state reasons on those existing customer-safe surfaces
|
||||
- locale-related helper or notification copy needed to switch into or remain in the chosen language on the in-scope flow
|
||||
- explicit verification that customer-facing locale adoption changes surface chrome and messaging only, not underlying review-pack, JSON, audit, or other machine-readable artifacts
|
||||
- consistent use of the approved customer-safe glossary across workspace list, released review detail, and current governance-package action labels
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- any new locale resolver, plugin framework, or supported-locale expansion beyond the existing EN/DE foundation
|
||||
- website localization or public documentation translation
|
||||
- a broad operator-wide translation rewrite outside the existing customer-safe review and package surfaces
|
||||
- localized export review-pack contents, JSON payloads, audit records, or other machine-readable artifacts
|
||||
- a new panel, auth plane, customer portal, or new package domain
|
||||
- reopening Spec 252 localization foundation work or re-normalizing completed Spec 258 artifacts
|
||||
|
||||
## Candidate Selection Rationale
|
||||
|
||||
- **Selected candidate**: Customer-Facing Localization Adoption v1
|
||||
- **Source locations**:
|
||||
- `docs/product/spec-candidates.md` manual-promotion backlog priority 4
|
||||
- `docs/product/roadmap.md` customer-facing localization remains a `Next` and manual-promotion productization lane
|
||||
- `docs/product/implementation-ledger.md` explicit blocker: customer-facing localization adoption is incomplete
|
||||
- **Why selected**: The active auto-prep queue is intentionally empty, this candidate remains unspecced, and it is the next repo-grounded customer-facing localization follow-through that builds directly on already-real foundations rather than requiring new infrastructure.
|
||||
- **Why this is the smallest viable implementation slice**: The repo already has locale resolution, language catalogs, customer-safe review workspace, released review detail, and governance-package access messaging. The missing piece is one productized adoption pass over glossary completion and customer-safe surface coverage, not a new localization foundation.
|
||||
- **Why close alternatives are deferred**:
|
||||
- `Enterprise Access Boundary & Support Access Governance v1` remains a separate access-governance lane with different trust and audit seams.
|
||||
- `Stored Reports Surface v1` remains a retained-artifact product surface lane and is not required to make the current customer-safe review flow readable in EN/DE.
|
||||
- `Workspace & Tenant Closure Lifecycle v1` remains a lifecycle-runtime lane and does not tighten the current customer-facing review or locale contract.
|
||||
- `First Governed AI Runtime Consumer v1` remains a governed-AI follow-through lane and is not a prerequisite for customer-safe locale adoption.
|
||||
|
||||
## Completed-Spec Guardrail Result
|
||||
|
||||
- **Spec 252 - Platform Localization v1**: completed context only. It already contains review outcome and implementation close-out evidence, so this spec reuses it as foundation context and does not reopen or normalize it.
|
||||
- **Spec 258 - Customer Review Workspace Productization v1**: completed context only. It already contains implementation close-out evidence and remains the customer-safe review surface foundation for this adoption slice.
|
||||
- **Spec 260 - Governance-as-a-Service Packaging v1**: related context only. It remains a terminology and package-surface anchor for current governance-package wording, but this spec does not refresh or broaden that package domain.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- existing locale resolution and preference flow already present in the repo
|
||||
- existing EN/DE `localization.review.*` catalogs and current review-related translation keys
|
||||
- existing `CustomerReviewWorkspace` page and tenant review detail surface
|
||||
- existing current governance-package download action and proof-access messaging on the released review detail
|
||||
- existing customer-safe review RBAC, workspace or tenant isolation, and review-pack entitlement behavior
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The supported locale set remains exactly English and German for this slice.
|
||||
- Existing locale preference, override, and workspace-default behavior remain the canonical resolution path and are reused rather than redesigned.
|
||||
- The current `Download governance package` action continues to resolve to the existing current export review pack and remains a customer-safe surface label only.
|
||||
- Customer-facing localization in v1 applies to surface chrome, helper text, notifications, and access-state wording, not to the underlying downloadable artifact contents or audit payloads.
|
||||
- Product copy review can approve the bounded customer-facing glossary without creating a new terminology-governance framework.
|
||||
|
||||
## Risks
|
||||
|
||||
- Glossary drift can reappear if workspace and released-review detail copy are translated independently.
|
||||
- Partial German coverage can leave mixed-language surfaces if the bounded inventory is not enforced explicitly.
|
||||
- Scope pressure can try to turn this adoption slice into a broad operator-wide rewrite or an export-localization program.
|
||||
- Provider-owned artifact names could leak into default-visible customer-facing copy if the glossary is not kept platform-first.
|
||||
|
||||
## Follow-Up Candidates Explicitly Kept Out of Scope
|
||||
|
||||
- Enterprise Access Boundary & Support Access Governance v1
|
||||
- Stored Reports Surface v1
|
||||
- Workspace & Tenant Closure Lifecycle v1
|
||||
- First Governed AI Runtime Consumer v1
|
||||
- broad operator-wide localization beyond the current customer-safe review flow
|
||||
- website localization and localized export, JSON, audit, or stored-artifact contents
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Read the customer review workspace in the chosen language (Priority: P1)
|
||||
|
||||
As an entitled customer-safe reader, I want the current customer review workspace to honor the existing locale choice so I can understand which tenant review to open without translating mixed-language labels myself.
|
||||
|
||||
**Why this priority**: This is the first customer-facing surface in the current governance consumption flow, and it is where the productization gap is most visible.
|
||||
|
||||
**Independent Test**: Set or inherit a supported locale, open the customer review workspace, and verify that the in-scope headings, accepted-risk accountability summary, package or proof states, next-step wording, and `Open review` action use the approved glossary with no raw translation keys.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an entitled reader resolves to German through the existing locale foundation, **When** they open the customer review workspace, **Then** the workspace heading, accepted-risk accountability summary, governance-package state, proof state, next-step wording, and `Open review` label render in approved German copy.
|
||||
2. **Given** the same reader switches back to English using the existing locale controls, **When** they reopen the customer review workspace, **Then** the same in-scope terms, including accepted-risk accountability copy and any partial package or proof state, render in approved English copy and the current tenant or workspace filtering remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Consume released review detail with one consistent customer-safe glossary (Priority: P1)
|
||||
|
||||
As an entitled customer-safe reader, I want the released review detail to use the same approved customer-facing terminology as the workspace so I can trust the current governance package, accepted-risk status, and proof-access messaging.
|
||||
|
||||
**Why this priority**: The product claim fails if the workspace is localized but the released review detail falls back to inconsistent English, mixed synonyms, or unclear package or proof wording.
|
||||
|
||||
**Independent Test**: Open a released review from the customer review workspace in both supported locales and verify that the section labels, accepted-risk status, helper copy, governance-package action, and package or proof access-state reasons use the same glossary as the workspace.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a released review has a downloadable current governance package, **When** the reader opens the detail in German, **Then** the detail labels, accepted-risk status, and the dominant package action render in approved German while the underlying downloadable artifact remains unchanged.
|
||||
2. **Given** a released review is missing, partial, blocked, or expired for package or proof access, **When** the reader opens the detail in either supported locale, **Then** the access-state reason appears in the selected language without changing the underlying authorization outcome.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Receive truthful localized access-state and locale-feedback messaging (Priority: P2)
|
||||
|
||||
As a reader or operator using the customer-safe review flow, I want locale-related helper or notification copy and customer-safe access-state messages to stay localized so the product does not fall back to raw keys or generic English during common follow-up steps.
|
||||
|
||||
**Why this priority**: Trust breaks quickly when the visible review surface is localized but common helper or access-state messages are not.
|
||||
|
||||
**Independent Test**: Trigger locale preference or override feedback and package or proof unavailable states on the in-scope customer-safe flow, then verify that the messages are localized while the same authorization and artifact rules still apply.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a reader clears or changes their current locale using the existing flow, **When** the action completes, **Then** the resulting helper or notification copy appears in the selected supported language.
|
||||
2. **Given** a reader can view the released review but not download the current review pack, **When** they inspect the package action state, **Then** the reason is localized and the action remains correctly unavailable.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a German translation is missing for an in-scope key? The surface MUST fall back to approved English copy and MUST NOT show a raw translation key.
|
||||
- What happens when the reader can open the released review but not the current review pack or proof? The review remains readable, while the blocked or unavailable reason stays localized and truthful.
|
||||
- What happens when the reader changes locale mid-flow? The next rendered customer-safe surface updates its localized copy while preserving the same tenant, workspace, and review context.
|
||||
- What happens when the downloadable review pack or proof artifact is expired? The localized access-state wording changes, but the underlying artifact and machine-readable content remain unchanged.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST reuse the existing EN/DE locale foundation so the current customer review workspace and released review detail honor the reader's resolved locale without adding a new locale source or locale-management surface.
|
||||
- **FR-002**: System MUST localize the in-scope customer-facing review surfaces with one approved glossary covering workspace headings, section labels, accepted-risk summaries or status, review status, governance-package wording, proof-access wording, next-step guidance, and related helper copy.
|
||||
- **FR-003**: System MUST keep the same canonical customer-facing terms across the customer review workspace, released review detail, dominant package action, tooltips, helper text, empty states, and locale-related notifications for this slice.
|
||||
- **FR-004**: System MUST provide localized and truthful package or proof availability messaging for the current customer-safe review flow, including partial or unavailable states, unavailable reasons such as missing or not-ready where applicable, plus blocked and expired states where they already exist in repo truth.
|
||||
- **FR-005**: System MUST preserve the current workspace, tenant, and capability enforcement semantics on all in-scope localized surfaces, including existing `404` and `403` behavior.
|
||||
- **FR-006**: System MUST keep exported review-pack files, raw JSON, audit records, identifiers, timestamps, and other machine-readable artifacts unchanged and unlocalized.
|
||||
- **FR-007**: System MUST use existing locale-related helper or notification messaging for preference and override feedback rather than introducing a new panel, auth plane, or notification framework.
|
||||
- **FR-008**: System MUST define and validate a bounded v1 surface inventory for customer-facing locale adoption and MUST leave broader operator-wide localization as follow-up work.
|
||||
- **FR-009**: System MUST fail safely to approved English copy for missing in-scope translations and MUST NOT render raw translation keys on the default customer-safe path.
|
||||
- **FR-010**: System MUST keep provider-specific artifact or report wording out of the default customer-facing glossary unless the existing underlying record is intentionally opened through a secondary, already-gated context.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` | `Back to governance inbox` when launched contextually, `Clear filters` | dedicated `Open review` link column | none | none | `Clear filters` only when filters are active | `N/A` | `N/A` | yes | Action-surface contract stays satisfied through one primary inspect affordance and one neutral filter-reset action; this slice only localizes copy and glossary use |
|
||||
| Released Customer Review detail in customer-workspace mode | `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` | `Download governance package` remains the dominant header action in customer-workspace mode | `N/A` | none | none | `N/A` | `Download governance package` | `N/A` | yes | Existing destructive manage actions stay outside the customer-workspace-mode scope for this localization slice; no new actions are introduced |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Locale Context**: The existing resolved locale and source used to render customer-safe review surfaces, derived from already-real user, workspace, session, and system settings.
|
||||
- **Customer-Facing Review Glossary Inventory**: The approved EN/DE term set for current customer-safe review surfaces, including review status, governance-package wording, proof-access wording, and next-step guidance.
|
||||
- **Customer-Safe Artifact Access State**: The existing derived package or proof state shown to a reader on the customer-safe review flow, expressed through localized customer-facing wording without creating a new state family.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance review, 100% of default-visible labels and helper text on the in-scope customer-facing review surfaces render approved English or German copy with zero raw translation keys.
|
||||
- **SC-002**: An entitled reader can switch to or inherit either supported locale and identify the current review status, next step, and governance-package or proof availability for one tenant review in under 2 minutes.
|
||||
- **SC-003**: In 100% of sampled acceptance flows, changing locale updates only in-scope surface chrome, helper copy, and feedback messaging while leaving exported review-pack contents, audit payloads, and other machine-readable artifacts unchanged.
|
||||
- **SC-004**: Product review confirms that the approved customer-facing glossary stays consistent across the customer review workspace, released review detail, and current governance-package action in both supported locales.
|
||||
206
specs/275-customer-facing-localization-adoption/tasks.md
Normal file
206
specs/275-customer-facing-localization-adoption/tasks.md
Normal file
@ -0,0 +1,206 @@
|
||||
---
|
||||
description: "Task list for Customer-Facing Localization Adoption v1"
|
||||
---
|
||||
|
||||
# Tasks: Customer-Facing Localization Adoption v1
|
||||
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/`
|
||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/contracts/customer-facing-localization-adoption.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/quickstart.md`
|
||||
|
||||
**Review Artifact**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/checklists/requirements.md` is the outcome-of-record for the review outcome class, workflow outcome, and test-governance outcome. If implementation widens into website localization, export or audit localization, a new locale framework, or a new panel or auth plane, update that artifact before continuing.
|
||||
|
||||
**Tests**: Required (Pest) for runtime behavior changes. Keep proof in the existing `confidence` lane plus one bounded `browser` smoke only because this slice changes rendered customer-facing copy, glossary consistency, locale feedback, and dominant-action clarity on existing review surfaces.
|
||||
**Operations**: No new `OperationRun`, queue, remote call, publication flow, or background processing is introduced. Audit and machine-readable truth remain on the current shared paths only.
|
||||
**RBAC**: Workspace membership remains the first `404` boundary; tenant or review scope mismatches remain `404`; in-scope actors missing an optional secondary capability keep inherited `403` or explicit unavailable-state behavior. Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Auth/Capabilities.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Auth/RoleCapabilityMap.php`; do not introduce raw capability strings or role-name checks.
|
||||
**Shared Pattern Reuse**: Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/LocalizationController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, and the existing `localization.review.*` catalogs in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`. Do not add a new locale framework, panel, provider, auth plane, or customer-facing shell.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/providers.php`. No new globally searchable resource, no new panel, no destructive action, and no asset-strategy change are allowed in this slice.
|
||||
**Organization**: Tasks are grouped by user story so glossary completion, workspace adoption, released-review detail adoption, and localized feedback or invariance protection remain independently testable after shared seams are settled.
|
||||
**Review Outcome**: `acceptable-special-case`
|
||||
**Workflow Outcome**: `keep`
|
||||
**Test-governance Outcome**: `keep`
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New or changed tests stay in the smallest honest family, and any browser addition remains explicit and bounded.
|
||||
- [x] Shared helpers, fixtures, memberships, released reviews, review packs, and locale defaults stay cheap by default.
|
||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [x] Surface test profile stays explicit: `standard-native-filament` for the workspace page and `shared-detail-family` for the customer-workspace review detail.
|
||||
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active feature package or PR close-out.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Lock the bounded productization slice, glossary scope, and proving lanes before runtime edits begin.
|
||||
|
||||
- [x] T001 Review the bounded slice, explicit non-goals, completed-spec guardrails, and repo-fit outcome in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/checklists/requirements.md`
|
||||
- [x] T002 [P] Review locale-precedence reuse, glossary boundary, customer-workspace route reuse, and machine-artifact invariance in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/contracts/customer-facing-localization-adoption.openapi.yaml`
|
||||
- [x] T003 [P] Confirm the focused Sail/Pest validation commands, existing localization or review test families, and one bounded browser smoke in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Settle the shared glossary inventory, fallback and invariance guardrails, and reuse seams that every user story depends on.
|
||||
|
||||
**Critical**: No user-story work should begin until this phase is complete.
|
||||
|
||||
- [x] T004 [P] Inventory and map the canonical `localization.review.*` glossary coverage across `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/LocalizationController.php` so later story work reuses one approved customer-facing glossary only
|
||||
- [x] T005 [P] Add or extend shared guard coverage for English fallback, no raw translation-key leakage, localized locale-feedback reuse, and unchanged machine-artifact truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.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/LocalizedNotificationFormattingTest.php`
|
||||
- [x] T006 Reuse or minimally tighten the existing locale-resolution and customer-workspace seams in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/LocalizationController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/routes/web.php`, `/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`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php` so story work stays on one locale source, one read-only detail route, and unchanged artifact/download behavior
|
||||
|
||||
**Checkpoint**: Shared glossary ownership, fallback safety, and invariance boundaries are fixed before workspace or detail adoption begins.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Read the customer review workspace in the chosen language (Priority: P1)
|
||||
|
||||
**Goal**: Let an entitled customer-safe reader use the existing locale choice on `/admin/reviews/workspace` without mixed-language labels or broken tenant context.
|
||||
|
||||
**Independent Test**: Resolve English or German through the existing locale foundation, open `/admin/reviews/workspace`, and verify the heading, intro, disclosure copy, package and proof states, next-step wording, empty state, and `Open review` label render with the approved glossary while the current tenant filter stays intact.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T007 [P] [US1] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` for EN or DE workspace headings, intro or disclosure copy, accepted-risk accountability summary, package or proof labels including partial states, next-step wording, empty-state copy, `Open review`, and locale-switch filter continuity
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T008 [US1] Complete the workspace glossary entries and missing EN or DE copy in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php` for workspace headings, intro or disclosure copy, accepted-risk accountability summaries, package or proof states including partial, next-step guidance, empty states, and `Open review`
|
||||
- [x] T009 [US1] Apply the canonical glossary to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` so the existing workspace page renders localized customer-facing copy, accepted-risk accountability summary, and existing package or proof states without adding a new action family or row behavior
|
||||
- [x] T010 [US1] Preserve tenant and workspace filter continuity during locale changes by tightening `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/LocalizationController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/routes/web.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` without introducing page-local locale logic
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when the workspace is readable in English or German with unchanged filter and row-action behavior.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Consume released review detail with one consistent customer-safe glossary (Priority: P1)
|
||||
|
||||
**Goal**: Let the same reader open the released review detail from the workspace and see the same customer-facing terminology on package and proof states.
|
||||
|
||||
**Independent Test**: Open a released review from `/admin/reviews/workspace` in both supported locales and verify the section labels, helper copy, dominant `Download governance package` action, and localized package or proof access reasons match the workspace glossary while the detail stays read-only in customer-workspace mode.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T011 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` for customer-workspace detail section labels, accepted-risk status, helper copy, localized package or proof reasons including partial states, dominant `Download governance package` wording, and glossary continuity with the workspace surface
|
||||
- [x] T012 [P] [US2] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` for the bounded workspace-to-detail handoff, localized dominant action, secondary proof-path visibility, and the absence of raw translation keys while keeping browser proof inside the existing smoke family only
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T013 [US2] Complete the released-review detail glossary entries and missing EN or DE copy in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php` for section labels, accepted-risk status wording, helper text, governance-package wording, proof-access wording, and localized partial or unavailable or blocked or expired reasons
|
||||
- [x] T014 [US2] Apply the shared glossary to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` so the existing customer-workspace detail flow stays read-only, explanatory, and centered on one dominant `Download governance package` action
|
||||
- [x] T015 [US2] Reuse current package and proof truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` so localized labels and reasons, including partial package or proof states, stay truthful without changing signed downloads, proof gating, or customer-workspace read-only mode
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when the released-review detail uses the same glossary as the workspace and keeps the one-action customer-workspace contract.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Receive truthful localized access-state and locale-feedback messaging (Priority: P2)
|
||||
|
||||
**Goal**: Keep locale feedback, blocked or unavailable messaging, and fallback behavior localized without changing authorization or machine-readable artifact truth.
|
||||
|
||||
**Independent Test**: Trigger locale preference or override feedback and in-scope unavailable, blocked, or expired package or proof states, then verify the messages render in the effective locale with English fallback only and with unchanged `404` or `403`, download, and machine-artifact behavior.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T016 [P] [US3] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/LocalePreferenceFlowTest.php`, `/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/CustomerReviewSurfaceLocalizationTest.php` for localized override or preference feedback, localized partial or unavailable or blocked or expired messaging, controlled English fallback, no raw-key leakage, and unchanged artifact-truth assertions on the in-scope customer-safe flow
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T017 [US3] Finalize locale-feedback copy and supported fallback behavior in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/LocalizationController.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php` so override or preference responses render in the effective locale without a new notification or locale framework
|
||||
- [x] T018 [US3] Keep package or proof availability messaging, including partial or unavailable or blocked or expired states, `404` or `403` semantics, and machine-readable artifact invariance unchanged while localized reasons render 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`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/ReviewPackDownloadController.php` without localizing review-pack bytes, raw JSON, audit payloads, or identifiers
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when feedback and access-state messaging stay localized and truthful without altering artifact or authorization truth.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: Run the canonical proof commands, format touched files, and keep any discovered spillover explicit instead of absorbing it into this slice.
|
||||
|
||||
- [x] T019 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/LocalePreferenceFlowTest.php tests/Feature/Localization/LocalizedNotificationFormattingTest.php tests/Feature/Localization/CustomerReviewSurfaceLocalizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php` exactly as recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/quickstart.md`
|
||||
- [x] T020 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` exactly as recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/quickstart.md`
|
||||
- [x] T021 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for all touched platform files
|
||||
- [x] T022 Review `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Localization/LocaleResolver.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Http/Controllers/LocalizationController.php`, `/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`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/275-customer-facing-localization-adoption/checklists/requirements.md` to confirm no website localization, no export or JSON or audit-artifact localization, no new panel or provider or auth plane, no locale framework, and explicit `follow-up-spec` recording for any spillover discovered during implementation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories until glossary inventory, fallback safety, and reuse seams are settled.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and delivers the first customer-facing localization increment on the workspace landing surface.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should follow US1 because it deepens the same workspace-led review flow on shared files and copy.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because feedback, fallback, and invariance hardening rely on the final glossary contract.
|
||||
- **Phase 6 (Polish)**: depends on all implemented stories.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: first independently testable increment once the glossary inventory and fallback guardrails exist.
|
||||
- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the workspace and detail must share one glossary and one dominant-action contract.
|
||||
- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 and US2 because it hardens the localized feedback and invariance behavior of the same flow.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and make it fail for the intended gap before runtime implementation.
|
||||
- Reuse the existing locale, review, package, and proof seams before adding any new helper or translation mapping.
|
||||
- Re-run the narrowest relevant validation command after each story checkpoint before moving to the next story.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Phase 1
|
||||
|
||||
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- T004 and T005 can run in parallel while T006 settles the shared reuse seam.
|
||||
|
||||
### User Story 1
|
||||
|
||||
- T007 can run in parallel with any last Phase 2 cleanup.
|
||||
- After the missing glossary terms are identified, T008 and T010 can proceed in parallel before T009 finalizes the workspace copy wiring.
|
||||
|
||||
### User Story 2
|
||||
|
||||
- T011 and T012 can run in parallel because they cover feature and browser proof separately.
|
||||
- After the missing glossary terms are identified, T013 and T015 can proceed in parallel before T014 finalizes the shared detail surface wording.
|
||||
|
||||
### User Story 3
|
||||
|
||||
- T016 can run in parallel with late US2 verification.
|
||||
- T017 and T018 can proceed in parallel once the feedback and invariance gaps are proven.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **Phase 2 + US1 + US2**. The package becomes product-meaningful once the existing workspace and released-review detail both render one customer-facing EN or DE glossary on the current review flow.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1 and validate the localized workspace surface.
|
||||
3. Deliver US2 and validate the localized released-review detail plus the bounded browser smoke.
|
||||
4. Deliver US3 and validate localized feedback, fallback safety, and invariance protection.
|
||||
5. Finish with Phase 6 validation, formatting, and explicit spillover review.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the glossary inventory and fallback guardrails before changing multiple shared surfaces.
|
||||
2. Parallelize test authoring inside each story before converging on the shared localization files.
|
||||
3. Serialize merges around `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` because they are the main conflict hotspots for this slice.
|
||||
|
||||
---
|
||||
|
||||
## Explicit Follow-Ups / Out of Scope
|
||||
|
||||
- Broader operator-wide localization outside the existing customer-review workspace, released-review detail, and locale-feedback surfaces remains a separate follow-up spec.
|
||||
- Website localization remains out of scope and must not be folded into this task list.
|
||||
- Localized export review-pack contents, raw JSON, audit payloads, or other machine-readable artifacts remain out of scope and must not be folded into this task list.
|
||||
- Any new locale or plugin framework, new panel, provider-registration change, new auth plane, or customer portal shell remains out of scope and must be split into a separate follow-up spec if later required.
|
||||
- Any spillover discovered during implementation that cannot be contained to the in-scope customer-facing review flow must resolve as explicit `follow-up-spec`, not as an implicit expansion of this feature.
|
||||
Loading…
Reference in New Issue
Block a user