feat: implement sync capture backup operation semantics (#433)

Implemented sync capture backup operation semantics as requested.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #433
This commit is contained in:
ahmido 2026-06-07 01:19:08 +00:00
parent 252cd4513d
commit 548a37c888
35 changed files with 3815 additions and 25 deletions

View File

@ -7,6 +7,7 @@
use App\Models\ManagedEnvironment;
use App\Support\OperateHub\OperateHubShell;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
use RuntimeException;
trait ResolvesPanelTenantContext
@ -68,16 +69,50 @@ private static function currentPanelId(mixed $request): ?string
if (str_contains($routeName, '.admin.')) {
return 'admin';
}
if (str_contains($routeName, '.system.')) {
return 'system';
}
}
$path = is_object($request) && method_exists($request, 'path')
? '/'.ltrim((string) $request->path(), '/')
: null;
if (is_string($path) && str_starts_with($path, '/admin/')) {
return 'admin';
$panelId = static::panelIdFromPath($path);
if ($panelId !== null) {
return $panelId;
}
return null;
if (! $request instanceof Request || ! static::isLivewireRequest($request, $path)) {
return null;
}
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH);
if (! is_string($refererPath) || $refererPath === '') {
return null;
}
return static::panelIdFromPath('/'.ltrim($refererPath, '/'));
}
private static function panelIdFromPath(?string $path): ?string
{
if (! is_string($path) || $path === '') {
return null;
}
return match (true) {
$path === '/admin', str_starts_with($path, '/admin/') => 'admin',
$path === '/system', str_starts_with($path, '/system/') => 'system',
default => null,
};
}
private static function isLivewireRequest(Request $request, ?string $path): bool
{
return $request->headers->has('x-livewire')
|| (is_string($path) && preg_match('#^/(?:livewire(?:-[^/]+)?/update|livewire-unit-test-endpoint)(?:/|$)#', $path) === 1);
}
}

View File

@ -241,7 +241,8 @@ protected function getHeaderActions(): array
->send();
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
})
}),
fn (): ?ManagedEnvironment => static::resolveTenantContextForCurrentPanel()
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)

View File

@ -178,7 +178,8 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
}),
fn (): ?ManagedEnvironment => static::resolveTenantContextForCurrentPanel()
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip(static::text('resource.sync_permission_tooltip'))

View File

@ -2,18 +2,29 @@
namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\PolicyResource;
use App\Models\ManagedEnvironment;
use App\Support\Filament\CanonicalAdminEnvironmentFilterState;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\ListRecords;
class ListPolicies extends ListRecords
{
use ResolvesPanelTenantContext;
protected static string $resource = PolicyResource::class;
public function mount(): void
{
$this->syncCanonicalAdminEnvironmentFilterState();
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof ManagedEnvironment) {
app(WorkspaceContext::class)->rememberEnvironmentContext($tenant, request());
}
parent::mount();
}

View File

@ -382,9 +382,12 @@ public function artifactResultContext(): array
public function relatedArtifactId(): ?int
{
return match ($this->governanceArtifactFamily()) {
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
? (int) data_get($this->context, 'result.snapshot_id')
: null,
'baseline_snapshot' => $this->reconciledRelatedBaselineSnapshotId()
?? (is_numeric(data_get($this->context, 'baseline_snapshot_id'))
? (int) data_get($this->context, 'baseline_snapshot_id')
: (is_numeric(data_get($this->context, 'result.snapshot_id'))
? (int) data_get($this->context, 'result.snapshot_id')
: null)),
default => null,
};
}
@ -441,11 +444,21 @@ public function reconciledRelatedEvidenceSnapshotId(): ?int
return $this->reconciledRelatedIdForType('evidence_snapshot', ['snapshot.id']);
}
public function reconciledRelatedBaselineSnapshotId(): ?int
{
return $this->reconciledRelatedIdForType('baseline_snapshot', ['baseline_snapshot.id', 'snapshot.id']);
}
public function reconciledRelatedReviewPackId(): ?int
{
return $this->reconciledRelatedIdForType('review_pack', ['review_pack.id']);
}
public function reconciledRelatedBackupSetId(): ?int
{
return $this->reconciledRelatedIdForType('backup_set', ['backup_set.id']);
}
public function isLifecycleReconciled(): bool
{
return $this->reconciliation() !== [];

View File

@ -5,6 +5,7 @@
namespace App\Services;
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use App\Support\OperationRunStatus;
use App\Support\Operations\Reconciliation\OperationRunReconciliationAdapter;
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
@ -34,20 +35,17 @@ public function supportedTypes(): array
*/
public function reconcile(array $options = []): array
{
$type = $options['type'] ?? null;
$type = $this->normalizeRequestedType($options['type'] ?? null);
$tenantId = $options['managed_environment_id'] ?? null;
$olderThanMinutes = max(1, (int) ($options['older_than_minutes'] ?? 10));
$limit = max(1, (int) ($options['limit'] ?? 50));
$dryRun = (bool) ($options['dry_run'] ?? true);
if ($type !== null && ! in_array($type, $this->supportedTypes(), true)) {
throw new \InvalidArgumentException('Unsupported adapter run type: '.$type);
}
$cutoff = CarbonImmutable::now()->subMinutes($olderThanMinutes);
$candidateTypes = $this->candidateRawTypes($type);
$query = OperationRun::query()
->whereIn('type', $type ? [$type] : $this->supportedTypes())
->whereIn('type', $candidateTypes)
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
->where(function (Builder $q) use ($cutoff): void {
$q
@ -186,7 +184,37 @@ private function reconcileUsingAdapter(
private function resolveAdapter(OperationRun $run): ?OperationRunReconciliationAdapter
{
return $this->registry->forType((string) $run->type);
return $this->registry->forType($run->canonicalOperationType());
}
/**
* @return array<int, string>
*/
private function candidateRawTypes(?string $type = null): array
{
$canonicalTypes = $type !== null ? [$type] : $this->supportedTypes();
return array_values(array_unique(array_merge(
...array_map(
static fn (string $canonicalType): array => OperationCatalog::rawValuesForCanonical($canonicalType),
$canonicalTypes,
),
)));
}
private function normalizeRequestedType(mixed $type): ?string
{
if (! is_string($type) || trim($type) === '') {
return null;
}
$canonicalType = OperationCatalog::canonicalCode($type);
if (! in_array($canonicalType, $this->supportedTypes(), true)) {
throw new \InvalidArgumentException('Unsupported adapter run type: '.$type);
}
return $canonicalType;
}
private function syncRestoreLifecycleTimestamps(OperationRun $run, ReconciliationResult $result): void

View File

@ -494,7 +494,8 @@ private function resolveOperationRunRule(NavigationMatrixRule $rule, OperationRu
return match ($rule->relationKey) {
'backup_set' => $this->backupSetEntry(
rule: $rule,
backupSetId: is_numeric($context['backup_set_id'] ?? null) ? (int) $context['backup_set_id'] : null,
backupSetId: $run->reconciledRelatedBackupSetId()
?? (is_numeric($context['backup_set_id'] ?? null) ? (int) $context['backup_set_id'] : null),
tenantId: is_numeric($run->managed_environment_id ?? null) ? (int) $run->managed_environment_id : null,
),
'restore_run' => $this->restoreRunEntry(
@ -509,7 +510,8 @@ private function resolveOperationRunRule(NavigationMatrixRule $rule, OperationRu
),
'baseline_snapshot' => $this->baselineSnapshotEntry(
rule: $rule,
snapshotId: is_numeric($context['baseline_snapshot_id'] ?? null) ? (int) $context['baseline_snapshot_id'] : null,
snapshotId: $run->reconciledRelatedBaselineSnapshotId()
?? (is_numeric($context['baseline_snapshot_id'] ?? null) ? (int) $context['baseline_snapshot_id'] : null),
workspaceId: (int) $run->workspace_id,
),
'parent_policy' => $this->operationRunPolicyEntry($rule, $run),

View File

@ -8,6 +8,7 @@
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Pages\InventoryCoverage as InventoryCoveragePage;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\RestoreRunResource;
@ -169,6 +170,10 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
if ($canonicalType === 'inventory.sync') {
$links['Inventory'] = InventoryItemResource::getUrl('index', tenant: $tenant);
if ($run->inventoryCoverage() instanceof \App\Support\Inventory\InventoryCoverage) {
$links['Inventory Coverage'] = InventoryCoveragePage::getUrl(tenant: $tenant);
}
}
if ($canonicalType === 'policy.sync') {
@ -189,7 +194,12 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
}
if ($canonicalType === 'baseline.capture') {
$snapshotId = data_get($context, 'result.snapshot_id');
$snapshotId = $run->reconciledRelatedBaselineSnapshotId()
?? (is_numeric(data_get($context, 'baseline_snapshot_id'))
? (int) data_get($context, 'baseline_snapshot_id')
: (is_numeric(data_get($context, 'result.snapshot_id'))
? (int) data_get($context, 'result.snapshot_id')
: null));
if (is_numeric($snapshotId)) {
$links['Baseline Snapshot'] = BaselineSnapshotResource::getUrl('view', ['record' => (int) $snapshotId], panel: 'admin');
@ -207,6 +217,14 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant):
if (in_array($canonicalType, ['backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge'], true)) {
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
$backupSetId = $run->reconciledRelatedBackupSetId()
?? (is_numeric($context['backup_set_id'] ?? null) ? (int) $context['backup_set_id'] : null);
if ($backupSetId !== null) {
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => $backupSetId], tenant: $tenant);
}
}
if ($canonicalType === 'restore.execute') {

View File

@ -35,7 +35,7 @@ public function shortExplanation(): string
self::StaleRunning => 'The run stayed active past its lifecycle window and was marked failed.',
self::InfrastructureTimeoutOrAbandonment => 'Queue infrastructure ended the job before normal completion could update the run.',
self::QueueFailureBridge => 'The platform bridged a queue failure back to the owning run and marked it failed.',
self::AdapterOutOfSync => 'A related restore record reached terminal truth before the operation run was updated.',
self::AdapterOutOfSync => 'A related artifact or result reached terminal truth before the operation run was updated.',
};
}
@ -54,7 +54,7 @@ public function nextSteps(): array
{
return match ($this) {
self::AdapterOutOfSync => [
NextStepOption::instruction('Review the related restore record before deciding whether to run the workflow again.'),
NextStepOption::instruction('Review the related artifact or result before deciding whether to run the workflow again.'),
],
default => [
NextStepOption::instruction('Review worker health and logs before retrying this operation.'),

View File

@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\LifecycleReconciliationReason;
use Throwable;
final class BackupScheduleExecutionReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function key(): string
{
return 'backup_schedule_execution';
}
public function supportedTypes(): array
{
return [OperationRunType::BackupScheduleExecute->value];
}
public function supportsType(string $type): bool
{
return OperationCatalog::canonicalCode($type) === OperationRunType::BackupScheduleExecute->value;
}
public function reconcile(OperationRun $run): ?ReconciliationResult
{
if (! $this->supportsType((string) $run->type)) {
return ReconciliationResult::unsupported(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'This adapter only supports backup schedule execution runs.',
evidence: [
'adapter' => $this->key(),
'type' => (string) $run->type,
],
);
}
$context = is_array($run->context) ? $run->context : [];
$scheduleId = is_numeric($context['backup_schedule_id'] ?? null) ? (int) $context['backup_schedule_id'] : null;
$backupSetId = is_numeric($context['backup_set_id'] ?? null) ? (int) $context['backup_set_id'] : null;
$evidence = [
'adapter' => $this->key(),
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => (int) $run->workspace_id,
'managed_environment_id' => (int) $run->managed_environment_id,
'backup_schedule_id' => $scheduleId,
'backup_set_id' => $backupSetId,
];
[$backupSet, $scopeProblem] = $this->resolveBackupSet($run, $backupSetId);
if ($scopeProblem !== null) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $scopeProblem,
evidence: $evidence,
);
}
if ($backupSet instanceof BackupSet) {
$failures = is_array($backupSet->metadata['failures'] ?? null) ? $backupSet->metadata['failures'] : [];
$failureCount = count($failures);
$itemCount = max(0, (int) ($backupSet->item_count ?? 0));
$summaryCounts = [
'total' => $itemCount + $failureCount,
'processed' => $itemCount + $failureCount,
'succeeded' => $itemCount,
'failed' => $failureCount,
'created' => 1,
'updated' => $itemCount,
'items' => $itemCount + $failureCount,
];
$related = [
'type' => 'backup_set',
'id' => (int) $backupSet->getKey(),
'status' => (string) $backupSet->status,
'item_count' => $itemCount,
];
if ((string) $backupSet->status === 'completed' && $failureCount === 0) {
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule already produced a complete backup set before the run finished updating.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
if ($itemCount > 0 && ((string) $backupSet->status === 'partial' || $failureCount > 0)) {
return new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule produced a usable backup set, but some captures did not finish cleanly.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
summaryCounts: $summaryCounts,
failures: [[
'code' => 'backup_schedule.partial',
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
'message' => 'The backup schedule produced a usable backup set, but some captures did not finish cleanly.',
]],
safeForAutoCompletion: true,
);
}
if ((string) $backupSet->status === 'failed') {
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule produced no usable backup set before the run stopped updating.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The related backup set is still being prepared and does not prove final backup truth yet.',
evidence: $evidence + ['chosen_backup_set_id' => (int) $backupSet->getKey()],
related: $related,
);
}
$schedule = $scheduleId !== null
? BackupSchedule::query()->withTrashed()->whereKey($scheduleId)->where('managed_environment_id', (int) $run->managed_environment_id)->first()
: null;
if ($schedule instanceof BackupSchedule && $schedule->trashed()) {
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The backup schedule is archived, so this run never produced a current backup set.',
evidence: $evidence,
summaryCounts: [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 1,
],
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'No current backup set proof is available yet for this backup schedule run.',
evidence: $evidence,
);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
return null;
}
/**
* @return array{0:BackupSet|null,1:?string}
*/
private function resolveBackupSet(OperationRun $run, ?int $backupSetId): array
{
if ($backupSetId === null) {
return [null, null];
}
$candidate = BackupSet::query()->whereKey($backupSetId)->first();
if (! $candidate instanceof BackupSet) {
return [null, null];
}
if ((int) $candidate->managed_environment_id !== (int) $run->managed_environment_id) {
return [null, 'The recorded backup set no longer matches the queued backup schedule scope safely.'];
}
return [$candidate, null];
}
}

View File

@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\LifecycleReconciliationReason;
use Throwable;
final class BaselineCaptureReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function key(): string
{
return 'baseline_capture';
}
public function supportedTypes(): array
{
return [OperationRunType::BaselineCapture->value];
}
public function supportsType(string $type): bool
{
return OperationCatalog::canonicalCode($type) === OperationRunType::BaselineCapture->value;
}
public function reconcile(OperationRun $run): ?ReconciliationResult
{
if (! $this->supportsType((string) $run->type)) {
return ReconciliationResult::unsupported(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'This adapter only supports baseline capture runs.',
evidence: [
'adapter' => $this->key(),
'type' => (string) $run->type,
],
);
}
$context = is_array($run->context) ? $run->context : [];
$profileId = is_numeric($context['baseline_profile_id'] ?? null) ? (int) $context['baseline_profile_id'] : null;
$reasonCode = $this->captureReasonCode($context);
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
$gapsCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$itemsCaptured = $this->intValue(data_get($context, 'result.items_captured'));
$evidence = [
'adapter' => $this->key(),
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => (int) $run->workspace_id,
'baseline_profile_id' => $profileId,
'reason_code' => $reasonCode,
'subjects_total' => $subjectsTotal,
'gaps_count' => $gapsCount,
'items_captured' => $itemsCaptured,
'resume_token_present' => is_string($resumeToken) && trim($resumeToken) !== '',
];
[$snapshot, $scopeProblem] = $this->resolveSnapshot($run, $profileId);
if ($scopeProblem !== null) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $scopeProblem,
evidence: $evidence,
);
}
if ($snapshot instanceof BaselineSnapshot) {
$itemsCaptured = max($itemsCaptured, $this->intValue(data_get($snapshot->summary_jsonb, 'total_items')));
$subjectsTotal = max($subjectsTotal, $itemsCaptured);
$gapsCount = max($gapsCount, $this->intValue(data_get($snapshot->summary_jsonb, 'gaps.count')));
$reasonCode ??= is_string(data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code'))
? (string) data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code')
: null;
$summaryCounts = $this->summaryCounts($subjectsTotal, $itemsCaptured);
$related = [
'type' => 'baseline_snapshot',
'id' => (int) $snapshot->getKey(),
'lifecycle_state' => $snapshot->lifecycleState()->value,
'baseline_profile_id' => (int) $snapshot->baseline_profile_id,
];
if ($snapshot->lifecycleState() === BaselineSnapshotLifecycleState::Complete) {
if ((is_string($resumeToken) && trim($resumeToken) !== '') || $gapsCount > 0 || $itemsCaptured < $subjectsTotal) {
return new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture recorded a usable snapshot, but evidence gaps still limit it.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
failures: [[
'code' => 'baseline.capture.partial',
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
'message' => 'The baseline capture recorded a usable snapshot, but evidence gaps still limit it.',
]],
safeForAutoCompletion: true,
);
}
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture already produced a complete snapshot before the run finished updating.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
return new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture finished without a usable baseline because no governed subjects were in scope.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
failures: [[
'code' => 'baseline.capture.partial',
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
'message' => 'The baseline capture finished without a usable baseline because no governed subjects were in scope.',
]],
safeForAutoCompletion: true,
);
}
if ($snapshot->lifecycleState() === BaselineSnapshotLifecycleState::Building) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline snapshot is still building and does not prove final capture truth yet.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
);
}
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture created a snapshot row, but it never became usable baseline truth.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
if ($reasonCode !== null && in_array($reasonCode, $this->blockedReasonCodes(), true)) {
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $this->blockedReasonMessage($reasonCode, (bool) data_get($context, 'baseline_capture.eligibility.changed_after_enqueue')),
evidence: $evidence,
summaryCounts: $this->summaryCounts($subjectsTotal, $itemsCaptured),
);
}
if ($reasonCode !== null && in_array($reasonCode, [
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED,
BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED,
BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF,
BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY,
], true)) {
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture stopped before it could prove a usable snapshot.',
evidence: $evidence,
summaryCounts: $this->summaryCounts($subjectsTotal, $itemsCaptured),
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'No current-scope baseline snapshot proof is available yet for this run.',
evidence: $evidence,
);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
return null;
}
/**
* @return array{0:BaselineSnapshot|null,1:?string}
*/
private function resolveSnapshot(OperationRun $run, ?int $profileId): array
{
$context = is_array($run->context) ? $run->context : [];
$snapshotId = is_numeric($context['baseline_snapshot_id'] ?? null)
? (int) $context['baseline_snapshot_id']
: (is_numeric(data_get($context, 'result.snapshot_id')) ? (int) data_get($context, 'result.snapshot_id') : null);
if ($snapshotId !== null) {
$candidate = BaselineSnapshot::query()->whereKey($snapshotId)->first();
if (! $candidate instanceof BaselineSnapshot) {
return [null, null];
}
if ((int) $candidate->workspace_id !== (int) $run->workspace_id || ($profileId !== null && (int) $candidate->baseline_profile_id !== $profileId)) {
return [null, 'The recorded baseline snapshot no longer matches the queued capture scope safely.'];
}
return [$candidate, null];
}
if ($profileId === null) {
return [null, null];
}
$candidates = BaselineSnapshot::query()
->where('workspace_id', (int) $run->workspace_id)
->where('baseline_profile_id', $profileId)
->where('completion_meta_jsonb->producer_run_id', (int) $run->getKey())
->orderByDesc('id')
->get();
if ($candidates->count() > 1) {
return [null, 'Multiple baseline snapshots point at this run, so reconciliation stays fail-closed.'];
}
return [$candidates->first(), null];
}
/**
* @param array<string, mixed> $context
*/
private function captureReasonCode(array $context): ?string
{
foreach ([
data_get($context, 'baseline_capture.reason_code'),
$context['reason_code'] ?? null,
data_get($context, 'result.snapshot_reason_code'),
] as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
return null;
}
/**
* @return array<int, string>
*/
private function blockedReasonCodes(): array
{
return [
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT,
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE,
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
BaselineReasonCodes::CAPTURE_INVALID_SCOPE,
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE,
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
];
}
private function blockedReasonMessage(string $reasonCode, bool $changedAfterEnqueue): string
{
return match ($reasonCode) {
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING => 'The baseline capture could not continue because no current inventory basis was available.',
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync was blocked.',
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync failed.',
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.',
default => 'The baseline capture was blocked before it could produce trustworthy snapshot proof.',
};
}
/**
* @return array<string, int>
*/
private function summaryCounts(int $subjectsTotal, int $itemsCaptured): array
{
$total = max(0, $subjectsTotal);
$succeeded = max(0, $itemsCaptured);
$total = max($total, $succeeded);
return [
'total' => $total,
'processed' => $total,
'succeeded' => $succeeded,
'failed' => max(0, $total - $succeeded),
];
}
private function intValue(mixed $value): int
{
return is_numeric($value) ? (int) $value : 0;
}
}

View File

@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Support\Inventory\InventoryCoverage;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\LifecycleReconciliationReason;
use Throwable;
final class InventorySyncReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function key(): string
{
return 'inventory_sync';
}
public function supportedTypes(): array
{
return [OperationRunType::InventorySync->value];
}
public function supportsType(string $type): bool
{
return OperationCatalog::canonicalCode($type) === OperationRunType::InventorySync->value;
}
public function reconcile(OperationRun $run): ?ReconciliationResult
{
if (! $this->supportsType((string) $run->type)) {
return ReconciliationResult::unsupported(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'This adapter only supports inventory sync runs.',
evidence: [
'adapter' => $this->key(),
'type' => (string) $run->type,
],
);
}
$context = is_array($run->context) ? $run->context : [];
$attemptedTypes = $this->attemptedTypes($context);
$coverage = $run->inventoryCoverage();
$coverageRows = $coverage?->rows() ?? [];
$errorCodes = $this->errorCodes($context);
$scopedItemCount = InventoryItem::query()
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->where('last_seen_operation_run_id', (int) $run->getKey())
->count();
$globalItemCount = InventoryItem::query()
->where('last_seen_operation_run_id', (int) $run->getKey())
->count();
$outOfScopeItemCount = max(0, $globalItemCount - $scopedItemCount);
$evidence = [
'adapter' => $this->key(),
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => (int) $run->workspace_id,
'managed_environment_id' => (int) $run->managed_environment_id,
'attempted_types' => $attemptedTypes,
'coverage_types' => array_keys($coverageRows),
'scoped_item_count' => $scopedItemCount,
'result_error_codes' => $errorCodes,
];
if ($outOfScopeItemCount > 0) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'Inventory rows tied to this run exist outside the run scope, so reconciliation stays fail-closed.',
evidence: $evidence + ['out_of_scope_item_count' => $outOfScopeItemCount],
);
}
if ($attemptedTypes === []) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The run no longer carries a trustworthy inventory selection scope.',
evidence: $evidence,
);
}
if (! $coverage instanceof InventoryCoverage) {
return $this->resolveWithoutCoverage($errorCodes, $evidence);
}
$unexpectedTypes = array_values(array_diff(array_keys($coverageRows), $attemptedTypes));
if ($unexpectedTypes !== []) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'Recorded inventory coverage no longer matches the queued selection scope safely.',
evidence: $evidence + ['unexpected_types' => $unexpectedTypes],
);
}
$successCount = 0;
$failedCount = 0;
$skippedCount = 0;
$missingCount = 0;
foreach ($attemptedTypes as $type) {
$row = $coverageRows[$type] ?? null;
if (! is_array($row)) {
$missingCount++;
continue;
}
match ((string) ($row['status'] ?? '')) {
InventoryCoverage::StatusSucceeded => $successCount++,
InventoryCoverage::StatusFailed => $failedCount++,
InventoryCoverage::StatusSkipped => $skippedCount++,
default => $missingCount++,
};
}
$summaryCounts = [
'total' => count($attemptedTypes),
'processed' => count($attemptedTypes),
'succeeded' => $successCount,
'failed' => $failedCount + $missingCount,
'skipped' => $skippedCount,
'items' => $scopedItemCount,
'updated' => $scopedItemCount,
];
if ($successCount === count($attemptedTypes) && $failedCount === 0 && $skippedCount === 0 && $missingCount === 0) {
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The inventory sync already recorded complete coverage before the run finished updating.',
evidence: $evidence,
summaryCounts: $summaryCounts,
);
}
if ($successCount > 0) {
return new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The inventory sync recorded usable coverage, but some selected policy types did not complete cleanly.',
evidence: $evidence,
related: [],
summaryCounts: $summaryCounts,
failures: [[
'code' => 'inventory.partial',
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
'message' => 'The inventory sync recorded usable coverage, but some selected policy types did not complete cleanly.',
]],
safeForAutoCompletion: true,
);
}
$blockedCode = $this->blockedErrorCode($errorCodes);
if ($blockedCode !== null && $skippedCount + $missingCount === count($attemptedTypes)) {
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $this->blockedReasonMessage($blockedCode),
evidence: $evidence + ['blocked_error_code' => $blockedCode],
summaryCounts: $summaryCounts,
);
}
if ($failedCount > 0 || $errorCodes !== []) {
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The inventory sync produced no trustworthy coverage before the run stopped updating.',
evidence: $evidence,
summaryCounts: $summaryCounts,
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'Inventory coverage is still incomplete, so the run stays active until stronger proof appears.',
evidence: $evidence,
);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
return null;
}
/**
* @param array<string, mixed> $context
* @return array<int, string>
*/
private function attemptedTypes(array $context): array
{
$policyTypes = is_array($context['policy_types'] ?? null)
? array_values(array_filter(array_map('strval', $context['policy_types'])))
: [];
$foundationTypes = array_values(array_filter(
array_map(
static fn (array $row): mixed => $row['type'] ?? null,
InventoryPolicyTypeMeta::foundations(),
),
static fn (mixed $type): bool => is_string($type) && $type !== '',
));
$attemptedTypes = (bool) ($context['include_foundations'] ?? false)
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
: array_values(array_diff($policyTypes, $foundationTypes));
sort($attemptedTypes, SORT_STRING);
return $attemptedTypes;
}
/**
* @param array<string, mixed> $context
* @return array<int, string>
*/
private function errorCodes(array $context): array
{
$errorCodes = is_array(data_get($context, 'result.error_codes'))
? data_get($context, 'result.error_codes')
: [];
return array_values(array_filter(
array_unique(array_map('strval', $errorCodes)),
static fn (string $code): bool => trim($code) !== '',
));
}
/**
* @param array<int, string> $errorCodes
* @param array<string, mixed> $evidence
*/
private function resolveWithoutCoverage(array $errorCodes, array $evidence): ReconciliationResult
{
$blockedCode = $this->blockedErrorCode($errorCodes);
if ($blockedCode !== null) {
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $this->blockedReasonMessage($blockedCode),
evidence: $evidence + ['blocked_error_code' => $blockedCode],
summaryCounts: [
'total' => count($evidence['attempted_types'] ?? []),
'processed' => count($evidence['attempted_types'] ?? []),
'succeeded' => 0,
'failed' => 0,
'skipped' => count($evidence['attempted_types'] ?? []),
],
);
}
if ($errorCodes !== []) {
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The inventory sync stopped before it could write trustworthy coverage.',
evidence: $evidence,
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'No canonical inventory coverage was recorded yet for this run.',
evidence: $evidence,
);
}
/**
* @param array<int, string> $errorCodes
*/
private function blockedErrorCode(array $errorCodes): ?string
{
foreach ($errorCodes as $errorCode) {
if (in_array($errorCode, ['concurrency_limit_global', 'concurrency_limit_tenant', 'lock_contended'], true)) {
return $errorCode;
}
}
return null;
}
private function blockedReasonMessage(string $errorCode): string
{
return match ($errorCode) {
'concurrency_limit_global' => 'The inventory sync never started because the global concurrency limit blocked execution.',
'concurrency_limit_tenant' => 'The inventory sync never started because the tenant concurrency limit blocked execution.',
'lock_contended' => 'The inventory sync never started because another run already held the same selection lock.',
default => 'The inventory sync was blocked before it could record trustworthy coverage.',
};
}
}

View File

@ -11,6 +11,9 @@ public function __construct(
private readonly EnvironmentReviewComposeReconciliationAdapter $environmentReviewComposeAdapter,
private readonly EvidenceSnapshotReconciliationAdapter $evidenceSnapshotAdapter,
private readonly ReviewPackArtifactReconciliationAdapter $reviewPackAdapter,
private readonly InventorySyncReconciliationAdapter $inventorySyncAdapter,
private readonly BaselineCaptureReconciliationAdapter $baselineCaptureAdapter,
private readonly BackupScheduleExecutionReconciliationAdapter $backupScheduleExecutionAdapter,
) {}
/**
@ -23,6 +26,9 @@ public function all(): array
$this->environmentReviewComposeAdapter,
$this->evidenceSnapshotAdapter,
$this->reviewPackAdapter,
$this->inventorySyncAdapter,
$this->baselineCaptureAdapter,
$this->backupScheduleExecutionAdapter,
];
}
@ -36,6 +42,9 @@ public function supportedTypes(): array
$this->environmentReviewComposeAdapter->supportedTypes(),
$this->evidenceSnapshotAdapter->supportedTypes(),
$this->reviewPackAdapter->supportedTypes(),
$this->inventorySyncAdapter->supportedTypes(),
$this->baselineCaptureAdapter->supportedTypes(),
$this->backupScheduleExecutionAdapter->supportedTypes(),
)));
}

View File

@ -60,7 +60,7 @@ public function reconcile(OperationRun $run): ?ReconciliationResult
status: $status,
outcome: $outcome,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
reasonMessage: 'A related restore record reached terminal truth before the operation run was updated.',
evidence: array_filter([
'restore_run_id' => (int) $restoreRun->getKey(),
'restore_status' => $restoreStatus?->value,

View File

@ -62,9 +62,12 @@ private function __construct(Action|BulkAction $action)
*
* @param Action $action The Filament action to wrap
*/
public static function forAction(Action $action): self
public static function forAction(Action $action, Model|Closure|null $record = null): self
{
return new self($action);
$instance = new self($action);
$instance->record = $record;
return $instance;
}
/**

View File

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(60_000);
it('Spec362 smokes selected-family reconciled truth across the existing operations hub and detail pages', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
spec362AuthenticateBrowser($this, $user, $environment);
[$inventoryRun, $baselineRun, $backupRun] = spec362BrowserSeedSelectedFamilyRuns($environment, $user);
visit(OperationRunLinks::index($environment))
->resize(1440, 1100)
->waitForText('Operations Hub')
->assertSee('Inventory sync')
->assertSee('Baseline capture')
->assertSee('Backup schedule run')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(OperationRunLinks::tenantlessView($inventoryRun))
->waitForText('Monitoring detail')
->assertSee('Inventory sync')
->assertSee('Completed with follow-up')
->assertSee('Automatically reconciled')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(OperationRunLinks::tenantlessView($baselineRun))
->waitForText('Monitoring detail')
->assertSee('Baseline capture')
->assertSee('Completed successfully')
->assertSee('Automatically reconciled')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(OperationRunLinks::tenantlessView($backupRun))
->waitForText('Monitoring detail')
->assertSee('Backup schedule run')
->assertSee('Completed with follow-up')
->assertSee('Automatically reconciled')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
function spec362AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void
{
$workspaceId = (int) $environment->workspace_id;
$test->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $environment->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $environment->getKey(),
]);
setAdminPanelContext($environment);
}
/**
* @return array{0: OperationRun, 1: OperationRun, 2: OperationRun}
*/
function spec362BrowserSeedSelectedFamilyRuns(ManagedEnvironment $environment, User $user): array
{
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $environment->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $environment->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'summary_jsonb' => ['total_items' => 2],
]);
$backupSet = BackupSet::factory()->for($environment)->create([
'status' => 'partial',
'item_count' => 2,
'metadata' => [
'failures' => [
['policy_id' => 'policy-1'],
],
],
]);
$inventoryRun = OperationRun::factory()->forTenant($environment)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'],
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'conditionalAccessPolicy' => 'succeeded',
'deviceConfiguration' => 'skipped',
], []),
],
],
]);
$baselineRun = OperationRun::factory()->forTenant($environment)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'baseline.capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
],
]);
$backupRun = OperationRun::factory()->forTenant($environment)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'backup.schedule.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$service = app(OperationRunService::class);
$inventoryRun = $service->updateRunWithReconciliation(
run: $inventoryRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: [
'total' => 2,
'processed' => 2,
'succeeded' => 1,
'failed' => 0,
'skipped' => 1,
],
failures: [[
'code' => 'inventory.partial',
'reason_code' => 'run.adapter_out_of_sync',
'message' => 'The inventory sync recorded usable coverage, but some selected policy types did not complete cleanly.',
]],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'The inventory sync recorded usable coverage, but some selected policy types did not complete cleanly.',
source: 'adapter_reconciler',
evidence: ['adapter' => 'inventory_sync'],
adapter: 'inventory_sync',
decision: 'reconciled_partially_succeeded',
)->fresh();
$baselineRun = $service->updateRunWithReconciliation(
run: $baselineRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'total' => 2,
'processed' => 2,
'succeeded' => 2,
'failed' => 0,
],
failures: [],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'The baseline capture already produced a complete snapshot before the run finished updating.',
source: 'adapter_reconciler',
evidence: ['adapter' => 'baseline_capture'],
adapter: 'baseline_capture',
decision: 'reconciled_succeeded',
related: [
'type' => 'baseline_snapshot',
'id' => (int) $snapshot->getKey(),
'lifecycle_state' => 'complete',
],
)->fresh();
$backupRun = $service->updateRunWithReconciliation(
run: $backupRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: [
'total' => 3,
'processed' => 3,
'succeeded' => 2,
'failed' => 1,
'created' => 1,
'updated' => 2,
'items' => 3,
],
failures: [[
'code' => 'backup_schedule.partial',
'reason_code' => 'run.adapter_out_of_sync',
'message' => 'The backup schedule produced a usable backup set, but some captures did not finish cleanly.',
]],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'The backup schedule produced a usable backup set, but some captures did not finish cleanly.',
source: 'adapter_reconciler',
evidence: ['adapter' => 'backup_schedule_execution'],
adapter: 'backup_schedule_execution',
decision: 'reconciled_partially_succeeded',
related: [
'type' => 'backup_set',
'id' => (int) $backupSet->getKey(),
'status' => 'partial',
],
)->fresh();
return [$inventoryRun, $baselineRun, $backupRun];
}

View File

@ -64,8 +64,7 @@
],
]);
expect(data_get($operationRun->context, 'backup_schedule_id'))->toBe((int) $schedule->id)
->and(data_get($operationRun->context, 'reason_code'))->toBe('run.stale_running')
->and(data_get($operationRun->context, 'reconciliation.reason'))->toBe('run.stale_running')
->and(data_get($operationRun->context, 'reconciliation.reason_code'))->toBe('run.stale_running')
->and(data_get($operationRun->context, 'reconciliation.reason_message'))->toBe('The run stayed active past its lifecycle window and was marked failed.')
->and(data_get($operationRun->context, 'reconciliation.source'))->toBe('scheduled_reconciler');
});

View File

@ -10,6 +10,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -89,6 +90,32 @@
->assertActionExists('run_inventory_sync', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
});
test('inventory sync action mounts from a workspace-scoped Livewire referer', function (): void {
Queue::fake();
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$referer = InventoryItemResource::getUrl('index', panel: 'admin', tenant: $tenant);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Livewire::withHeaders([
'referer' => $referer,
'x-livewire' => '1',
])
->actingAs($user)
->test(ListInventoryItems::class)
->assertActionVisible('run_inventory_sync')
->assertActionEnabled('run_inventory_sync')
->mountAction('run_inventory_sync')
->assertActionMounted('run_inventory_sync')
->assertMountedActionModalSee('Policy types');
Queue::assertNothingPushed();
});
test('inventory items page shows truthful coverage stats instead of support-matrix wording', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Services\AdapterRunReconciler;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('reconciles legacy backup schedule runs from a matching complete backup set in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$schedule = spec362BackupSchedule($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 2,
'metadata' => [],
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup_schedule_run',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_schedule_id' => (int) $schedule->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$result = app(AdapterRunReconciler::class)->reconcile([
'type' => 'backup_schedule_run',
'managed_environment_id' => (int) $tenant->getKey(),
'older_than_minutes' => 10,
'limit' => 10,
'dry_run' => false,
]);
expect($result['candidates'] ?? null)->toBe(1)
->and($result['reconciled'] ?? null)->toBe(1);
$run->refresh();
$backupSet->refresh();
expect($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and($run->reconciliationAdapter())->toBe('backup_schedule_execution')
->and($run->reconciledRelatedBackupSetId())->toBe((int) $backupSet->getKey())
->and($run->summary_counts)->toMatchArray([
'total' => 2,
'processed' => 2,
'succeeded' => 2,
'failed' => 0,
'created' => 1,
'updated' => 2,
'items' => 2,
])
->and($backupSet->status)->toBe('completed');
expect(array_keys(OperationRunLinks::related($run->fresh(), $tenant)))
->toContain('Backup Schedules', 'Backup Sets', 'Backup Set');
});
it('marks backup schedule runs partially succeeded when the backup set is usable but incomplete in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$schedule = spec362BackupSchedule($tenant);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'partial',
'item_count' => 2,
'metadata' => [
'failures' => [
['policy_id' => 'policy-1'],
],
],
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup.schedule.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_schedule_id' => (int) $schedule->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
expect($change['applied'] ?? null)->toBeTrue();
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
->and($run->reconciliationDecision())->toBe('reconciled_partially_succeeded')
->and($run->summary_counts)->toMatchArray([
'total' => 3,
'processed' => 3,
'succeeded' => 2,
'failed' => 1,
'created' => 1,
'updated' => 2,
'items' => 3,
]);
});
it('marks backup schedule runs blocked when the schedule was archived before any current backup set existed in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$schedule = spec362BackupSchedule($tenant);
$schedule->delete();
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup.schedule.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_schedule_id' => (int) $schedule->getKey(),
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
expect($change['applied'] ?? null)->toBeTrue();
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($run->reconciliationDecision())->toBe('blocked')
->and((string) data_get($run->failure_summary, '0.message'))->toContain('archived');
});
it('fails closed when a backup set crosses the queued tenant scope in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$foreignTenant = ManagedEnvironment::factory()->create();
$schedule = spec362BackupSchedule($tenant);
$foreignBackupSet = BackupSet::factory()->for($foreignTenant)->create();
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup.schedule.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_schedule_id' => (int) $schedule->getKey(),
'backup_set_id' => (int) $foreignBackupSet->getKey(),
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true);
expect($change['applied'] ?? null)->toBeFalse()
->and($change['decision'] ?? null)->toBe('not_reconciled')
->and((string) ($change['reason_message'] ?? ''))->toContain('queued backup schedule scope safely');
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
});
it('keeps backup schedule runs active when no current backup set proof exists yet in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$schedule = spec362BackupSchedule($tenant);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup.schedule.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_schedule_id' => (int) $schedule->getKey(),
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true);
expect($change['applied'] ?? null)->toBeFalse()
->and($change['decision'] ?? null)->toBe('not_reconciled')
->and((string) ($change['reason_message'] ?? ''))->toContain('No current backup set proof');
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
});
function spec362BackupSchedule(ManagedEnvironment $tenant, array $overrides = []): BackupSchedule
{
return BackupSchedule::query()->create(array_merge([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec362 Daily backup',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '10:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'last_run_at' => null,
'last_run_status' => null,
'next_run_at' => null,
], $overrides));
}

View File

@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
use App\Services\AdapterRunReconciler;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('reconciles legacy baseline capture runs from a matching complete snapshot in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'baseline_capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
],
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'summary_jsonb' => ['total_items' => 3],
'completion_meta_jsonb' => [
'producer_run_id' => (int) $run->getKey(),
'persisted_items' => 3,
'expected_items' => 3,
'was_empty_capture' => false,
],
]);
$result = app(AdapterRunReconciler::class)->reconcile([
'type' => 'baseline_capture',
'managed_environment_id' => (int) $tenant->getKey(),
'older_than_minutes' => 10,
'limit' => 10,
'dry_run' => false,
]);
expect($result['candidates'] ?? null)->toBe(1)
->and($result['reconciled'] ?? null)->toBe(1);
$run->refresh();
$snapshot->refresh();
expect($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and($run->reconciliationAdapter())->toBe('baseline_capture')
->and($run->reconciledRelatedBaselineSnapshotId())->toBe((int) $snapshot->getKey())
->and($run->relatedArtifactId())->toBe((int) $snapshot->getKey())
->and($run->summary_counts)->toMatchArray([
'total' => 3,
'processed' => 3,
'succeeded' => 3,
'failed' => 0,
])
->and($snapshot->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Complete);
$expected = BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin');
$links = OperationRunLinks::related($run->fresh(), $tenant);
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
expect($links['Baseline Snapshot'] ?? null)->toBe($expected)
->and($truth->relatedArtifactUrl)->toBe($expected);
});
it('marks baseline capture runs partially succeeded when a usable snapshot still carries gaps in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'summary_jsonb' => [
'total_items' => 2,
'gaps' => ['count' => 1],
],
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'baseline.capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'result' => [
'items_captured' => 2,
],
'baseline_capture' => [
'subjects_total' => 3,
'gaps' => ['count' => 1],
],
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
expect($change['applied'] ?? null)->toBeTrue();
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
->and($run->reconciliationDecision())->toBe('reconciled_partially_succeeded')
->and($run->summary_counts)->toMatchArray([
'total' => 3,
'processed' => 3,
'succeeded' => 2,
'failed' => 1,
]);
});
it('marks baseline capture runs blocked when precondition proof is explicit and no usable snapshot exists in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'baseline.capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
'eligibility' => [
'changed_after_enqueue' => true,
],
],
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
expect($change['applied'] ?? null)->toBeTrue();
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($run->reconciliationDecision())->toBe('blocked')
->and((string) data_get($run->failure_summary, '0.message'))->toContain('latest inventory sync changed after the run was queued');
});
it('fails closed when an explicit baseline snapshot crosses the queued capture scope in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$foreignProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$foreignSnapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $foreignProfile->getKey(),
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'baseline.capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $foreignSnapshot->getKey(),
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true);
expect($change['applied'] ?? null)->toBeFalse()
->and($change['decision'] ?? null)->toBe('not_reconciled')
->and((string) ($change['reason_message'] ?? ''))->toContain('queued capture scope safely');
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value)
->and($run->reconciledRelatedBaselineSnapshotId())->toBeNull();
});

View File

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
use App\Models\InventoryItem;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Services\AdapterRunReconciler;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('reconciles legacy inventory sync runs from full current-scope coverage in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'],
'include_foundations' => false,
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'conditionalAccessPolicy' => 'succeeded',
'deviceConfiguration' => 'succeeded',
], []),
],
],
]);
InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'conditionalAccessPolicy',
'last_seen_operation_run_id' => (int) $run->getKey(),
]);
InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'last_seen_operation_run_id' => (int) $run->getKey(),
]);
$result = app(AdapterRunReconciler::class)->reconcile([
'type' => 'inventory_sync',
'managed_environment_id' => (int) $tenant->getKey(),
'older_than_minutes' => 10,
'limit' => 10,
'dry_run' => false,
]);
expect($result['candidates'] ?? null)->toBe(1)
->and($result['reconciled'] ?? null)->toBe(1);
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
->and($run->reconciliationDecision())->toBe('reconciled_succeeded')
->and($run->reconciliationAdapter())->toBe('inventory_sync')
->and($run->summary_counts)->toMatchArray([
'total' => 2,
'processed' => 2,
'succeeded' => 2,
'failed' => 0,
'skipped' => 0,
'items' => 2,
'updated' => 2,
])
->and(InventoryItem::query()->where('last_seen_operation_run_id', (int) $run->getKey())->count())->toBe(2);
expect(array_keys(OperationRunLinks::related($run->fresh(), $tenant)))
->toContain('Inventory', 'Inventory Coverage');
});
it('marks inventory sync runs partially succeeded when usable coverage exists alongside blocked proof in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'],
'include_foundations' => false,
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'conditionalAccessPolicy' => 'succeeded',
'deviceConfiguration' => 'skipped',
], []),
],
'result' => [
'error_codes' => ['concurrency_limit_tenant'],
],
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
expect($change['applied'] ?? null)->toBeTrue();
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
->and($run->reconciliationDecision())->toBe('reconciled_partially_succeeded')
->and($run->summary_counts)->toMatchArray([
'total' => 2,
'succeeded' => 1,
'failed' => 0,
'skipped' => 1,
]);
});
it('marks inventory sync runs blocked when no canonical coverage was written and lock contention is the only proof in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'result' => [
'error_codes' => ['lock_contended'],
],
],
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false);
expect($change['applied'] ?? null)->toBeTrue();
$run->refresh();
expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($run->reconciliationDecision())->toBe('blocked')
->and(data_get($run->failure_summary, '0.message'))->toContain('same selection lock');
});
it('fails closed when inventory proof leaks outside the queued tenant scope in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$foreignTenant = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'deviceConfiguration' => 'succeeded',
], []),
],
],
]);
InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'last_seen_operation_run_id' => (int) $run->getKey(),
]);
InventoryItem::factory()->create([
'managed_environment_id' => (int) $foreignTenant->getKey(),
'policy_type' => 'deviceConfiguration',
'last_seen_operation_run_id' => (int) $run->getKey(),
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true);
expect($change['applied'] ?? null)->toBeFalse()
->and($change['decision'] ?? null)->toBe('not_reconciled')
->and((string) ($change['reason_message'] ?? ''))->toContain('outside the run scope');
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Queued->value)
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
});
it('keeps selected-family reconciliation idempotent once a run is finalized in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'deviceConfiguration' => 'succeeded',
], []),
],
],
]);
InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'policy_type' => 'deviceConfiguration',
'last_seen_operation_run_id' => (int) $run->getKey(),
]);
$reconciler = app(AdapterRunReconciler::class);
$first = $reconciler->reconcile([
'type' => 'inventory_sync',
'managed_environment_id' => (int) $tenant->getKey(),
'older_than_minutes' => 10,
'limit' => 10,
'dry_run' => false,
]);
$second = $reconciler->reconcile([
'type' => 'inventory_sync',
'managed_environment_id' => (int) $tenant->getKey(),
'older_than_minutes' => 10,
'limit' => 10,
'dry_run' => false,
]);
$run->refresh();
expect($first['reconciled'] ?? null)->toBe(1)
->and($second['candidates'] ?? null)->toBe(0)
->and($second['reconciled'] ?? null)->toBe(0)
->and(data_get($run->context, 'reconciliation.adapter'))->toBe('inventory_sync')
->and(data_get($run->context, 'reconciliation.previous_status'))->toBe(OperationRunStatus::Queued->value)
->and(data_get($run->context, 'reconciliation.reason_code'))->toBe('run.adapter_out_of_sync');
});

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
use App\Services\OperationRunService;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders selected-family reconciled operations DB-only on list and detail surfaces in Spec362', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$backupSet = BackupSet::factory()->for($tenant)->create();
$inventoryRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'policy_types' => ['deviceConfiguration'],
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'deviceConfiguration' => 'succeeded',
], []),
],
],
]);
$baselineRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'baseline.capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
],
]);
$backupRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup.schedule.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$service = app(OperationRunService::class);
$service->updateRunWithReconciliation(
run: $inventoryRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: ['total' => 1, 'succeeded' => 1, 'failed' => 0],
failures: [[
'code' => 'inventory.partial',
'reason_code' => 'run.adapter_out_of_sync',
'message' => 'Usable coverage exists, but not every type completed cleanly.',
]],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'Usable coverage exists, but not every type completed cleanly.',
source: 'adapter_reconciler',
evidence: ['adapter' => 'inventory_sync'],
adapter: 'inventory_sync',
decision: 'reconciled_partially_succeeded',
);
$service->updateRunWithReconciliation(
run: $baselineRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: ['total' => 1, 'succeeded' => 1, 'failed' => 0],
failures: [],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'A matching baseline snapshot was already available.',
source: 'adapter_reconciler',
evidence: ['adapter' => 'baseline_capture'],
adapter: 'baseline_capture',
decision: 'reconciled_succeeded',
related: [
'type' => 'baseline_snapshot',
'id' => (int) $snapshot->getKey(),
'lifecycle_state' => 'complete',
],
);
$service->updateRunWithReconciliation(
run: $backupRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: ['total' => 1, 'succeeded' => 1, 'failed' => 0],
failures: [],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'A matching backup set was already available.',
source: 'adapter_reconciler',
evidence: ['adapter' => 'backup_schedule_execution'],
adapter: 'backup_schedule_execution',
decision: 'reconciled_succeeded',
related: [
'type' => 'backup_set',
'id' => (int) $backupSet->getKey(),
'status' => 'completed',
],
);
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertSuccessful()
->assertSee('Inventory sync')
->assertSee('Baseline capture')
->assertSee('Backup schedule run');
foreach ([$inventoryRun, $baselineRun, $backupRun] as $run) {
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run->fresh()))
->assertSuccessful();
}
});

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Services\AdapterRunReconciler;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps scheduled reconciliation scoped to the selected Spec362 families only', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
BackupSet::factory()->for($tenant)->create();
$policyRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'deviceConfiguration' => 'succeeded',
], []),
],
],
]);
$backupSetUpdateRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup_set.update',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_set_id' => 1,
],
]);
$result = app(AdapterRunReconciler::class)->reconcile([
'managed_environment_id' => (int) $tenant->getKey(),
'older_than_minutes' => 10,
'limit' => 10,
'dry_run' => false,
]);
expect($result['candidates'] ?? null)->toBe(0)
->and($result['reconciled'] ?? null)->toBe(0)
->and($result['changes'] ?? null)->toBe([]);
$policyRun->refresh();
$backupSetUpdateRun->refresh();
expect($policyRun->status)->toBe(OperationRunStatus::Queued->value)
->and($backupSetUpdateRun->status)->toBe(OperationRunStatus::Queued->value);
});
it('does not auto-reconcile unsupported sync and backup families from adjacent selected-family proof in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$backupSet = BackupSet::factory()->for($tenant)->create();
$policyRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'deviceConfiguration' => 'succeeded',
], []),
],
],
]);
$backupSetUpdateRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup_set.update',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
expect(app(AdapterRunReconciler::class)->reconcileOperationRun($policyRun, true))->toBeNull()
->and(app(AdapterRunReconciler::class)->reconcileOperationRun($backupSetUpdateRun, true))->toBeNull();
});
it('keeps unsupported sync and backup detail surfaces diagnostic-first in Spec362', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$policyRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
]);
$backupSetUpdateRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup_set.update',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
]);
Filament::setTenant(null, true);
foreach ([$policyRun, $backupSetUpdateRun] as $run) {
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertSuccessful()
->assertSee('Monitoring detail')
->assertDontSee('Automatically reconciled');
}
});

View File

@ -1,6 +1,7 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource;
use App\Jobs\SyncPoliciesJob;
use App\Models\OperationRun;
use App\Models\Policy;
@ -89,6 +90,31 @@ function getPolicyEmptyStateAction(Testable $component, string $name): ?Action
->count())->toBe(1);
});
it('mounts policy sync from a workspace-scoped Livewire referer', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$referer = PolicyResource::getUrl('index', panel: 'admin', tenant: $tenant);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Livewire::withHeaders([
'referer' => $referer,
'x-livewire' => '1',
])
->actingAs($user)
->test(ListPolicies::class)
->assertActionVisible('sync')
->assertActionEnabled('sync')
->mountAction('sync')
->assertActionMounted('sync')
->assertMountedActionModalSee(__('localization.policy.resource.sync_modal_heading'));
Queue::assertNothingPushed();
});
it('queues row policy sync and creates a canonical operation run (no Graph calls in request)', function () {
Queue::fake();
bindFailHardGraphClient();

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource;
use App\Models\ManagedEnvironment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
test('ResolvesPanelTenantContext infers the admin panel tenant from a Livewire referer', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
$updateUri = '/'.collect(app('router')->getRoutes()->getRoutes())
->first(fn ($route): bool => str_contains((string) $route->getName(), 'livewire.update'))
?->uri();
expect($updateUri)->toBeString();
$request = Request::create($updateUri, 'POST');
$request->headers->set('x-livewire', '1');
$request->headers->set('referer', InventoryItemResource::getUrl('index', panel: 'admin', tenant: $tenant));
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
app()->instance('request', $request);
$resolver = new class
{
use ResolvesPanelTenantContext;
public static function resolveTenant(): ?ManagedEnvironment
{
return static::resolveTenantContextForCurrentPanel();
}
};
$resolvedTenant = $resolver::resolveTenant();
expect($resolvedTenant)->toBeInstanceOf(ManagedEnvironment::class);
expect($resolvedTenant?->is($tenant))->toBeTrue();
});

View File

@ -60,3 +60,34 @@
->and($failed->decision)->toBe('failed_unrecoverable')
->and($failed->failures)->toHaveCount(1);
});
it('can express partially succeeded terminal reconciliation decisions without a dedicated helper in Spec362', function (): void {
$reasonCode = LifecycleReconciliationReason::AdapterOutOfSync->value;
$partial = new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: \App\Support\OperationRunStatus::Completed->value,
outcome: \App\Support\OperationRunOutcome::PartiallySucceeded->value,
reasonCode: $reasonCode,
reasonMessage: 'Usable proof exists, but some work remained incomplete.',
evidence: ['proof' => 'usable'],
related: ['type' => 'baseline_snapshot', 'id' => 41],
summaryCounts: ['total' => 3, 'succeeded' => 2, 'failed' => 1],
failures: [[
'code' => 'partial.proof',
'reason_code' => $reasonCode,
'message' => 'Usable proof exists, but some work remained incomplete.',
]],
safeForAutoCompletion: true,
);
expect($partial->shouldFinalizeRun())->toBeTrue()
->and($partial->decision)->toBe('reconciled_partially_succeeded')
->and($partial->outcome)->toBe(\App\Support\OperationRunOutcome::PartiallySucceeded->value)
->and($partial->toArray())->toMatchArray([
'decision' => 'reconciled_partially_succeeded',
'outcome' => \App\Support\OperationRunOutcome::PartiallySucceeded->value,
'reason_code' => $reasonCode,
'summary_counts' => ['total' => 3, 'succeeded' => 2, 'failed' => 1],
]);
});

View File

@ -12,9 +12,15 @@
'environment.review.compose',
'tenant.evidence.snapshot.generate',
'environment.review_pack.generate',
'inventory.sync',
'baseline.capture',
'backup.schedule.execute',
])->and($registry->forType('restore.execute')?->key())->toBe('restore_run')
->and($registry->forType('environment.review.compose')?->key())->toBe('environment_review_compose')
->and($registry->forType('tenant.evidence.snapshot.generate')?->key())->toBe('evidence_snapshot')
->and($registry->forType('environment.review_pack.generate')?->key())->toBe('review_pack')
->and($registry->forType('inventory.sync')?->key())->toBe('inventory_sync')
->and($registry->forType('baseline.capture')?->key())->toBe('baseline_capture')
->and($registry->forType('backup.schedule.execute')?->key())->toBe('backup_schedule_execution')
->and($registry->forType('policy.sync'))->toBeNull();
});

View File

@ -12,8 +12,14 @@
'environment.review.compose',
'tenant.evidence.snapshot.generate',
'environment.review_pack.generate',
'inventory.sync',
'baseline.capture',
'backup.schedule.execute',
])->and($registry->forType('tenant.evidence.snapshot.generate')?->key())->toBe('evidence_snapshot')
->and($registry->forType('environment.review_pack.generate')?->key())->toBe('review_pack')
->and($registry->forType('inventory.sync')?->key())->toBe('inventory_sync')
->and($registry->forType('baseline.capture')?->key())->toBe('baseline_capture')
->and($registry->forType('backup.schedule.execute')?->key())->toBe('backup_schedule_execution')
->and($registry->forType('permission.posture.check'))->toBeNull()
->and($registry->forType('entra.admin_roles.scan'))->toBeNull();
});

View File

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\Reconciliation\BackupScheduleExecutionReconciliationAdapter;
use App\Support\Operations\Reconciliation\BaselineCaptureReconciliationAdapter;
use App\Support\Operations\Reconciliation\InventorySyncReconciliationAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prefers usable inventory coverage over blocked concurrency proof in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['conditionalAccessPolicy', 'deviceConfiguration'],
'include_foundations' => false,
'inventory' => [
'coverage' => InventoryCoverage::buildPayload([
'conditionalAccessPolicy' => 'succeeded',
'deviceConfiguration' => 'skipped',
], []),
],
'result' => [
'error_codes' => ['concurrency_limit_tenant'],
],
],
]);
$result = app(InventorySyncReconciliationAdapter::class)->reconcile($run);
expect($result?->decision)->toBe('reconciled_partially_succeeded')
->and($result?->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
->and($result?->summaryCounts)->toMatchArray([
'total' => 2,
'succeeded' => 1,
'failed' => 0,
'skipped' => 1,
]);
});
it('distinguishes blocked and failed inventory runs when no canonical coverage exists in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$blockedRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'result' => [
'error_codes' => ['lock_contended'],
],
],
]);
$failedRun = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'policy_types' => ['deviceConfiguration'],
'include_foundations' => false,
'result' => [
'error_codes' => ['inventory.pipeline_failed'],
],
],
]);
$adapter = app(InventorySyncReconciliationAdapter::class);
expect($adapter->reconcile($blockedRun)?->decision)->toBe('blocked')
->and($adapter->reconcile($failedRun)?->decision)->toBe('failed_unrecoverable');
});
it('rejects ambiguous fallback baseline snapshots in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'baseline.capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
],
]);
foreach ([1, 2] as $index) {
BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'snapshot_identity_hash' => hash('sha256', 'spec362-ambiguous-'.$index),
'summary_jsonb' => ['total_items' => 1],
'completion_meta_jsonb' => [
'producer_run_id' => (int) $run->getKey(),
'persisted_items' => 1,
'expected_items' => 1,
'was_empty_capture' => false,
],
]);
}
$result = app(BaselineCaptureReconciliationAdapter::class)->reconcile($run);
expect($result?->decision)->toBe('not_reconciled')
->and($result?->reasonMessage)->toContain('Multiple baseline snapshots');
});
it('fails closed when an explicit baseline snapshot crosses the queued profile scope in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$foreignProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$foreignSnapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $foreignProfile->getKey(),
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'baseline.capture',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $foreignSnapshot->getKey(),
],
]);
$result = app(BaselineCaptureReconciliationAdapter::class)->reconcile($run);
expect($result?->decision)->toBe('not_reconciled')
->and($result?->reasonMessage)->toContain('queued capture scope safely');
});
it('fails closed when a backup set crosses the queued tenant scope in Spec362', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$foreignTenant = ManagedEnvironment::factory()->create();
$foreignBackupSet = BackupSet::factory()->for($foreignTenant)->create();
$run = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'backup.schedule.execute',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'backup_set_id' => (int) $foreignBackupSet->getKey(),
],
]);
$result = app(BackupScheduleExecutionReconciliationAdapter::class)->reconcile($run);
expect($result?->decision)->toBe('not_reconciled')
->and($result?->reasonMessage)->toContain('queued backup schedule scope safely');
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Services\AdapterRunReconciler;
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
it('resolves selected-family adapters from canonical and legacy operation values in Spec362', function (): void {
$registry = app(OperationRunReconciliationRegistry::class);
expect($registry->supportedTypes())->toContain(
'inventory.sync',
'baseline.capture',
'backup.schedule.execute',
)->and($registry->forType('inventory.sync')?->key())->toBe('inventory_sync')
->and($registry->forType('inventory_sync')?->key())->toBe('inventory_sync')
->and($registry->forType('baseline.capture')?->key())->toBe('baseline_capture')
->and($registry->forType('baseline_capture')?->key())->toBe('baseline_capture')
->and($registry->forType('backup.schedule.execute')?->key())->toBe('backup_schedule_execution')
->and($registry->forType('backup_schedule_run')?->key())->toBe('backup_schedule_execution');
});
it('keeps weaker sync and backup families outside the selected-family registry in Spec362', function (): void {
$registry = app(OperationRunReconciliationRegistry::class);
$reconciler = app(AdapterRunReconciler::class);
expect($registry->forType('policy.sync'))->toBeNull()
->and($registry->forType('backup_set.update'))->toBeNull()
->and($reconciler->supportedTypes())->toContain(
'inventory.sync',
'baseline.capture',
'backup.schedule.execute',
)
->and($reconciler->supportedTypes())->not->toContain('policy.sync')
->and($reconciler->supportedTypes())->not->toContain('backup_set.update');
});

View File

@ -0,0 +1,369 @@
# Livewire Action Context Contract Root-Cause Audit
Date: 2026-06-07
Branch: `362-sync-capture-backup-operation-semantics`
Scope: read-only root-cause audit plus this report artifact. No runtime/source/test files were changed by this audit.
## Executive Summary
- Final classification: **Case C - Systemic Action Context Contract Gap** (`derived`, confidence: high).
- Readiness impact: workspace/environment-scoped header and page actions remain fragile unless they pass explicit action context and have first-click modal regression tests (`repo-verified`, `test-covered` for the fixed inventory/policy actions only).
- Sellability impact: this can surface as "button does nothing", "modal does not open", or "action is disabled for an authorized operator" in admin-safe workflows, which weakens trust in backup, sync, evidence, review, and restore surfaces (`derived`).
- Product risk: the highest risk is not unauthorized execution; the current failure mode usually fails closed. The risk is silent UX failure, wrong disabled state, inconsistent render-vs-submit context, and repeated implementation drift (`repo-verified`, `derived`).
- Recommended next move: create a dedicated spec for an explicit Livewire action-context contract before adding new modal-first workspace/environment actions (`derived`).
Selected case:
| Case | Result | Evidence |
| --- | --- | --- |
| Case A - Local bug only | Rejected | Same fix pattern appears in `InventoryItemResource` and `PolicyResource`, and similar no-record actions still exist (`repo-verified`). |
| Case B - Repeated implementation drift | Partially true | Several pages resolve tenant/environment context independently (`repo-verified`). |
| Case C - Systemic Action Context Contract Gap | **Selected** | `UiEnforcement` still falls back to `Filament::getTenant()` for no-record actions, while workspace-scoped Livewire requests need canonical page/referer context (`repo-verified`, `test-covered`). |
| Case D - Fundamental architectural mismatch | Not selected | The repo already has canonical workspace/environment resolvers and `WorkspaceUiEnforcement`; the issue is incomplete enforcement of that contract, not total architectural inversion (`repo-verified`, `derived`). |
## Repo State
Commands executed:
```bash
git status --short --branch
git diff --name-only
git diff --stat
git diff --cached --name-only
```
Current branch: `362-sync-capture-backup-operation-semantics` (`repo-verified`).
Staged files: none (`repo-verified`).
Tracked dirty files before report creation (`repo-verified`):
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
- `apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
- `apps/platform/app/Filament/Resources/PolicyResource.php`
- `apps/platform/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Services/AdapterRunReconciler.php`
- `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Support/Operations/LifecycleReconciliationReason.php`
- `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php`
- `apps/platform/app/Support/Operations/Reconciliation/RestoreExecuteReconciliationAdapter.php`
- `apps/platform/app/Support/Rbac/UiEnforcement.php`
- `apps/platform/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`
- `apps/platform/tests/Feature/Filament/InventoryItemResourceTest.php`
- `apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php`
- `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec359ReconciliationResultTest.php`
- `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php`
- `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactRegistryResolutionTest.php`
Untracked files before report creation include Spec 362 code/tests and `specs/362-sync-capture-backup-operation-semantics/` (`repo-verified`).
Diff summary before report creation: 18 tracked files changed, 241 insertions, 25 deletions (`repo-verified`).
Is the local fix still open? Yes. The dirty diff includes the `UiEnforcement`, inventory action, policy action, and Livewire referer resolver changes (`repo-verified`).
Is the worktree safe for a read-only audit? Yes, with caution: runtime files are dirty, so this audit did not overwrite, format, refactor, or stage them (`derived`).
## Tests Executed
Read-only targeted test command:
```bash
cd apps/platform
./vendor/bin/sail artisan test --compact \
tests/Feature/Filament/InventoryItemResourceTest.php \
tests/Feature/PolicySyncStartSurfaceTest.php \
tests/Unit/Filament/ResolvesPanelTenantContextLivewireRefererTest.php \
tests/Feature/RunStartAuthorizationTest.php
```
Result: **18 passed, 121 assertions** (`test-covered`).
Browser verification was not executed in this audit (`not verified`). The request described prior browser verification for `Run Inventory Sync`; this report treats that as prior context, not as audit-executed evidence.
## Trigger Case
What broke:
- A no-record header/page action such as `run_inventory_sync` or `sync` rendered in a workspace-scoped admin page, but the first Livewire action click/mount evaluated action visibility/disabled state without stable environment context (`repo-verified`, `derived`).
- The result was a modal that did not open, a wrong disabled/visibility state, or an action state mismatch between initial render and Livewire mounted action request (`derived`).
Why the local fix worked:
- `UiEnforcement::forAction()` now accepts `Model|Closure|null $record` and stores it as an explicit context source (`apps/platform/app/Support/Rbac/UiEnforcement.php:65`) (`repo-verified`).
- `ListInventoryItems` passes `fn (): ?ManagedEnvironment => static::resolveTenantContextForCurrentPanel()` into `UiEnforcement` (`apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php:69`, `:245`) (`repo-verified`).
- `PolicyResource::makeSyncAction()` passes the same explicit resolver (`apps/platform/app/Filament/Resources/PolicyResource.php:122`, `:182`) (`repo-verified`).
- `ResolvesPanelTenantContext` now infers the admin panel from Livewire update requests using the `referer` path when route/current panel context is absent (`apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php:56-117`) (`repo-verified`, `test-covered`).
Which context was missing:
- Product environment context, represented by `ManagedEnvironment`, was missing during the mounted action Livewire request (`derived`).
- `Filament::getTenant()` was not a stable truth source in that request shape because `/livewire/update` is not the original `/admin/...` page route (`repo-verified`, `derived`).
Which previous assumption was wrong:
- `UiEnforcement` assumed a no-record header/page action could safely fall back to `Filament::getTenant()` (`apps/platform/app/Support/Rbac/UiEnforcement.php:641-694`) (`repo-verified`).
- In TenantPilot, workspace-first admin surfaces can represent environment context through page shell, workspace session, URL/referer, or record scope rather than Filament tenancy alone (`repo-verified`, `derived`).
Tests that cover the local fix:
- Inventory first-click modal mount with no Filament tenant and Livewire referer: `apps/platform/tests/Feature/Filament/InventoryItemResourceTest.php:93-117` (`test-covered`).
- Policy sync first-click modal mount with no Filament tenant and Livewire referer: `apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php:93-116` (`test-covered`).
- Resolver unit test for Livewire referer panel/environment reconstruction: `apps/platform/tests/Unit/Filament/ResolvesPanelTenantContextLivewireRefererTest.php:15-55` (`test-covered`).
Tests that do not cover the broader class:
- No central test enumerates all workspace/environment-scoped no-record header actions and asserts first-click modal mount (`repo-verified`).
- No static guard fails new `UiEnforcement::forAction(...)` no-record OperationRun actions without explicit context (`not implemented`).
- `RunStartAuthorizationTest` covers readonly and cross-tenant start prevention for representative actions, but mostly with `Filament::setTenant(...)`, not workspace-scoped Livewire referer (`apps/platform/tests/Feature/RunStartAuthorizationTest.php:18-132`) (`test-covered`, `test-file-only` for the missing referer variant).
## Context Model
| Context Axis | Primary Source | Stable Across Livewire Mounted Action? | Used By `UiEnforcement`? | Risk |
| --- | --- | --- | --- | --- |
| Workspace | `WorkspaceContext`, route/query/page context, workspace record | Usually stable if explicitly passed | No, except via `WorkspaceUiEnforcement` | Medium (`repo-verified`) |
| Managed Environment / Tenant | record, page resolver, `OperateHubShell`, `ManagedEnvironment::current()` | Stable only if explicit resolver handles Livewire referer | Yes | High for no-record header actions (`repo-verified`, `derived`) |
| Filament Tenant Context | `Filament::getTenant()` / `Filament::setTenant()` | Not stable enough for workspace-scoped Livewire update requests | Yes, final fallback | High (`repo-verified`, `test-covered`) |
| Page Context | page methods, shell context, URL/referer | Stable when resolver is designed for Livewire | Indirect only if closure passed | Medium (`repo-verified`) |
| Livewire Request Context | `/livewire/update`, `x-livewire`, `referer` | Different from initial page route | Only through custom resolver | High (`repo-verified`) |
| Mounted Action Context | Filament action record/form state | Stable for record-backed actions, weak for no-record actions | Partly via `$action->getRecord()` | Medium (`repo-verified`) |
| OperationRun Context | explicit `tenant`, `managed_environment_id`, identity inputs, context payload | Stable if created from canonical tenant | Not directly | High if UI and execution context diverge (`repo-verified`, `derived`) |
| RBAC Capability Context | `CapabilityResolver`, `WorkspaceCapabilityResolver` | Stable if scope is explicit | Yes | High if scope is implicit/missing (`repo-verified`) |
Product architecture conclusion: TenantPilot is workspace-first; environment/tenant is a product scope inside workspace. Filament tenant context can be a transport/detail, but it must not be treated as product scope truth unless explicitly justified (`derived`).
## UiEnforcement Contract Assessment
Current design (`repo-verified`):
- `UiEnforcement::forAction(Action $action, Model|Closure|null $record = null)` accepts a direct model or closure (`apps/platform/app/Support/Rbac/UiEnforcement.php:65`).
- `resolveTenantWithRecord()` resolves in this order: passed record, owned record, action record, stored closure/model, then `Filament::getTenant()` (`apps/platform/app/Support/Rbac/UiEnforcement.php:641-694`).
- Missing user or tenant returns `TenantAccessContext(user: null, tenant: null, isMember: false, hasCapability: false)` (`apps/platform/app/Support/Rbac/UiEnforcement.php:607-620`).
- That fail-closed behavior hides or disables actions depending on membership/capability application (`repo-verified`).
Observed issue (`repo-verified`, `derived`):
- For no-record header actions, the final fallback is still `Filament::getTenant()`.
- Missing context is treated the same as non-membership for UI state, so the user can see a silent hidden/disabled action instead of an explanatory "context unavailable" state.
- `UiEnforcement` is a UX gate, but several action handlers also resolve scope again, sometimes through page resolvers and sometimes from records.
Better contract (`derived`):
- `UiEnforcement` should not infer product scope for scoped no-record actions.
- New workspace/environment actions should receive an explicit `UiActionContext` or canonical page/record resolver.
- Missing context should be distinguishable from non-membership and insufficient capability for observability and UX copy.
- OperationRun-starting handlers must reauthorize at submit/execution time using the same explicit scope.
Migration effort (`derived`):
- Minimal guardrail: low/medium, by requiring explicit context on no-record `UiEnforcement::forAction` calls that start runs or mutate state.
- DTO/contract: medium, because many callsites are record-backed and should not be churned unnecessarily.
- Static CI guard: medium, because source scanning must avoid false positives for record-backed detail/table actions.
## Action Lifecycle Assessment
| Phase | What Happens | Risk |
| --- | --- | --- |
| Initial render | Filament evaluates visibility/disabled closures with page request context. | Usually correct when `/admin/...` context exists (`derived`). |
| First click / mount action | Livewire sends update request; route/path no longer proves original admin page. | Highest risk for no-record header actions (`repo-verified`, `test-covered`). |
| Modal mount | Form defaults and modal visibility are evaluated. | Hidden fields/defaults can be wrong if environment context is missing (`repo-verified`, `derived`). |
| Modal open | Should not create OperationRun or dispatch job. | Covered for inventory/policy; not centrally covered (`test-covered`, `not implemented`). |
| Modal submit / action execution | Action handler resolves tenant and creates run/job or mutates state. | Safer when handler re-resolves/validates context; risky when render and submit use different sources (`repo-verified`, `derived`). |
| OperationRun creation | `OperationRunService` receives tenant and context. | Correct only if tenant source is canonical and reauthorized (`repo-verified`, `derived`). |
## Callsite Inventory
Static search result: 83 `UiEnforcement::forAction` matches in `apps/platform/app/Filament` (`repo-verified`). The table below groups the relevant action-context risk surfaces rather than treating record-backed CRUD helpers as the same failure class.
| File / Class | Action | Type | Scope | Explicit Context | Record? | Modal? | OperationRun / External Effect | Reauth / Submit Guard | Risk | Evidence |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `InventoryItemResource/Pages/ListInventoryItems.php` | `run_inventory_sync` | Header | Environment | Yes, page resolver | No | Yes | Creates `inventory.sync` run/job on submit | Context mismatch guard via hidden `managed_environment_id`; readonly tests | Medium after local fix | `repo-verified`, `test-covered` |
| `PolicyResource.php` / `ListPolicies.php` | `sync` | Header | Environment | Yes, page resolver | No | Confirmation modal | Creates `policy.sync` run/job on submit | Missing context aborts 404 | Medium after local fix | `repo-verified`, `test-covered` |
| `EntraGroupResource/Pages/ListEntraGroups.php` | `sync_groups` | Header | Environment | No | No | No modal | Starts directory group sync via service | Handler re-resolves tenant and returns on missing context | High latent | `repo-verified`, `test-file-only` |
| `EvidenceSnapshotResource/Pages/ListEvidenceSnapshots.php` | `create_snapshot` | Header | Environment | No | No | Form modal | Generates evidence snapshot/run | Execution resolves tenant and returns notification on missing context | High latent | `repo-verified` |
| `EvidenceSnapshotResource.php` | `create_first_snapshot` | Empty-state action | Environment | No | No | No explicit modal, action executes | Generates evidence snapshot/run | Execution resolves tenant and returns notification on missing context | Medium/high | `repo-verified` |
| `ReviewPackResource.php` | `generate_pack` / `generate_first` | Header/empty-state action | Environment | No | No | Form modal | Generates review pack/run | Execution resolves current tenant and handles missing context | High latent | `repo-verified` |
| `EnvironmentReviewResource.php` | `create_review` | Header/empty-state action | Environment | No | No | Form modal | Creates review and compose run | Execution checks access/capability after tenant resolution | High latent | `repo-verified` |
| `EnvironmentDiagnostics.php` | `bootstrapOwner`, `mergeDuplicateMemberships` | Header | Environment | No | No | Confirmation modal | Tenant membership repair mutation | Handler resolves tenant or fails | High, sensitive admin repair | `repo-verified` |
| `EnvironmentDashboard.php` | `submit_support_request`, `openSupportDiagnostics` | Page actions | Environment | No | No | Form/slide-over modal | Support request creation, diagnostics audit | Uses page resolver methods | Medium/high | `repo-verified` |
| `BaselineCompareLanding.php` | baseline compare action | Page/header action | Environment | No explicit `UiEnforcement` context | Page state | Action/queue | Creates baseline compare run | Uses current environment/page state | Medium/high | `repo-verified`, `derived` |
| `CrossEnvironmentComparePage.php` | `generatePromotionPreflight` | Header/page action | Workspace | Yes, workspace closure | No | No modal | Preflight generation | Workspace capability resolver | Low current, high future feature class | `repo-verified` |
| `BaselineCompareMatrix.php` | `compareAssignedTenants` | Header/page action | Workspace | Yes, workspace closure | Profile/page | Confirmation modal | Starts environment-owned compare path | Workspace capability resolver | Low current | `repo-verified` |
| `CustomerReviewWorkspace.php` | `create_next_review` | Page action | Environment via review record | Record closure | Yes | Confirmation modal | Review lifecycle mutation | Handler validates user/review | Medium | `repo-verified` |
| `PolicyResource/Pages/ViewPolicy.php` | `capture_snapshot` | Detail header | Environment | No explicit closure, but action record/policy tenant available | Yes | Form confirmation | Creates capture snapshot run/job | Handler uses policy tenant and missing context notification | Medium | `repo-verified` |
| `PolicyResource/RelationManagers/VersionsRelationManager.php` | `restore_to_intune` | Relation row action | Environment | No explicit closure | Yes | Form confirmation | Starts restore via service | Owner-scoped record resolution and tenant mismatch guard | Medium/high impact | `repo-verified` |
| `BackupScheduleResource.php` | `runNow`, `retry` | Table row actions | Environment | No explicit closure | Yes via table record/action | No modal | Creates backup schedule run/job | Handler resolves tenant; readonly tests | Medium | `repo-verified`, `test-covered` |
| `RestoreRunResource.php` | create restore run | Header create action/wizard | Environment | No explicit closure | No before create | Wizard/modal-like | Restore flow and potential restore run | Wizard and write gate checks later | High impact | `repo-verified` |
| `RestoreRunResource.php` | restore/archive/forceDelete rows | Table row actions | Environment | Yes in table actions for row lifecycle actions | Yes | Confirmation modal | Mutates restore records | Scoped record resolver/audit | Medium | `repo-verified` |
| `ProviderConnectionResource.php` | check/inventory/compliance | Row/detail actions | Environment | No explicit closure | Provider connection record | No modal | Provider jobs/runs | Handler resolves tenant from record and gate service | Medium | `repo-verified` |
| `ProviderConnectionResource.php` | dedicated credential actions | Row/detail actions | Environment | No explicit closure | Provider connection record | Form/confirmation modal | Secret-bearing provider mutation | Handler resolves tenant from record | Medium/high impact | `repo-verified` |
| `ViewProviderConnection.php` | `grant_admin_consent` | Detail header URL action | Environment | No explicit closure | Detail record/page tenant | URL action | External admin consent navigation | Capability UI gate only | Medium | `repo-verified` |
| `ManagedEnvironmentResource.php` | tenant sync/verify/archive/remove/restore | Record/detail/table actions | Environment record | Record/action scope | Yes | Confirmation on sensitive actions | Sync jobs and lifecycle mutation | Uses record tenant identity | Medium | `repo-verified` |
| `FindingResource.php` | triage/assign/resolve/exception actions | Row/bulk/detail actions | Environment | Mostly record/bulk selected records | Yes | Many confirmation modals | Finding lifecycle mutations | Capability checks via `UiEnforcement` | Medium | `repo-verified` |
| `AlertDestinationResource.php`, `AlertRuleResource.php` | toggle/delete/create | Table/create actions | Workspace/environment depending model | No `UiEnforcement` hits in searched files | Records | Confirmation for delete/toggle | Alert mutation, no OperationRun | Resource/policy-dependent | Medium/unknown | `repo-verified`, `not verified` |
| `StoredReportResource/Pages/ViewStoredReport.php` | `open_current_report` | Detail header URL action | Record/report | No `UiEnforcement` | Record | No | URL only | Scoped record resolver | Low | `repo-verified` |
## Similar Patterns Found
| Pattern | Files | Evidence | Severity |
| --- | --- | --- | --- |
| No-record environment header action depends on `UiEnforcement` fallback | `ListEntraGroups`, `ListEvidenceSnapshots`, `ReviewPackResource::generatePackAction`, `EnvironmentReviewResource::makeCreateReviewAction`, `EnvironmentDiagnostics` | Actions call `UiEnforcement::forAction(...)` without explicit context closure and later resolve tenant in execution | High (`repo-verified`, `derived`) |
| Local resolver fixes repeated per surface | Inventory and policy sync now pass identical page resolver closures | Same shape in two separate implementations | High (`repo-verified`) |
| Modal open versus submit not centrally guarded | Inventory/policy have direct tests; evidence/review/pack/diagnostics do not show equivalent first-click matrix | Existing coverage is per-resource | Medium/high (`repo-verified`, `not implemented`) |
| Fail-closed missing context is silent | `TenantAccessContext` collapses missing tenant into non-member false state | No distinct missing-context UI state in `UiEnforcement` | Medium (`repo-verified`, `derived`) |
| Workspace enforcement has stricter explicit context pattern | `WorkspaceUiEnforcement` only resolves direct/closure workspace, no global fallback | Better model for new context contract | Medium positive evidence (`repo-verified`) |
## Blast Radius
| Area | Relevant Actions | Potentially Affected | Evidence | Missing Tests | Risk |
| --- | --- | --- | --- | --- | --- |
| Inventory | `run_inventory_sync` | Fixed locally, recurrence risk remains | Explicit resolver now passed | Central all-actions guard absent | Medium |
| Policies | `sync`, `capture_snapshot`, row/bulk sync/export | Header fixed locally; record actions lower risk | Header explicit resolver; detail uses record | Central first-click guard absent | Medium |
| Provider Connections | check, inventory sync, compliance, credential actions | Latent lower risk due records; high impact | Record-backed actions resolve provider tenant | Workspace-referer first-click modal not broad | Medium |
| Baseline Profiles | compare assigned tenants | Currently better guarded | `WorkspaceUiEnforcement` explicit workspace closure | Future environment action guard absent | Low/medium |
| Baseline Compare | landing compare, cross-environment preflight | Future/present risk in page-state actions | Mixed explicit workspace and page-state environment context | No central mounted action matrix | Medium/high |
| Drift Findings | finding lifecycle actions | Mostly record/bulk action class, not trigger shape | `FindingResource` uses row/bulk actions | Exhaustive first-click modal coverage not verified | Medium |
| Restore Runs | create/execute/rerun/archive/delete | High impact; not same current trigger everywhere | Create action no explicit context; row actions have scoped resolvers | Modal-first context matrix not central | High |
| Tenant Reviews | create, refresh, publish, archive, create-next | Latent risk for no-record create; record detail lower | Resource/page execution resolvers | First-click no-run tests not central | High |
| Evidence Snapshots | create, create-first, refresh, expire | Latent risk for create actions | Header/empty actions no explicit resolver | First-click no-run tests missing | High |
| Review Packs | generate, regenerate, expire | Latent risk for generate actions | `generatePackAction` no explicit resolver | First-click/modal-no-run tests missing | High |
| Stored Reports | open current report | Not affected by current class | URL action, scoped record | N/A | Low |
| Support Diagnostics | open diagnostics, support request | Latent UX/audit risk | Page actions use `UiEnforcement` without explicit context | First-click modal/audit timing not central | Medium/high |
| Support Requests | submit support request | Latent modal context risk | EnvironmentDashboard form action | Missing context consistency tests not verified | Medium/high |
| Alerts / Notification Routing | alert rule/destination table actions | Not clearly same `UiEnforcement` issue | No `UiEnforcement` hits in searched files | Authorization model not fully audited here | Medium/unknown |
| Permission Posture | evidence/review/dashboard surfaces consume posture | Future risk if actions added | No direct current action found in this audit slice | Future guard required | Medium |
| Entra Admin Roles / Groups | `sync_groups`, admin roles widgets | `sync_groups` is latent current risk | Header action no explicit context; readonly test uses Filament tenant | Workspace-referer first-click test missing | High |
| Customer Health | dashboards/system directory | Future risk | OperationRun-heavy metrics, not same action trigger in inspected files | Future guard required | Medium |
| Operational Controls | restore execute write gate | Future/current high-impact execution context | Restore flow has write gate; action context still mixed | Central guard absent | High |
| Cross-Tenant Compare | promotion/preflight | Future risk but current explicit workspace example is good | `WorkspaceUiEnforcement` closures | OperationRun action context spec needed before promotion | Medium/high |
| Workspace Entitlements | review packs, support, billing-gated operations | Risk when disabled state differs render/submit | Review pack blocks use tenant/workspace entitlement decisions | Entitlement consistency matrix missing | High |
| Commercial/Billing State | entitlement-gated modals | Future risk | Review pack generation blocked by entitlement exception | Guard before new billing-gated actions | High |
## Existing Latent Risks
| Risk | Evidence | Affected Surfaces | Severity | Recommended Fix |
| --- | --- | --- | --- | --- |
| `sync_groups` repeats the no-record header action shape without explicit context | `ListEntraGroups.php:51-80` | Entra groups | Severity 2: High | Add to follow-up spec as representative third action for the new contract. |
| Evidence/review/pack generation actions can lose context on modal mount | `ListEvidenceSnapshots.php:22-37`, `ReviewPackResource.php:382-400`, `EnvironmentReviewResource.php:405-423` | Evidence snapshots, review packs, tenant reviews | Severity 2: High | Require explicit environment context on no-record generation actions. |
| Sensitive diagnostics repair actions rely on fallback context | `EnvironmentDiagnostics.php:64-86` | Environment diagnostics | Severity 2: High | Require explicit tenant resolver and missing-context copy. |
| Restore create action is high impact and context-sensitive | `RestoreRunResource.php:282-289` | Restore runs | Severity 2: High | Include restore create/wizard in guardrail tests before new restore UX work. |
| Missing context is not semantically distinct from non-membership | `UiEnforcement.php:614-620` | All `UiEnforcement` actions | Severity 3: Medium | Model missing context as its own state in contract/DTO. |
TenantPilot audit classification:
| Finding | Classification | Severity | Delivery |
| --- | --- | --- | --- |
| No-record scoped actions depend on implicit Filament tenant fallback | Architectural Drift | Severity 2: High | Dedicated spec required |
| Missing context silently collapses into non-member UI state | Workflow Trust Gap | Severity 3: Medium | Follow-up refactor under same spec |
| No central first-click modal/no-run regression guard | Test Blind Spot | Severity 2: High | Dedicated spec required |
| OperationRun-starting action context is not statically enforceable | Workflow Trust Gap | Severity 2: High | Dedicated spec required |
## Test Coverage
| Test | Scenario | First Click | Modal No-Run | Reauth / Execution Guard | Gap |
| --- | --- | --- | --- | --- | --- |
| `InventoryItemResourceTest.php:93-117` | Inventory sync mounts from workspace-scoped Livewire referer | Yes | Yes, `Queue::assertNothingPushed()` | No submit in that test | Does not cover every similar action |
| `PolicySyncStartSurfaceTest.php:93-116` | Policy sync mounts from workspace-scoped Livewire referer | Yes | Yes, `Queue::assertNothingPushed()` | No submit in that test | Does not cover every similar action |
| `ResolvesPanelTenantContextLivewireRefererTest.php:15-55` | Resolver infers tenant from Livewire referer | N/A | N/A | N/A | Resolver only, not action matrix |
| `RunStartAuthorizationTest.php:18-132` | Cross-tenant/readonly no run for inventory, Entra, backup schedule | No referer first-click | No modal matrix | Yes for representative run starts | Uses `Filament::setTenant()` |
| Provider operation tests found by search | Provider operation gates/runs | Not verified in this audit | Not verified | Partly covered by service/gate tests | First-click mounted action matrix absent |
| Evidence/review/pack tests | Operation artifact reconciliation exists | Not verified for first-click | Not verified | Some service/output tests exist | Needs action lifecycle tests |
Coverage conclusion: the local fix is test-covered. The class is not centrally guarded (`derived`).
## Future Feature Risk
| Planned Feature | Why It Is Exposed | Required Guardrail Before Build |
| --- | --- | --- |
| Customer Review Workspace v1 | Adds customer-safe workspace actions, review acknowledgement, package/evidence flows | Explicit workspace/environment action context and modal-no-run tests |
| Decision-Based Governance Inbox v1 | Likely adds modal decisions, approvals, exception lifecycle actions | No implicit Filament tenant; record/page context must be explicit |
| Localization v1 | Translated labels/tooltips can hide context/missing-state copy gaps | Missing-context state must have copy keys and tests |
| Cross-Tenant Compare and Promotion v1 | Multi-environment actions and promotion preflight can mix source/target contexts | Explicit DTO with source workspace, source environment, target environment |
| Commercial Entitlements and Billing-State Maturity | Disabled state can differ between render and submit if entitlement context is implicit | Entitlement decision must use same explicit action context |
| External Support Desk / PSA Handoff | Support handoff actions are modal-first and tenant-sensitive | First-click modal, audit-on-open, submit reauth tests |
| Private AI Execution Governance Foundation | AI execution approvals are high-trust, likely modal/OperationRun actions | Explicit operation scope, approval context, no-run-on-modal-open tests |
Most critical future features: Decision-Based Governance Inbox, Cross-Tenant Compare and Promotion, Commercial entitlement-gated actions, External Support Desk handoff, and AI execution approval actions (`derived`).
## New Feature Action Contract
| Requirement | Required? | Current Support | Gap |
| --- | --- | --- | --- |
| Declare product scope: workspace/environment/tenant/system | Yes | Informal in many pages/specs | Not enforced for actions |
| Declare context source: record/page resolver/DTO | Yes | Some explicit closures, many implicit fallbacks | Not mandatory |
| No implicit `Filament::getTenant()` for no-record scoped actions | Yes | Not currently enforced | Main gap |
| Modal open must not execute or create OperationRun | Yes | Covered for inventory/policy only | No central guard |
| Submit must reauthorize | Yes | Many handlers re-resolve/guard; not uniform | Need standard test/helper |
| Execution receives explicit workspace/environment IDs | Yes | OperationRun often receives tenant; source varies | Need context contract |
| Missing context fails closed with understandable UX | Yes | Fails closed, but often silent | Need distinct state/copy |
| Tests: render, first-click, modal-no-run, submit, wrong context, readonly | Yes | Point coverage only | Need reusable test/helper or matrix |
## Recommended Guardrail
Minimal (`recommended immediate spec scope`):
- Require explicit context closure/DTO for every no-record `UiEnforcement::forAction` that is workspace/environment-scoped.
- Cover representative actions: inventory sync, policy sync, Entra group sync, evidence snapshot create, review pack generate, environment review create, restore create.
- Add reusable Pest helper/assertion for first-click modal mount and modal-no-run.
- Add a static audit command/test that lists no-record `UiEnforcement::forAction` actions with `OperationRun`, queue dispatch, provider access, or modal forms and no explicit context.
Medium:
- Introduce `UiActionContext` with scope type, workspace, environment, source, and missing-context reason.
- Make `UiEnforcement` distinguish missing context from non-member and insufficient capability.
- Add `ResolvesActionContext` trait for page/header actions that wraps `ResolvesPanelTenantContext`, record scope, workspace scope, and Livewire referer.
Ideal:
- Make scoped action construction impossible without an explicit action context.
- Add CI guard for new OperationRun/provider/write actions without execution reauthorization and first-click lifecycle tests.
- Move action context and OperationRun start authorization into a shared contract that UI actions, jobs, and service gates can validate consistently.
Option evaluation:
| Option | Problem Solved | Problem Not Solved | Effort | Regression Risk | Recommendation |
| --- | --- | --- | --- | --- | --- |
| Keep local resolver | Fixes current action only | Recurrence on next action | Low | Low | Insufficient |
| Centralize resolver | Reduces duplication | Does not force use | Medium | Low/medium | Good |
| Introduce `UiActionContext` DTO | Models scope explicitly | Requires callsite migration | Medium | Medium | Best medium path |
| Extend `UiEnforcement` contract | Enforces guardrail near UX gate | Needs careful compatibility for record-backed actions | Medium | Medium | Recommended |
| Add trait for workspace/environment page actions | Improves ergonomics | Can become another optional helper | Low/medium | Low | Recommended with DTO |
| Add test helper only | Catches regressions | Does not improve design | Low | Low | Required but not enough |
| Static audit script/CI guard | Prevents new obvious violations | Needs false-positive tuning | Medium | Low | Recommended |
| Do nothing | Avoids churn | Recurrence likely | None | High product risk | Reject |
## Anti-Regression Rule
Proposed hard rule (`derived`):
> No new workspace-/environment-scoped `HeaderAction` or no-record page action may call `UiEnforcement` or create an `OperationRun` unless it receives an explicit action context resolved from the canonical page/record scope and has tests for render, first-click modal mount, modal-no-run, submit reauthorization, and missing-context fail-closed behavior.
Is this realistic now? Yes, if scoped to no-record header/page actions first. Applying it retroactively to every record-backed row/detail action would create too much churn and false positives (`derived`).
## Filament v5 Blueprint Notes
- Livewire compliance: app is on Livewire 4.1.4 and Filament 5.2.1 (`repo-verified` via Laravel Boost).
- Provider registration: this audit did not add or change Filament panels; Laravel 11+/12 provider registration location remains `apps/platform/bootstrap/providers.php` (`not changed`).
- Global search: this audit did not add resources or global search behavior. For any future globally searchable resource, the v5 rule remains: it needs Edit/View page or global search disabled (`not changed`).
- Destructive actions: this audit did not change actions. Existing sensitive examples inspected use `->requiresConfirmation()` on destructive/confirmation actions such as policy restore, review archive, restore archive/delete, tenant lifecycle, and provider credential changes (`repo-verified`).
- Assets: no assets added; no `filament:assets` deployment impact (`not implemented`).
- Testing plan: future spec should add Livewire tests for pages/actions and Filament action tests using `mountAction()` for modal open and `callMountedAction()`/`callAction()` for execution (`derived`, version-specific Filament guidance checked through Boost earlier in this workstream).
## Final Answer
1. Where else does this currently occur?
- Confirmed latent current risk exists in no-record environment actions that still call `UiEnforcement::forAction` without explicit context: `sync_groups`, evidence snapshot create, review pack generate, environment review create, environment diagnostics repair, restore create, and support dashboard actions (`repo-verified`, `derived`).
2. Where could it occur next?
- Any new workspace/environment-scoped header/page action that opens a modal, starts an `OperationRun`, dispatches a provider job, gates on readonly/entitlements, or uses page state instead of a record (`derived`).
3. Is this a pattern or isolated bug?
- It is a **systemic action context contract gap**, with repeated implementation drift as a symptom (`derived`, confidence high).
4. What should be mandatory for new features?
- Explicit action context, no implicit `Filament::getTenant()` for no-record scoped actions, first-click modal tests, modal-no-run tests, submit reauthorization, missing-context fail-closed tests, and explicit OperationRun scope (`derived`).
5. What is the smallest safe spec to prevent recurrence?
- Spec title: **Livewire Action Context Contract for Workspace/Environment-Scoped Actions**.
- Scope: add action context contract/trait/DTO, retrofit representative no-record actions, add reusable test helper/static guard, and document the anti-regression rule. No migrations or assets are required unless the chosen implementation adds persisted audit/telemetry for missing context (`derived`).

View File

@ -0,0 +1,65 @@
# Requirements Checklist: Spec 362 - Sync, Capture, and Backup Operation Semantics
**Purpose**: Preparation analysis for Spec 362 readiness
**Created**: 2026-06-07
**Feature**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/362-sync-capture-backup-operation-semantics/spec.md`
## Candidate Selection And Guardrails
- [x] CHK001 The candidate source is explicit: the direct user-provided Spec 362 draft from `/Users/ahmeddarrazi/.codex/attachments/c6e1919f-3e04-4f47-b6a8-58c0fdc2ab82/pasted-text.txt`.
- [x] CHK002 No `specs/362-*` package existed before Spec Kit branch creation.
- [x] CHK003 The active candidate queue's empty-state note is respected; this package is an intentional manual promotion, not an auto-selected queue item.
- [x] CHK004 Related specs were checked and treated correctly: Specs 358, 359, and 361 are implementation context, and Spec 360 remains canonical-cutover context only.
- [x] CHK005 Repo-truth deviations from the user draft are recorded in `spec.md`, especially the narrowed safe scope around `inventory.sync`, `baseline.capture`, and `backup.schedule.execute` plus the explicit defer/fail-closed treatment for `policy.sync` and `backup_set.update`.
## Required Prep Artifacts
- [x] CHK006 `spec.md` exists and contains no template placeholders.
- [x] CHK007 `plan.md` exists and is repo-aware.
- [x] CHK008 `tasks.md` exists and is ordered, small, and verifiable.
- [x] CHK009 This checklist exists.
## Spec Quality
- [x] CHK010 Spec Candidate Check is completed and scores above the approval threshold.
- [x] CHK011 The spec keeps `OperationRun`, `InventoryItem`, `BaselineSnapshot`, `BackupSet`, and `BackupItem` persistence unchanged.
- [x] CHK012 The spec explains why bounded adapter extension is justified now under ABSTR-001: the repo already has real registry-backed reconciliation consumers and current proof seams for the selected families.
- [x] CHK013 The spec keeps scope bounded to inventory-sync reconciliation, baseline-capture reconciliation, backup-schedule reconciliation, and honest unsupported-family handling.
- [x] CHK014 The proportionality review rejects a generic sync/backup engine, new operation types, new persistence, and weak "latest artifact exists" heuristics.
## Plan / Task Alignment
- [x] CHK015 The plan identifies the actual repo surfaces likely to change, including the current registry, write seam, proof models, proof presenters, and operations/detail pages.
- [x] CHK016 The plan keeps Filament v5 / Livewire v4 posture and provider-registration location visible.
- [x] CHK017 The plan explicitly states that no migration, no new panel/provider, no new global search change, and no new asset strategy are expected.
- [x] CHK018 The tasks start with repo truth and failing tests before runtime edits.
- [x] CHK019 The tasks include explicit anti-creep guardrails against new persistence, new queue families, generic sync/backup heuristics, and unsupported-family widening.
- [x] CHK020 The tasks keep the current `OperationRun` lifecycle owner, current presenter/link surfaces, and current workspace-first RBAC model authoritative.
## UI / Monitoring / Proof Coverage
- [x] CHK021 UI Surface Impact is completed and does not claim a new page family.
- [x] CHK022 The changed surfaces are correctly classified as existing operations monitoring/detail plus existing inventory, baseline, and backup detail follow-through, not a new strategic customer-facing page.
- [x] CHK023 No new page-report identity or route-inventory expansion is required unless implementation proves a materially new visible hierarchy.
- [x] CHK024 Audience-aware disclosure and no-overclaim wording boundaries are explicit for operations, run detail, and current proof/detail surfaces.
## Test Governance
- [x] CHK025 The declared test families are the narrowest honest proof: Unit + Feature + one bounded Browser smoke.
- [x] CHK026 No heavy-governance family or PGSQL-only schema lane is introduced by default.
- [x] CHK027 Planned validation commands are explicit, partitioned into the primary Spec 362 gate and bounded contextual regressions, and remain scoped to selected-family fallout only.
## Readiness Gate Outcome
- [x] CHK028 Candidate Selection Gate passes.
- [x] CHK029 Spec Readiness Gate passes.
- [x] CHK030 Runtime implementation has not started in this preparation step.
- [x] CHK031 Recommended next step is implementation, not more prep.
## Review Outcome
- [x] CHK032 Outcome class: acceptable-special-case
- [x] CHK033 Workflow outcome: keep
- [x] CHK034 Final note location: active feature PR close-out entry `Guardrail / Smoke Coverage`
- [x] CHK035 Preparation analyze result: pass via repo-based cross-artifact review; no standalone local `speckit.analyze` command was exposed in this repo surface beyond prompts and agent instructions
- [x] CHK036 Tooling note: Spec Kit branch/spec creation succeeded via `create-new-feature.sh`, `setup-plan.sh` generated the plan file, and `tasks.md` plus this checklist were authored manually to match the repo's Spec Kit templates and agent instructions

View File

@ -0,0 +1,259 @@
# Implementation Plan: Spec 362 - Sync, Capture, and Backup Operation Semantics
**Branch**: `362-sync-capture-backup-operation-semantics` | **Date**: 2026-06-07 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/362-sync-capture-backup-operation-semantics/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/362-sync-capture-backup-operation-semantics/spec.md`
**Note**: This plan is repo-aware and preparation-only. No application implementation is performed in this step.
## Summary
Extend the current `OperationRun` reconciliation path so selected current-proof families can finalize truthfully after stale, interrupted, or late-finishing execution:
- `inventory.sync`
- `baseline.capture`
- `backup.schedule.execute`
Reuse only current repo-real proof seams and the existing `OperationRunOutcome` enum. Add no new persistence, no new queue family, and no new UI framework. Keep `policy.sync`, `backup_set.update`, restore, and generic report families fail-closed and explicitly deferred.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1
**Storage**: PostgreSQL 16 (`operation_runs`, `inventory_items`, `baseline_snapshots`, `policy_versions`, `backup_sets`, `backup_items`)
**Testing**: Pest Unit + Feature + one bounded Browser smoke
**Validation Lanes**: fast-feedback, confidence, browser
**Target Platform**: Laravel monolith in Sail / Dokploy container workflow
**Project Type**: single web application (`apps/platform`)
**Performance Goals**: no new polling or background family; reconciliation remains bounded to current stale/queued scan paths and current operator-facing read surfaces
**Constraints**: no new schema, no new operation type, no new panel/provider, no new Filament asset strategy, no broad sync/backup taxonomy rewrite, no Graph calls during render
**Scale/Scope**: narrow `OperationRun` truth hardening over exactly three current-proof families plus explicit unsupported-family handling
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- current inventory, baseline, and backup detail surfaces only if outcome wording or safe related links need alignment
- **No-impact class, if applicable**: N/A
- **Native vs custom classification summary**: native Filament surfaces with shared `OperationRun` and current proof/detail helpers
- **Shared-family relevance**: `OperationRun` monitoring family plus current inventory, baseline, and backup detail families
- **State layers in scope**: page, detail, URL-query
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the Operations hub, diagnostics-second on run detail, deeper proof on current detail pages only
- **Raw/support gating plan**: raw context stays in current diagnostic sections only
- **One-primary-action / duplicate-truth control**: keep current inspect/open paths; do not add a second competing outcome summary on related proof pages
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory for any attempt to widen scope into `policy.sync`, generic backup taxonomy work, or new persistence
- **Special surface test profiles**: monitoring-state-page
- **Required tests or manual smoke**: functional-core plus one bounded browser smoke
- **Exception path and spread control**: none
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **UI/Productization coverage decision**: existing route and page families remain sufficient; no new coverage artifact is expected by default
- **Coverage artifacts to update**: none by default
- **No-impact rationale**: existing reachable operations and current proof/detail surfaces only; no new route family or navigation entry is planned
- **Navigation / Filament provider-panel handling**: no panel, provider, or navigation change
- **Screenshot or page-report need**: no by default; use bounded browser smoke unless implementation proves a material hierarchy shift
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**:
- `App\Services\OperationRunService`
- `App\Services\Operations\OperationLifecycleReconciler`
- `App\Services\AdapterRunReconciler`
- `App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry`
- `App\Support\Operations\Reconciliation\ReconciliationResult`
- `App\Support\OperationRunLinks`
- `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`
- current inventory, baseline, and backup proof/detail helpers
- **Shared abstractions reused**: current adapter contract, service-owned reconciliation writes, current monitoring/detail presenters, current scope-safe links, and current proof models
- **New abstraction introduced? why?**: at most one small shared proof helper if duplicated completeness rules across the three selected families become noisy; no generic sync/backup framework is allowed
- **Why the existing abstraction was sufficient or insufficient**: the registry and service-owned write seam already exist, but they currently stop short of selected sync/capture/backup proof semantics and cannot yet represent partial success inside the reconciliation-result helper path
- **Bounded deviation / spread control**: keep family-specific proof logic local to `App\Support\Operations\Reconciliation\`; do not add provider-neutral engines, new persistence, or a second lifecycle owner
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes
- **Central contract reused**: current `OperationRunService`, `OperationLifecycleReconciler`, registry-backed reconciliation, `OperationRunLinks`, and existing monitoring/detail presenters
- **Delegated UX behaviors**: existing queued toast, run link, run-enqueued browser event, and terminal notification paths remain unchanged
- **Surface-owned behavior kept local**: wording and placement only
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: unchanged central lifecycle mechanism
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no new provider seam
- **Provider-owned seams**: N/A
- **Platform-core seams**: `OperationRun`, current proof/result truth, current scope-safe links
- **Neutral platform terms / contracts preserved**: `operation`, `coverage`, `snapshot`, `backup set`, `reconciliation`, `partially succeeded`, `blocked`
- **Retained provider-specific semantics and why**: only already-recorded provider error codes or readiness reasons remain secondary evidence; no new provider-owned truth is introduced
- **Bounded extraction or follow-up path**: `policy.sync`, broader counted-progress, and broader backup/restore taxonomy remain separate follow-ups
## Constitution Check
*GATE: Must pass before implementation starts. Re-check if scope changes.*
- Inventory-first: PASS. The selected proof paths read current inventory, baseline snapshot, and backup artifact truth instead of creating a new truth layer.
- Read/write separation: PASS. Adapters read proof only and finalize runs only through `OperationRunService`.
- Graph contract path: PASS. No new Graph surface or render-time Graph call is planned.
- Deterministic capabilities: PASS. No new capability derivation is introduced.
- Workspace and tenant isolation: PASS. Adapters must use run-owned workspace/environment scope and current related resource policies.
- Run observability: PASS. `OperationRun` remains the only lifecycle owner; no shadow table or duplicate outcome store is introduced.
- TEST-GOV-001: PASS. Unit + Feature + bounded Browser are the narrowest honest proof.
- PROP-001 / ABSTR-001: PASS only if any new helper stays local to the three selected families and does not become a generic sync/backup engine.
- PERSIST-001 / STATE-001: PASS. No new persisted truth or new outcome family is planned.
- XCUT-001 / LAYER-001: PASS. Extend the current registry and presenters; do not create parallel operator language.
- UI-SEM-001 / UI-FIL-001 / UI-COV-001: PASS. Existing native surfaces only; no new page family or asset strategy.
- BADGE-001: PASS. Existing outcome and badge semantics remain authoritative; only truthful mapping changes are allowed.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for family resolution, partial-success helper behavior, proof rules, and idempotency; Feature for run finalization, wrong-scope rejection, DB-only render-path protection, and operator-facing fallthrough; Browser for the changed Operations hub/detail wording only if visible copy changes
- **Affected validation lanes**: fast-feedback, confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: the feature is primarily service and model coordination with a small amount of operator-visible wording fallout; no PGSQL-only schema behavior is introduced
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyRegistryResolutionTest.php tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyProofRulesTest.php tests/Unit/Support/Operations/Reconciliation/Spec359ReconciliationResultTest.php tests/Feature/Operations/Spec362*`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/MonitoringOperationsTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec362SyncCaptureBackupSemanticsSmokeTest.php`
- **Fixture / helper / factory / seed / context cost risks**: existing workspace/environment, `OperationRun`, `InventoryItem`, `BaselineSnapshot`, `BackupSet`, and `BackupItem` factories only; no new global defaults should be introduced
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: one explicit Browser smoke only
- **Surface-class relief / special coverage rule**: monitoring-state-page
- **Closing validation and reviewer handoff**: reviewers should verify that partial success only lands when proof is truly incomplete but usable, that blocked remains distinct from failed, that weaker families stay unsupported, and that no new persistence or operation type appears
- **Budget / baseline / trend follow-up**: none expected
- **Review-stop questions**: does the feature overclaim success, broaden into `policy.sync`, or create a second lifecycle truth?
- **Escalation path**: document-in-feature if a weaker family still needs a named follow-up; reject-or-split if implementation tries to widen into generic sync/backup semantics
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Why no dedicated follow-up spec is needed now**: the three selected families already expose stronger current proof than the weaker families, so they can be implemented safely as one bounded slice
## Repo-Verified Runtime Surfaces Likely Affected
- `apps/platform/app/Services/OperationRunService.php`
- `apps/platform/app/Services/Operations/OperationLifecycleReconciler.php`
- `apps/platform/app/Services/AdapterRunReconciler.php`
- `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php`
- `apps/platform/app/Support/Operations/Reconciliation/ReconciliationResult.php`
- `apps/platform/app/Support/OperationCatalog.php`
- `apps/platform/app/Support/OperationRunType.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
- `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Models/InventoryItem.php`
- `apps/platform/app/Models/BaselineSnapshot.php`
- `apps/platform/app/Models/BackupSet.php`
- `apps/platform/app/Models/BackupItem.php`
- `apps/platform/app/Services/Inventory/InventorySyncService.php`
- `apps/platform/app/Support/Inventory/InventoryCoverage.php`
- `apps/platform/app/Services/Baselines/BaselineCaptureService.php`
- `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`
- `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`
- `apps/platform/app/Jobs/RunBackupScheduleJob.php`
- `apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php`
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- current inventory, baseline, and backup detail surfaces only as required by runtime fallout
- `apps/platform/tests/Unit/Support/Operations/Reconciliation/*`
- `apps/platform/tests/Feature/Operations/*`
- `apps/platform/tests/Feature/Inventory/*`
- `apps/platform/tests/Feature/Baselines/*`
- `apps/platform/tests/Feature/BackupScheduling/*`
- `apps/platform/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`
- `apps/platform/tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php` plus new Spec 362 smoke coverage as needed
## Technical Approach
1. Re-verify the exact current proof seams and operation types before runtime edits.
2. Reuse the current registry and service-owned lifecycle write seam.
3. Add bounded family-specific adapters for:
- inventory sync
- baseline capture
- backup schedule execution
4. Add a bounded reconciliation-result helper for partial success only if the current object cannot express the existing enum cleanly.
5. Keep weaker families explicit and unsupported rather than broadening proof heuristics.
6. Reuse current operations and current proof/detail presentation seams for operator-visible fallout.
## Risk Controls
- Fail closed on ambiguous, missing, wrong-scope, or stale proof.
- No adapter may mutate inventory, baseline, or backup artifacts.
- No new operation type, queue family, or lifecycle table may be introduced.
- Touched Operations hub/detail render paths must remain DB-only and keep the existing no-Graph render guard green; if any related proof host surface changes render behavior, extend the smallest existing host render guard before merge.
- If `policy.sync` or another weaker family still appears to need proof semantics, record a named follow-up instead of widening this package.
- No new panel/provider, asset registration, or destructive action may be added in this slice.
## Implementation Phases
### Phase 1: Baseline and Repo-Truth Inventory
Confirm current operation types, run-start context, current proof models, and current operator-surface fallout. Explicitly verify that `policy.sync` remains weaker than `inventory.sync`.
### Phase 2: Foundational Reconciliation Contract
Add the bounded partial-success helper if needed, selected-family registry resolution, and proof-rule tests before family-specific runtime edits.
### Phase 3: Inventory Sync Truth
Add proof-based inventory sync reconciliation and the focused operations/inventory tests.
### Phase 4: Baseline Capture Truth
Add proof-based baseline capture reconciliation and the focused operations/baseline tests.
### Phase 5: Backup Schedule Execution Truth
Add proof-based backup schedule reconciliation and the focused operations/backup tests.
### Phase 6: Unsupported-Family Boundaries
Keep `policy.sync`, `backup_set.update`, restore, and weaker families explicit and unsupported in this slice, and record the defer path instead of widening.
### Phase 7: Validation and Close-Out
Run scoped Pest and Browser validation, confirm no migration/assets/panel drift, and record the supported versus explicitly deferred families.
## Project Structure
### Documentation (this feature)
```text
specs/362-sync-capture-backup-operation-semantics/
├── spec.md
├── plan.md
├── tasks.md
└── checklists/requirements.md
```
### Source Code (repository root)
```text
apps/platform/app/Services/
apps/platform/app/Support/Operations/Reconciliation/
apps/platform/app/Support/OpsUx/
apps/platform/app/Support/Ui/GovernanceArtifactTruth/
apps/platform/app/Services/Inventory/
apps/platform/app/Services/Baselines/
apps/platform/app/Services/BackupScheduling/
apps/platform/app/Jobs/
apps/platform/app/Console/Commands/
apps/platform/app/Filament/Pages/
apps/platform/tests/Unit/
apps/platform/tests/Feature/
apps/platform/tests/Browser/
```
## Assumptions
- The current registry and correlation seams visible in `platform-dev` are authoritative enough for this bounded follow-through.
- `inventory.sync`, `baseline.capture`, and `backup.schedule.execute` retain their current proof models and current operator meaning.
- `policy.sync` remains intentionally out of scope because its current proof is weaker than the selected families.
## Open Preparation Decision
Spec 360 artifacts still exist as open context, but current runtime inspection already shows the registry, correlation resolver, and reconciliation write seam that Spec 362 needs.
That is sufficient for preparation readiness.
If implementation later proves a missing Spec 360 canonical-cutover delta still blocks safe adapter work, that prerequisite must be handled explicitly and minimally rather than being smuggled into Spec 362 as unrelated drift.

View File

@ -0,0 +1,406 @@
# Feature Specification: Spec 362 - Sync, Capture, and Backup Operation Semantics
**Feature Branch**: `362-sync-capture-backup-operation-semantics`
**Created**: 2026-06-07
**Status**: Draft
**Type**: OperationRun semantic hardening / operator-truth follow-through / no new persistence
**Runtime posture**: Extend the current `OperationRun` reconciliation path only for selected repo-real sync, capture, and backup run families. Reuse existing artifact or result truth. Do not introduce a new lifecycle table, queue family, or UI framework.
**Input**: User-provided draft in `/Users/ahmeddarrazi/.codex/attachments/c6e1919f-3e04-4f47-b6a8-58c0fdc2ab82/pasted-text.txt` plus repo inspection of Specs 358-361 and the current `apps/platform` runtime seams.
## Dependencies And Historical Context
This package continues the current OperationRun truth line:
- **Spec 358 - OperationRun Queue Truth Foundation** established honest queued/running stale handling and explicitly deferred business-success reconciliation for sync, capture, backup, restore, and export families.
- **Spec 359 - OperationRun Reconciliation Adapter Framework & Review Compose Adapter** introduced one bounded adapter seam and review-compose-specific reconciliation while explicitly excluding report, evidence, sync, and backup adapters.
- **Spec 360 - OperationRun Canonical Cutover Cleanup** remains the canonical cutover context for dispatch/correlation and read-side cleanup. Its artifacts are context only here and must not be rewritten.
- **Spec 361 - Report and Evidence Reconciliation Adapters** added registry-backed reconciliation for `tenant.evidence.snapshot.generate` and `environment.review_pack.generate` while explicitly keeping sync, backup, and restore families out of scope.
Current repo truth already supports this next slice:
- `App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry` exists and already hosts multiple real adapters.
- `App\Support\OperationRunOutcome` already contains `partially_succeeded` and `blocked`.
- `App\Services\Inventory\InventorySyncService` already records canonical coverage and result context for `inventory.sync`.
- `App\Jobs\CaptureBaselineSnapshotJob` and related baseline services already record `baseline_snapshot_id`, gap summaries, and summary counts for `baseline.capture`.
- `App\Jobs\RunBackupScheduleJob` already records `backup_schedule_id`, `backup_set_id`, summary counts, and terminal backup outcomes for `backup.schedule.execute`.
This spec therefore continues an active repo-real line instead of inventing a new planning direction.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Selected sync, capture, and backup run families still depend too heavily on technical completion or generic stale-run failure. When a job dies late, partially finishes, or never writes its final lifecycle update, operators can be left with a stale run that hides whether the system already captured usable truth, only partial truth, or a real blocked state.
- **Today's failure**:
- `inventory.sync` already persists coverage and error summaries, but stale or interrupted runs can still end as generic failed/stale truth instead of a truthful succeeded, partially succeeded, or blocked result backed by current coverage.
- `baseline.capture` already records snapshot IDs, eligibility context, gap summaries, and summary counts, but the current reconciliation path does not promote those signals into a calm terminal outcome when the run itself becomes ambiguous.
- `backup.schedule.execute` already records schedule context, backup-set context, and terminal counters in the normal job path, but the stale-run reconciliation path still lacks a proof-based backup-specific truth contract.
- **User-visible improvement**: Operators can trust that the Operations hub and run detail pages say what actually happened for the selected families:
- `Succeeded` only when completeness is proven.
- `Partially succeeded` when usable output exists but some work failed or gaps remain.
- `Blocked` when configuration, eligibility, or capability truth prevented a meaningful run.
- `Failed` when no safe success or partial proof exists.
- **Smallest enterprise-capable version**:
- add bounded reconciliation for exactly three canonical families:
- `inventory.sync`
- `baseline.capture`
- `backup.schedule.execute`
- extend the existing reconciliation-result contract to express `partially_succeeded` using the existing `OperationRunOutcome` enum
- reuse only current repo-real proof paths and current operations/detail surfaces
- keep `policy.sync`, `backup_set.update`, restore, and generic report families out of scope
- **Explicit non-goals**:
- no new `OperationRun` status or outcome family
- no new table, artifact, queue family, or background worker contract
- no `policy.sync` direct adapter in this slice
- no restore, review-compose, evidence-snapshot, or review-pack expansion
- no provider/onboarding redesign
- no dashboard or product-wide operations UX rewrite
- no customer portal or AI follow-through
- **Permanent complexity imported**: three bounded adapters, one bounded partial-success helper on the current reconciliation result if needed, focused Unit/Feature coverage, and one bounded Browser smoke. No new persistence, global framework, or semantic taxonomy is introduced.
- **Why now**: Specs 358, 359, and 361 explicitly deferred sync/capture/backup business semantics. The current runtime now exposes the registry, proof, and outcome seams needed to finish the next narrow slice without inventing new platform machinery.
- **Why not local**: A local fix in one job or one page would keep outcome truth inconsistent across the registry, scheduled reconciliation, notifications, and current operations surfaces. This must be expressed once in the canonical reconciliation path.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: cross-surface monitoring truth and bounded adapter growth. **Defense**: scope stays on three existing families, uses existing `OperationRunOutcome` values, forbids new persistence, and explicitly keeps weaker families fail-closed.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve.
## Candidate Source And Completed-Spec Guardrail
- **Candidate source**:
- direct user-provided Spec 362 draft in `pasted-text.txt`
- repo-real follow-through after Specs 358, 359, and 361
- roadmap relationship: execution-truth maturity over the existing `OperationRun` line, especially before broader counted-progress or fan-out UX expansion
- **Queue boundary**: `docs/product/spec-candidates.md` still marks the active queue as empty for auto-selection. This package is an intentional manual promotion from direct user input, not an automatic queue pick.
- **Completed-spec check result**:
- no `specs/362-*` package existed before this prep
- Specs 358, 359, and 361 are treated as historical implementation context
- Spec 360 remains dependency context only; its package must not be rewritten or normalized here
- earlier manual-promotion product lanes such as Specs 264, 267, 274-277, 339, 341, 342, and 346 are already specced and therefore excluded from next-best-prep selection
- **Close alternatives deferred**:
- `First Governed AI Runtime Consumer v1` remains unspecced but is later-stage productization and less directly enabled by the current branch/runtime line
- `policy.sync` direct reconciliation is deferred because current repo proof is weaker than `inventory.sync`
- broader counted-progress, phase, or activity-center work remains separate from terminal-outcome truth
- **Smallest viable implementation slice**: selected repo-real proof for `inventory.sync`, `baseline.capture`, and `backup.schedule.execute` only, plus calm operations-surface fallout and explicit unsupported-family handling.
## Summary
This feature makes selected sync, capture, and backup runs honest when technical lifecycle and business truth drift apart.
It does **not** claim that every long-running job can now auto-complete. It only allows current repo-backed proof to finalize selected families:
- `inventory.sync` from coverage and result truth
- `baseline.capture` from snapshot and gap truth
- `backup.schedule.execute` from backup-set and backup-summary truth
Where proof is incomplete or ambiguous, the feature must fail closed as `partially_succeeded`, `blocked`, `failed`, or `not_reconciled` instead of pretending success.
## Roadmap Relationship
This slice belongs to the execution-truth line rather than the customer-review or productization backlog.
It sharpens terminal outcome semantics before any broader rollout of counted progress, fan-out progress, or calmer activity-center UX:
- it complements the repo's current progress and activity-truth candidate line
- it keeps terminal outcome semantics separate from progress semantics
- it avoids turning backup or sync product surfaces into false green states just because some artifact exists
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view plus environment-bound execution truth
- **Primary Routes**:
- `/admin/workspaces/{workspace}/operations`
- `/admin/workspaces/{workspace}/operations/{run}`
- current repo-real related detail surfaces only when a safe link already exists:
- inventory coverage and inventory-related detail routes
- baseline profile, baseline snapshot, and current baseline-related detail routes
- backup schedule, backup set, and backup-item detail routes
- **Data Ownership**:
- `operation_runs` remain the only execution and reconciliation truth
- `inventory_items.last_seen_operation_run_id` and `context.inventory.coverage` remain inventory observation proof
- `baseline_snapshots`, policy-version evidence, and `context.baseline_capture.*` remain baseline capture proof
- `backup_sets`, `backup_items`, and `context.backup_schedule_id` / `context.backup_set_id` remain backup proof
- no new persistence is introduced
- **RBAC**:
- existing workspace-first `OperationRun` access remains authoritative
- existing inventory, baseline, and backup detail/resource policies remain authoritative
- non-members and out-of-scope actors remain `404`
- no new capability strings are introduced
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: the Operations hub remains workspace-scoped with explicit environment filters only; reconciliation must rely on the run's recorded scope, not remembered environment state or current page filters.
- **Explicit entitlement checks preventing cross-tenant leakage**: no adapter may reconcile to inventory, snapshot, or backup artifacts outside the run's workspace and managed environment, and no related-artifact link may bypass current scope-safe routes.
## UI Surface Impact *(mandatory - UI-COV-001)*
- [ ] No UI surface impact
- [x] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [ ] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
- **Route/page/surface**:
- `App\Filament\Pages\Monitoring\Operations`
- `App\Filament\Pages\Operations\TenantlessOperationRunViewer`
- current repo-real inventory, baseline, and backup detail surfaces only if outcome copy or related links change there
- **Current or new page archetype**: existing monitoring/detail family plus current artifact/detail families
- **Design depth**: Domain Pattern Surface
- **Repo-truth level**: repo-verified
- **Existing pattern reused**: current `OperationRun` monitoring family, current related-artifact links, current inventory/baseline/backup detail families
- **New pattern required**: none; this is a truthful-outcome follow-through inside current families
- **Screenshot required**: no by default; one bounded Browser smoke is sufficient unless implementation proves a material first-screen hierarchy change
- **Page audit required**: no new page-report identity is required by default
- **Customer-safe review required**: no; touched copy remains operator-facing and diagnostic
- **Dangerous-action review required**: no; no new mutation surface is added
- **Coverage files updated or explicitly not needed**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [x] `N/A - existing operations, inventory, baseline, and backup surface families already cover the reachable routes`
- **No-impact rationale when applicable**: no new page family, navigation entry, or route family is added; the feature only adjusts terminal truth within current surfaces
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, action links, monitoring/detail explanation, related-artifact linkage
- **Systems touched**:
- `App\Services\OperationRunService`
- `App\Services\Operations\OperationLifecycleReconciler`
- `App\Services\AdapterRunReconciler`
- `App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry`
- `App\Support\Operations\Reconciliation\ReconciliationResult`
- `App\Support\OperationRunLinks`
- `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`
- current inventory, baseline, and backup proof helpers
- **Existing pattern(s) to extend**: current registry-backed reconciliation, current service-owned lifecycle writes, current operations/detail presenters, and current scope-safe related-artifact links
- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunService::applyReconciliationResult()`, `OperationRun::reconciliation()`, `OperationRunLinks`, `ArtifactTruthPresenter`, `GovernanceRunDiagnosticSummaryBuilder`, and current inventory/baseline/backup detail presenters
- **Why the existing shared path is sufficient or insufficient**: the registry and write seam already exist, but they do not yet cover selected sync/capture/backup proof families or partial-success finalization.
- **Allowed deviation and why**: one small proof helper is allowed only if duplicated completeness checks across the three selected adapters become noisy. It must remain derived-only and local to `App\Support\Operations\Reconciliation\`.
- **Consistency impact**: outcome wording, summary counts, related links, and supported/unsupported-family boundaries must stay consistent across jobs, reconciliation commands, monitoring/detail surfaces, notifications, and current detail pages.
- **Review focus**: no second reconciliation write path, no generic "latest artifact exists" heuristics, no policy-sync widening, and no new persistence.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: yes
- **Shared OperationRun UX contract/layer reused**: current `OperationRunService`, `OperationLifecycleReconciler`, registry-backed reconciliation, `OperationRunLinks`, and existing monitoring/detail presenters
- **Delegated start/completion UX behaviors**: existing queued toasts, run links, terminal notifications, and run-enqueued browser events remain unchanged
- **Local surface-owned behavior that remains**: wording and placement only
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: unchanged central lifecycle mechanism
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: no new provider boundary is introduced
- **Boundary classification**: platform-core execution truth over current provider-backed data
- **Seams affected**: run lifecycle, current artifact/result truth, current scope-safe links
- **Neutral platform terms preserved or introduced**: `operation`, `reconciliation`, `coverage`, `snapshot`, `backup set`, `blocked`, `partially succeeded`
- **Provider-specific semantics retained and why**: only existing provider error codes or readiness reasons already recorded in current context remain visible secondarily
- **Why this does not deepen provider coupling accidentally**: the feature uses platform-owned outcome and artifact/result truth only; it does not add provider-specific persistence, provider-routing rules, or Graph render calls
- **Follow-up path**: `policy.sync` direct semantics, broader counted progress, and provider/onboarding guidance remain separate follow-ups
## UI / Surface Guardrail Impact
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Operations hub selected run outcomes | yes | Native Filament + shared presenters | `OperationRun` monitoring family | page, detail, URL-query | no | no new action family |
| Run detail selected family explanation | yes | Native Filament + shared presenters | `OperationRun` monitoring family | detail | no | existing link model stays canonical |
| Inventory, baseline, and backup proof/detail surfaces | yes | Native Filament + shared detail helpers | related artifact/detail families | detail | no | only if outcome copy or links need alignment |
## Decision-First Surface Role
| 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 |
|---|---|---|---|---|---|---|---|
| Operations hub | Primary Decision Surface | Decide whether the selected run still needs follow-up | lifecycle, selected-family outcome, safe related proof, next action | raw failures, deep proof detail, timestamps | primary because operators clear run state from here | stays aligned to monitoring workflow | removes manual cross-checking across run and artifact pages |
| Run detail | Secondary Context Surface | Verify why the run ended as succeeded, partially succeeded, blocked, or failed | one calm explanation plus safe related proof link | full context payload and existing diagnostics | secondary because the run is already selected | stays aligned to detail workflow | keeps proof in one canonical place |
| Inventory/baseline/backup detail | Tertiary Evidence / Diagnostics | Inspect the artifact or result that justified the run outcome | current proof state | full detail, payloads, counters, existing diagnostics | tertiary because detail pages prove or explain the selected run | stays aligned to current detail workflows | avoids duplicating full proof truth in run rows |
## Audience-Aware Disclosure
| 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 |
|---|---|---|---|---|---|---|---|
| Operations hub | operator-MSP, support-platform | run status, selected-family outcome, safe reason, one next action | counts, failure categories, timestamps after explicit reveal | raw context remains diagnostic-only on current host surfaces | `Open operation` or safe related proof link | raw context and low-level provider detail | list row states the outcome once; later sections add proof only |
| Run detail | operator-MSP, support-platform | calm outcome explanation and safe proof summary | full reconciliation metadata and failures after explicit reveal | raw payloads stay secondary and host-gated | `Open related proof` when safe | low-level payloads and support-only detail | no second conflicting default-visible summary |
| Inventory/baseline/backup detail | operator-MSP, support-platform | current proof truth that supports or limits the outcome | existing detail diagnostics after explicit reveal | raw provider or payload detail remains behind existing gates | `Open related operation` when safe | payloads, fingerprints, and raw support context | detail pages add proof; they do not restate a competing run summary |
## UI/UX Surface Classification
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Operations hub | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Open the run or its safe proof | row click to run detail | required | existing filters and contextual links only | unchanged | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | workspace route plus explicit environment filter | Operations / Operation | honest selected-family outcome | none |
| Run detail | Record / Detail / Monitoring | Diagnostics-first detail surface | Inspect safe proof and deeper diagnostics | canonical detail page | N/A | existing navigation and related links only | unchanged | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | run-owned workspace/environment scope | Operation | calm outcome plus proof summary | none |
| Inventory/baseline/backup detail | Record / Detail / Monitoring | Detail-first proof surface | Confirm whether the current proof really supports the outcome | canonical detail page | N/A | existing related links only | unchanged | existing family collection routes | existing family detail routes | current workspace/environment scope on host surface | Inventory / Baseline Snapshot / Backup Set | current proof truth | none |
## Operator Surface Contract
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Operations hub | MSP operator | Decide whether the run still needs attention | list/workbench | Did this selected sync/capture/backup run finish successfully, partially, get blocked, or truly fail? | lifecycle state, selected-family outcome, safe proof hint, next step | raw failures, timestamps, detailed summary counts | lifecycle, proof completeness, scope safety | none | existing inspect/open actions only | none |
| Run detail | MSP operator or support | Confirm why the run resolved that way | detail | What exact proof or missing proof drove the final outcome? | calm outcome explanation, proof summary, safe related link | raw reconciliation context, low-level failure detail | lifecycle, proof completeness, support state | none | open related proof when safe | none |
| Inventory/baseline/backup detail | MSP operator or support | Confirm the supporting proof | detail | Does the current proof really justify or limit the selected run outcome? | proof readiness, counts, gaps or failures summary | payloads, fingerprints, deeper host diagnostics | proof completeness, expiration or gaps, scope safety | none | open related operation when safe | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes, bounded adapters and possibly one small proof helper
- **New enum/state/reason family?**: no; reuse the existing `OperationRunOutcome::PartiallySucceeded` and current blocked/failed semantics
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: selected sync/capture/backup runs can remain stale or end with generic failure even when repo-real proof already shows success, partial success, or a blocked precondition.
- **Existing structure is insufficient because**: the current registry covers restore, review-compose, evidence snapshot, and review-pack truth, but not the selected families that already emit usable proof through current context and artifact models.
- **Narrowest correct implementation**: add exactly three bounded proof adapters plus a bounded partial-success helper if the current reconciliation result cannot express the existing enum value cleanly.
- **Ownership cost**: adapter maintenance, focused tests, and some monitoring/detail copy review. No migration, new artifact family, or new operator workflow is introduced.
- **Alternative intentionally rejected**: a generic "latest artifact exists" rule or a new lifecycle table was rejected because it would overclaim success, weaken scope safety, and import permanent complexity unrelated to the current release.
- **Release truth**: current-release truth
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixture preservation, and compatibility-specific tests are out of scope unless explicitly required by the spec. Canonical current-release truth is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature, Browser
- **Validation lane(s)**: fast-feedback, confidence, browser
- **Why this classification and these lanes are sufficient**: proof and decision logic are cheapest in Unit coverage; run finalization, scope-safe related links, and current-family fallout require Feature coverage; one bounded Browser smoke is enough for changed operator wording on the existing Operations surfaces
- **New or expanded test families**: one explicit Spec 362 Unit/Feature family and one bounded Browser smoke
- **Fixture / helper cost impact**: moderate existing factory usage for `OperationRun`, `InventoryItem`, `BaselineSnapshot`, `BackupSet`, `BackupItem`, and workspace/environment membership; no new expensive default fixtures should be introduced
- **Heavy-family visibility / justification**: no heavy-governance family is added
- **Special surface test profile**: monitoring-state-page
- **Standard-native relief or required special coverage**: ordinary Feature coverage for existing Filament surfaces plus one bounded Browser smoke if visible copy changes
- **Reviewer handoff**: reviewers must confirm that partial success only lands when current proof is genuinely incomplete but usable, that blocked remains distinct from failed, that unsupported families stay out of scope, and that no new persistence or operation type slips into the implementation
- **Budget / baseline / trend impact**: none expected
- **Escalation needed**: document-in-feature if a weaker family such as `policy.sync` still needs a named follow-up; reject-or-split if implementation tries to widen into generic sync or backup taxonomy work
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyRegistryResolutionTest.php tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyProofRulesTest.php tests/Unit/Support/Operations/Reconciliation/Spec359ReconciliationResultTest.php tests/Feature/Operations/Spec362*`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/MonitoringOperationsTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec362SyncCaptureBackupSemanticsSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## User Stories & Acceptance Scenarios
### User Story 1 - Trust inventory sync outcomes (Priority: P1)
As an operator, I need `inventory.sync` runs to end with truthful terminal outcomes that match current coverage and error proof so I do not mistake a partial or blocked sync for a full success.
**Independent Test**: a queued/running/stale `inventory.sync` run finalizes to succeeded only when all attempted types are covered successfully, to partially succeeded when some types failed but usable coverage exists, and to blocked or failed when current repo proof shows a full precondition or execution failure.
**Acceptance Scenarios**:
1. **Given** a stale `inventory.sync` run with current-scope coverage for every attempted type and no failure categories, **When** reconciliation evaluates it, **Then** the run ends `completed/succeeded`.
2. **Given** a stale `inventory.sync` run with mixed coverage and recorded failed categories, **When** reconciliation evaluates it, **Then** the run ends `completed/partially_succeeded`.
3. **Given** a stale `inventory.sync` run whose current proof shows only blocked or skipped execution reasons, **When** reconciliation evaluates it, **Then** the run ends `completed/blocked`.
4. **Given** a stale `inventory.sync` run without trustworthy current-scope coverage or with wrong-scope evidence only, **When** reconciliation evaluates it, **Then** it must not auto-succeed.
### User Story 2 - Trust baseline capture outcomes (Priority: P1)
As an operator, I need `baseline.capture` runs to distinguish complete capture, gap-limited capture, and blocked capture so baseline truth does not overclaim fidelity.
**Independent Test**: a queued/running/stale `baseline.capture` run finalizes to succeeded only when a current-scope snapshot exists with no capture gaps, to partially succeeded when a snapshot exists but capture gaps or fallback evidence remain, and to blocked or failed when no safe proof exists.
**Acceptance Scenarios**:
1. **Given** a stale `baseline.capture` run with a current-scope `baseline_snapshot_id` and zero gap count, **When** reconciliation evaluates it, **Then** the run ends `completed/succeeded`.
2. **Given** a stale `baseline.capture` run with a current-scope snapshot but recorded capture gaps or meta fallback, **When** reconciliation evaluates it, **Then** the run ends `completed/partially_succeeded`.
3. **Given** a stale `baseline.capture` run whose current context still proves inventory or scope eligibility was blocked, **When** reconciliation evaluates it, **Then** the run ends `completed/blocked`.
4. **Given** a stale `baseline.capture` run without a current-scope snapshot and without safe blocked proof, **When** reconciliation evaluates it, **Then** it must end failed or remain unreconciled instead of succeeding.
### User Story 3 - Trust backup schedule execution outcomes (Priority: P1)
As an operator, I need `backup.schedule.execute` runs to distinguish complete backup, partial backup, and blocked backup from current backup-set proof so backup history stays honest.
**Independent Test**: a queued/running/stale `backup.schedule.execute` run finalizes to succeeded only when the current backup set proves complete capture, to partially succeeded when usable backup artifacts exist with recorded failures, and to blocked or failed when current schedule or backup proof shows no meaningful successful backup.
**Acceptance Scenarios**:
1. **Given** a stale `backup.schedule.execute` run with a current-scope backup set and full successful counts, **When** reconciliation evaluates it, **Then** the run ends `completed/succeeded`.
2. **Given** a stale `backup.schedule.execute` run with a backup set plus recorded failed families or missing assignment capture, **When** reconciliation evaluates it, **Then** the run ends `completed/partially_succeeded`.
3. **Given** a stale `backup.schedule.execute` run whose current context proves the schedule was skipped or blocked before meaningful backup work, **When** reconciliation evaluates it, **Then** the run ends `completed/blocked`.
4. **Given** a stale `backup.schedule.execute` run with no usable backup set or wrong-scope backup proof, **When** reconciliation evaluates it, **Then** it must not auto-succeed.
### User Story 4 - Keep weaker families honest (Priority: P2)
As an operator, I need weaker or more ambiguous families such as `policy.sync` and `backup_set.update` to stay unsupported in this slice so the system does not overclaim semantics it cannot yet prove.
**Independent Test**: unsupported families remain out of the adapter registry and continue to follow the current lifecycle paths unless a future spec adds stronger causal proof.
## Functional Requirements
- **FR-362-001**: The reconciliation path MUST support only these canonical families in this slice:
- `inventory.sync`
- `baseline.capture`
- `backup.schedule.execute`
- **FR-362-002**: The feature MUST NOT add a new `OperationRun` outcome family. If partial success is needed, it MUST reuse `OperationRunOutcome::PartiallySucceeded`.
- **FR-362-003**: `inventory.sync` may auto-finalize as succeeded only when current repo proof shows full current-scope coverage of attempted types without failure categories.
- **FR-362-004**: `inventory.sync` may auto-finalize as partially succeeded only when current repo proof shows usable coverage plus explicit failed or gap-like categories for some attempted types.
- **FR-362-005**: `baseline.capture` may auto-finalize as succeeded only when a current-scope baseline snapshot exists and the current gap summary proves no capture gaps remain.
- **FR-362-006**: `baseline.capture` may auto-finalize as partially succeeded only when a current-scope snapshot exists but the current gap summary or fallback proof shows incomplete capture.
- **FR-362-007**: `backup.schedule.execute` may auto-finalize as succeeded only when a current-scope backup set and its current summary counts prove complete backup work.
- **FR-362-008**: `backup.schedule.execute` may auto-finalize as partially succeeded only when a current-scope backup set exists and the current summary counts or failure proof show usable but incomplete backup output.
- **FR-362-009**: Selected families may auto-finalize as blocked only when current repo proof shows a meaningful precondition stop before successful work was produced, such as eligibility, capability, configuration, scope, or lock failure. If usable success or partial-success proof exists, `succeeded` or `partially_succeeded` MUST take precedence over `blocked`.
- **FR-362-010**: Ambiguous, wrong-scope, or missing proof MUST fail closed as `not_reconciled`, `blocked`, or `failed` depending on what the current repo can safely prove. They MUST NOT become success.
- **FR-362-011**: Selected-family reconciliation MUST continue to write lifecycle truth only through `OperationRunService`.
- **FR-362-012**: Current operator-facing monitoring/detail surfaces MUST explain selected-family outcomes calmly and keep raw diagnostics secondary.
- **FR-362-013**: `policy.sync`, `backup_set.update`, restore, review-compose, evidence snapshot, review-pack, and generic report families MUST remain unchanged in this slice unless current repo truth proves they are already covered elsewhere.
## Non-Functional Requirements
- **NFR-362-001**: No new schema, table, or persisted artifact may be introduced.
- **NFR-362-002**: No new panel, provider registration, global-search contract, or Filament asset strategy may be introduced.
- **NFR-362-003**: Reconciliation must stay scope-safe by workspace and managed environment and must not link to foreign artifacts.
- **NFR-362-004**: No Graph or provider HTTP calls may be introduced during page render.
- **NFR-362-005**: Tests must remain the narrowest honest proof: Unit plus Feature plus one bounded Browser smoke only if visible wording changes.
## Edge Cases
- Legacy alias values must not reintroduce provider-prefixed or legacy-named types as separate semantic families.
- Current proof may exist but belong to a different workspace or environment; that case must fail closed.
- Multiple candidate artifacts or result records for the same run must not silently choose a winner without safe deterministic proof.
- `partially_succeeded` must remain a terminal outcome distinct from `blocked` and `failed`; the UI must not flatten them into one generic caution state.
- Existing direct job completion paths remain authoritative. The new adapters must not double-finalize already completed runs.
## Risks
- A proof rule that is too permissive could overclaim success for incomplete backup or baseline output.
- A proof rule that is too strict could keep obviously completed runs stuck in generic failure or attention states.
- Selected-family proof may duplicate some existing job-local decisions if the adapter boundaries are not kept narrow.
## Assumptions
1. The current registry and correlation seams visible in `platform-dev` are authoritative enough for a bounded follow-through even though Spec 360 artifacts still exist as context.
2. `inventory.sync`, `baseline.capture`, and `backup.schedule.execute` remain the strongest current proof families among the sync/capture/backup line.
3. `policy.sync` still lacks a safe proof contract equal to `inventory.sync`, so it is intentionally excluded from the first slice.
## Open Questions
No open question blocks preparation.
Implementation must still verify whether any remaining Spec 360 cutover delta is required for these adapters. If runtime inspection proves a missing canonical seam is still blocking, implementation must stop and split that prerequisite explicitly instead of widening Spec 362 silently.
## Success Criteria
- Selected-family stale or interrupted runs no longer overclaim success or collapse into generic stale failure when stronger current proof exists.
- Operators can distinguish complete, partial, blocked, and failed outcomes on current operations surfaces without reading raw diagnostics first.
- Unsupported families remain explicit and fail-closed instead of silently drifting into heuristic success.
## Acceptance Criteria
- **AC-362-001**: `inventory.sync`, `baseline.capture`, and `backup.schedule.execute` each have a bounded proof-based reconciliation path.
- **AC-362-002**: Partial success uses the existing `OperationRunOutcome::PartiallySucceeded` instead of a new outcome family.
- **AC-362-003**: `policy.sync`, `backup_set.update`, restore, and generic report families remain out of scope and explicit.
- **AC-362-004**: Existing operations/detail surfaces show calm, scope-safe outcome wording with diagnostics secondary.
- **AC-362-005**: Focused Unit, Feature, and bounded Browser coverage are defined in `tasks.md` and are sufficient to prove the slice.
## Follow-Up Spec Candidates
- `policy.sync` direct operator-truth semantics after a stable proof contract exists
- broader counted-progress and fan-out semantics over the current execution-truth line
- broader backup or restore outcome taxonomy only if current release truth proves that a shared family is needed

View File

@ -0,0 +1,239 @@
# Tasks: Spec 362 - Sync, Capture, and Backup Operation Semantics
**Input**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/362-sync-capture-backup-operation-semantics/spec.md`, `plan.md`, and `checklists/requirements.md`
**Prerequisites**: `spec.md` and `plan.md`
**Tests**: REQUIRED (Pest). Keep proof bounded to Unit + Feature + one explicit Browser smoke.
**Operations**: Reuse current `OperationRun` lifecycle ownership. No new run status column, no new queue family, no new schema, and no destructive cleanup.
**RBAC**: Reuse current workspace-first `OperationRun` access plus existing inventory, baseline snapshot, and backup-set policies. No new capability strings and no cross-scope artifact resolution.
**Shared Pattern Reuse**: Reuse `OperationRunService`, `OperationRun::reconciliation()`, `OperationRunLinks`, `ArtifactTruthPresenter`, `GovernanceRunDiagnosticSummaryBuilder`, and the current operations/detail surfaces. Introduce only bounded selected-family adapters plus at most one small derived partial-success helper in `ReconciliationResult`.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration stays in `apps/platform/bootstrap/providers.php`. No new panel, global-search change, or asset strategy is allowed.
**Organization**: Tasks are grouped by user story so inventory sync, baseline capture, backup schedule execution, and explicit unsupported-family handling remain independently reviewable.
## Repo Baseline At Prep Time
- **Branch**: `362-sync-capture-backup-operation-semantics`
- **HEAD**: `252cd451 feat: implement report evidence reconciliation (#432)`
- **`git status --short --branch` before Spec 362 prep**: clean on `platform-dev`; Spec Kit created this feature branch and copied the spec/plan templates
- **Merged adapter baseline**: `840c9bd2 refactor: rename ManagedEnvironment context badge to Environment context (#431)` and `252cd451 feat: implement report evidence reconciliation (#432)` remain the immediate adapter-registry baseline
- **Relevant runtime surfaces**:
- `apps/platform/app/Services/OperationRunService.php`
- `apps/platform/app/Services/Operations/OperationLifecycleReconciler.php`
- `apps/platform/app/Services/AdapterRunReconciler.php`
- `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php`
- `apps/platform/app/Support/Operations/Reconciliation/ReconciliationResult.php`
- `apps/platform/app/Support/OperationCatalog.php`
- `apps/platform/app/Support/OperationRunType.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
- `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
- `apps/platform/app/Services/Inventory/InventorySyncService.php`
- `apps/platform/app/Support/Inventory/InventoryCoverage.php`
- `apps/platform/app/Services/Baselines/BaselineCaptureService.php`
- `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`
- `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`
- `apps/platform/app/Jobs/RunBackupScheduleJob.php`
- `apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php`
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Models/InventoryItem.php`
- `apps/platform/app/Models/BaselineSnapshot.php`
- `apps/platform/app/Models/BackupSet.php`
- `apps/platform/app/Models/BackupItem.php`
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- `apps/platform/app/Filament/Pages/InventoryCoverage.php`
- `apps/platform/app/Filament/Resources/InventoryItemResource.php`
- `apps/platform/app/Filament/Resources/BaselineSnapshotResource.php`
- `apps/platform/app/Filament/Resources/BackupSetResource.php`
- **Completed-spec context only**: Specs 358, 359, 360, and 361 are baseline context and must not be reopened during Spec 362 prep
- **Scope guardrail**: `policy.sync`, `backup_set.update`, restore, review-compose, evidence snapshot, review-pack, and generic report families stay explicit context or defers only
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and the Browser addition remains explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without widening into unrelated lane cost.
- [x] The declared monitoring/detail surface profile is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active feature close-out.
## Phase 1: Setup (Repo Truth Inventory)
**Purpose**: confirm the current registry baseline, the selected proof seams, and the explicit unsupported-family boundary before runtime edits begin.
- [x] T001 Re-read `spec.md`, `plan.md`, `checklists/requirements.md`, `.specify/memory/constitution.md`, `docs/ai-coding-rules.md`, `docs/architecture-guidelines.md`, `docs/testing-guidelines.md`, `docs/security-guidelines.md`, `docs/filament-guidelines.md`, and `specs/358-operationrun-queue-truth-foundation/{spec,plan,tasks}.md` plus `specs/359-operationrun-reconciliation-adapter-framework-review-compose-adapter/{spec,plan,tasks}.md`, `specs/360-operationrun-canonical-cutover-cleanup/{spec,plan,tasks}.md`, and `specs/361-report-evidence-reconciliation/{spec,plan,tasks}.md` together before touching runtime code.
- [x] T002 [P] Confirm the current adapter and write seams in `apps/platform/app/Services/AdapterRunReconciler.php`, `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Services/Operations/OperationLifecycleReconciler.php`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php`, and `apps/platform/app/Support/Operations/Reconciliation/ReconciliationResult.php`.
- [x] T003 [P] Confirm the current proof seams for `inventory.sync`, `baseline.capture`, and `backup.schedule.execute` in `apps/platform/app/Services/Inventory/InventorySyncService.php`, `apps/platform/app/Support/Inventory/InventoryCoverage.php`, `apps/platform/app/Services/Baselines/BaselineCaptureService.php`, `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`, `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`, `apps/platform/app/Jobs/RunBackupScheduleJob.php`, `apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php`, and the related Eloquent models.
- [x] T004 [P] Confirm the current canonical operation types and explicit unsupported-family boundary in `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php`, and the current run-start paths for `inventory.sync`, `baseline.capture`, `backup.schedule.execute`, `policy.sync`, and `backup_set.update`.
- [x] T005 Confirm that no new schema, no new panel/provider path, no new asset registration, no new global-search contract, and no new provider-boundary work are required; if any of those are needed, stop and update the spec/plan instead of widening scope silently.
---
## Phase 2: Foundational (Blocking Selected-Family Reconciliation Contract)
**Purpose**: settle the bounded partial-success and selected-family registry shape before story-specific adapter work begins.
**Critical**: no user-story runtime work should begin until this phase is complete.
- [x] T006 [P] Add failing Unit coverage in `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyRegistryResolutionTest.php` for adapter resolution of `inventory.sync`, `baseline.capture`, `backup.schedule.execute`, and explicit unsupported handling for `policy.sync` and `backup_set.update`.
- [x] T007 [P] Add failing Unit coverage in `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyProofRulesTest.php` for scope checks, complete-versus-partial proof, blocked-versus-partial precedence when usable output exists, blocked-versus-failed distinction, ambiguous candidate rejection, and fail-closed wrong-scope behavior.
- [x] T008 [P] Extend `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec359ReconciliationResultTest.php` or add a bounded Spec 362 companion file to prove `partially_succeeded` can be expressed through `ReconciliationResult` without introducing a new outcome family.
- [x] T009 Add `InventorySyncReconciliationAdapter`, `BaselineCaptureReconciliationAdapter`, and `BackupScheduleExecutionReconciliationAdapter` under `apps/platform/app/Support/Operations/Reconciliation/` and register them in `OperationRunReconciliationRegistry`.
- [x] T010 Confirmed no additional partial-success helper is required because `ReconciliationResult` already expresses `OperationRunOutcome::PartiallySucceeded` cleanly.
- [x] T011 Update or add Unit coverage proving that selected-family adapters never mutate `InventoryItem`, `BaselineSnapshot`, `BackupSet`, or `BackupItem`, that all lifecycle writes still go through `OperationRunService`, that `context.reconciliation` keeps the canonical adapter or reason or previous-state or related-artifact shape, and that rerunning reconciliation remains idempotent.
- [x] T011A [P] Extend `apps/platform/tests/Feature/MonitoringOperationsTest.php` or add the smallest focused Spec 362 companion file so the changed Operations hub/detail render path stays DB-only and never invokes `GraphClientInterface` for selected-family reconciliation fallout.
**Checkpoint**: the registry is ready for selected sync/capture/backup extensions, partial success is bounded, and weaker families remain explicit.
---
## Phase 3: User Story 1 - Trust inventory sync outcomes (Priority: P1)
**Goal**: `inventory.sync` runs can finalize truthfully from current coverage and failure proof instead of collapsing into stale ambiguity or false success.
**Independent Test**: run focused Unit and Feature coverage showing that a queued/running/stale inventory-sync run finalizes as succeeded only for full proof, partially succeeded only for usable incomplete proof, blocked only for meaningful precondition proof, and never succeeds from wrong-scope evidence.
### Tests for User Story 1
- [x] T012 [P] [US1] Add `apps/platform/tests/Feature/Operations/Spec362InventorySyncReconciliationTest.php` covering full success, partial success, blocked, wrong-scope rejection, and ambiguous-proof rejection.
- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php` and `apps/platform/tests/Feature/Inventory/RunInventorySyncJobTest.php` only as needed to prove current-scope coverage semantics, recorded failure categories, and no drift in the existing inventory sync job path.
- [x] T014 [P] [US1] Extend monitoring/detail presentation coverage in `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, or a focused Spec 362 companion file so reconciled inventory-sync outcomes stay calm, diagnostic-secondary, and scope-safe.
### Implementation for User Story 1
- [x] T015 [US1] Implement `InventorySyncReconciliationAdapter` under `apps/platform/app/Support/Operations/Reconciliation/` using existing `context.inventory.coverage`, `InventoryCoverage`, `InventoryItem::last_seen_operation_run_id`, and current failure-category truth.
- [x] T016 [US1] Reuse `apps/platform/app/Services/Inventory/InventorySyncService.php` and `apps/platform/app/Services/OperationRunService.php` without adding a new inventory lifecycle or persistence layer.
- [x] T017 [US1] Update the current operations/detail presentation seams in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Filament/Resources/InventoryItemResource.php` only as needed so reconciled inventory runs explain usable coverage without duplicate default-visible truth.
**Checkpoint**: inventory-sync runs reconcile safely from current coverage truth and never overclaim success for partial, wrong-scope, or ambiguous evidence.
---
## Phase 4: User Story 2 - Trust baseline capture outcomes (Priority: P1)
**Goal**: `baseline.capture` runs can distinguish complete capture, gap-limited capture, and blocked capture from current snapshot and gap proof.
**Independent Test**: run focused Unit and Feature coverage showing that a queued/running/stale baseline-capture run finalizes as succeeded only for a current-scope gap-free snapshot, partially succeeded only for a usable snapshot with gaps or fallback proof, and blocked or failed when safe proof is absent.
### Tests for User Story 2
- [x] T018 [P] [US2] Add `apps/platform/tests/Feature/Operations/Spec362BaselineCaptureReconciliationTest.php` covering gap-free success, partial success with capture gaps, blocked preconditions, and fail-closed no-snapshot or wrong-scope cases.
- [x] T019 [P] [US2] Extend `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php` only as needed to prove current gap truth and no drift in the existing capture path.
- [x] T020 [P] [US2] Extend operator-facing fallout coverage in `apps/platform/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, or a focused Spec 362 companion file so reconciled baseline outcomes stay calm, gap-aware, and diagnostic-secondary.
### Implementation for User Story 2
- [x] T021 [US2] Implement `BaselineCaptureReconciliationAdapter` under `apps/platform/app/Support/Operations/Reconciliation/` using existing `baseline_snapshot_id`, gap summaries, capture summary counts, and current baseline scope truth.
- [x] T022 [US2] Reuse `apps/platform/app/Services/Baselines/BaselineCaptureService.php`, `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`, and `apps/platform/app/Services/OperationRunService.php` without adding a new baseline lifecycle or persistence layer.
- [x] T023 [US2] Update the current operations/detail presentation seams in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Filament/Resources/BaselineSnapshotResource.php`, and `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` only as needed so reconciled baseline runs explain capture completeness without flattening partial and blocked states together.
**Checkpoint**: baseline-capture runs reconcile safely from current snapshot and gap truth and never overclaim fidelity when capture is incomplete.
---
## Phase 5: User Story 3 - Trust backup schedule execution outcomes (Priority: P1)
**Goal**: `backup.schedule.execute` runs can distinguish complete backup, partial backup, and blocked backup from current backup-set proof instead of generic stale failure.
**Independent Test**: run focused Unit and Feature coverage showing that a queued/running/stale backup-schedule run finalizes as succeeded only for complete current-scope backup proof, partially succeeded only for usable incomplete backup proof, and blocked or failed when safe proof is absent.
### Tests for User Story 3
- [x] T024 [P] [US3] Add `apps/platform/tests/Feature/Operations/Spec362BackupScheduleExecutionReconciliationTest.php` covering full success, partial success, blocked, wrong-scope rejection, and no-backup-set rejection.
- [x] T025 [P] [US3] Extend `apps/platform/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php` and `apps/platform/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php` only as needed to prove backup summary-count truth, current backup-set linkage, and no drift in the existing backup scheduling path.
- [x] T026 [P] [US3] Extend operator-facing fallout coverage in `apps/platform/tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, `apps/platform/tests/Feature/Filament/BackupSetRelatedNavigationTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, or a focused Spec 362 companion file so reconciled backup outcomes stay calm, link safely, and keep raw diagnostics secondary.
### Implementation for User Story 3
- [x] T027 [US3] Implement `BackupScheduleExecutionReconciliationAdapter` under `apps/platform/app/Support/Operations/Reconciliation/` using existing `backup_schedule_id`, `backup_set_id`, backup summary counts, and current backup-set scope truth.
- [x] T028 [US3] Reuse `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`, `apps/platform/app/Jobs/RunBackupScheduleJob.php`, `apps/platform/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php`, and `apps/platform/app/Services/OperationRunService.php` without adding a new backup lifecycle or persistence layer.
- [x] T029 [US3] Update the current operations/detail presentation seams in `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Filament/Resources/BackupSetResource.php`, and `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php` only as needed so reconciled backup runs explain usable backup proof without exposing low-level storage detail by default.
**Checkpoint**: backup-schedule runs reconcile safely from current backup-set truth and never overclaim success when backup output is partial or ambiguous.
---
## Phase 6: User Story 4 - Keep weaker sync and backup families honest (Priority: P2)
**Goal**: weaker or ambiguous families such as `policy.sync` and `backup_set.update` stay explicit and unsupported in this slice so the registry does not drift into heuristic success.
**Independent Test**: run focused Unit and Feature coverage showing that `policy.sync` and `backup_set.update` remain unresolved or diagnostic-first because Spec 362 keeps selected-family truth bounded.
### Tests for User Story 4
- [x] T030 [P] [US4] Add focused Unit or Feature coverage in `apps/platform/tests/Feature/Operations/Spec362UnsupportedSyncBackupFamiliesTest.php` proving that `policy.sync` and `backup_set.update` remain outside the Spec 362 adapter registry and do not auto-succeed from adjacent inventory or backup artifacts.
- [x] T031 [P] [US4] Add or extend operations-detail coverage in `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` or a focused Spec 362 file so unsupported sync/backup families remain attention-first, keep one dominant default next action, and do not expose raw/support evidence in the default-visible layer for ordinary operators.
### Implementation for User Story 4
- [x] T032 [US4] Verify that `policy.sync`, `backup_set.update`, restore, review-compose, evidence snapshot, review-pack, and generic report families remain excluded from the Spec 362 selected-family registry path and record named follow-up candidates instead of widening this package.
- [x] T033 [US4] No generic sync/backup helper should be introduced; selected-family proof stays local to the new adapters and weaker families stay on existing operations diagnostics and current host-gated disclosure paths.
- [x] T034 [US4] Ensure unsupported-family wording on current operations surfaces stays calm, explicit, and fail-closed without implying that nearby inventory, snapshot, or backup artifacts completed a different run automatically.
**Checkpoint**: the feature improves truth where repo proof is strong and explicitly refuses false success where proof is weaker.
---
## Phase 7: Polish & Validation
- [ ] T035 [P] Refresh `spec.md`, `plan.md`, and `checklists/requirements.md` only if implementation proves a thinner touched-file boundary or requires a narrower explicit defer around `policy.sync`, `backup_set.update`, or any remaining Spec 360 prerequisite.
- [x] T036 [P] Run the primary in-scope Unit and Feature gate:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyRegistryResolutionTest.php tests/Unit/Support/Operations/Reconciliation/Spec362SelectedFamilyProofRulesTest.php tests/Unit/Support/Operations/Reconciliation/Spec359ReconciliationResultTest.php tests/Feature/Operations/Spec362*`
- [x] T037 [P] Run the bounded contextual regressions:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/MonitoringOperationsTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Inventory/RunInventorySyncJobTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`
- [x] T038 [P] Run the primary browser smoke and verify the changed Operations surfaces still show one dominant next action, keep duplicate visible decision truth out of the default layer, and keep deeper diagnostics behind explicit reveal paths:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec362SyncCaptureBackupSemanticsSmokeTest.php`
- [ ] T039 [P] Run the bounded contextual browser smoke only if visible copy or related-link behavior changes on current host surfaces:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php tests/Browser/Spec328OperationsHubProductizationSmokeTest.php tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php`
- [x] T040 [P] Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T041 [P] Run `git diff --check`.
- [ ] T042 [P] Record the final selected families reconciled, the explicit unsupported-family decision, validation results, no-migration status, no-asset status, and the final Guardrail / Smoke Coverage note in the active feature close-out.
## Close-Out Notes
- Supported selected families must end as exactly: `inventory.sync`, `baseline.capture`, and `backup.schedule.execute`.
- Explicit unsupported-family decision must remain: `policy.sync`, `backup_set.update`, restore, review-compose, evidence snapshot, review-pack, and generic report families stay outside Spec 362 unless a later spec proves stronger causal truth.
- No migration status should remain: no schema or persisted-truth changes are expected.
- No asset / panel status should remain: no Filament panel/provider/global-search/asset changes are expected; Laravel 12 provider registration remains in `apps/platform/bootstrap/providers.php`, and Filament stays on v5 with Livewire v4.
- Render-locality proof should remain explicit: changed Operations hub/detail behavior must keep the existing DB-only render guarantee, and any touched proof host surface must extend the smallest existing no-Graph render guard before merge.
- Guardrail / Smoke Coverage should stay bounded to Unit + Feature + one Browser smoke over the existing Operations hub/detail family plus current proof/detail surfaces only when required by visible fallout.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: no dependencies
- **Foundational (Phase 2)**: depends on Setup and blocks all story work
- **US1 (Phase 3)**: depends on Foundational completion
- **US2 (Phase 4)**: depends on Foundational completion; can land after US1 or in parallel once shared proof rules settle
- **US3 (Phase 5)**: depends on Foundational completion; can land after US1 or US2 once the partial-success helper and selected-family registry shape are stable
- **US4 (Phase 6)**: depends on US1 through US3 because the unsupported-family boundary should be validated against the final in-scope adapter set
- **Polish (Phase 7)**: depends on all desired user stories
### Parallel Opportunities
- `T002`, `T003`, and `T004` can run in parallel.
- `T006`, `T007`, and `T008` can run in parallel.
- `T012`, `T013`, and `T014` can run in parallel.
- `T018`, `T019`, and `T020` can run in parallel.
- `T024`, `T025`, and `T026` can run in parallel.
- `T030` and `T031` can run in parallel.
- `T036` through `T041` can run in parallel once implementation stabilizes, but the primary merge gate should be read out separately from contextual regressions.
### Implementation Strategy
1. Freeze the current registry and proof-model baseline first.
2. Land the bounded partial-success and selected-family registry shape before family-specific runtime edits.
3. Land inventory-sync reconciliation first because its proof path is the strongest current sync family.
4. Land baseline-capture reconciliation second because snapshot-and-gap truth is already repo-real.
5. Land backup-schedule reconciliation third because its proof path already exposes backup-set identity and summary counts.
6. Finish by locking fail-closed behavior for weaker sync/backup families and recording any explicit defer.
## Non-Goals / Must-Not-Do
- [ ] NT001 Do not add a new `OperationRun` status column, boolean, or separate reconciliation table.
- [ ] NT002 Do not add a new queue/job family, a generic sync/backup engine, or a second operator-center UI.
- [ ] NT003 Do not mutate `InventoryItem`, `BaselineSnapshot`, `BackupSet`, or `BackupItem` from adapters.
- [ ] NT004 Do not widen scope into `policy.sync`, `backup_set.update`, restore, review-compose, evidence snapshot, review-pack, or generic report semantics in this feature.
- [ ] NT005 Do not add compatibility shims, new panel/provider registration, global-search behavior, or asset strategy only to preserve pre-production historical behavior.