merge: session branch 271-counted-progress-rollout-session-1777940791 into 271-counted-progress-rollout (automated)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 10m6s

This commit is contained in:
Ahmed Darrazi 2026-05-05 02:29:12 +02:00
commit 685a2c8809
17 changed files with 1458 additions and 34 deletions

View File

@ -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(

View File

@ -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);

View File

@ -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),

View File

@ -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();

View File

@ -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],

View File

@ -12,7 +12,7 @@
$operationsCollectionLabel = \App\Support\OperationRunLinks::collectionLabel();
$operationsIndexUrl = $tenant ? \App\Support\OpsUx\OperationRunUrl::index($tenant) : null;
$primaryActionLabel = $usesCollectivePrimaryAction ? 'Review operations' : 'View operation';
$bannerTitle = $hasActiveVisibleRuns ? 'Active operations' : 'Operation updates';
$bannerTitle = $hasActiveVisibleRuns && ! $hasTerminalVisibleRuns ? 'Active operations' : 'Operation updates';
$bannerHelper = match (true) {
$hasActiveVisibleRuns && $hasTerminalFollowUpVisibleRuns => 'Active and recent operation updates that may need review.',
$hasActiveVisibleRuns => 'Queued and running work stays here until diagnostics are needed.',

View File

@ -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);

View File

@ -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);

View File

@ -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,

View File

@ -201,7 +201,7 @@
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
expect($html)->toContain('Review operations')
->and($html)->toContain('Active 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('Hide activity')

View File

@ -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();

View File

@ -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();

View File

@ -1331,6 +1331,16 @@ # TenantPilot Enterprise UI Standards**Status:** Active **Owner:** Product / En
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
keep queued rows activity-only even when a planned total exists
keep running rows without trustworthy processed and total counts activity-only or indeterminate

View File

@ -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`.

View 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.

View 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.

View 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