TenantAtlas/specs/087-legacy-runs-removal/analysis-report.md

40 KiB

Spec 087 — Legacy Runs Removal: Analysis Report

Report Date: 2026-02-11 Scope: Analyse → Backfill → Cleanup → Drop for all non-canonical run tracking tables. Canonical Run System: operation_runs table + OperationRun model + /admin/operations/{run}


1) Executive Summary

Legacy Sources Identified: 4 (plus 1 already dropped)

# Source Table Classification Current State Risk
1 inventory_sync_runs Legacy run tracking Active writes, has FK operation_run_id (nullable) Medium — Drift depends on this as entity
2 entra_group_sync_runs Legacy run tracking Active writes, has FK operation_run_id (nullable) Low — hidden nav, small footprint
3 backup_schedule_runs Legacy run tracking Active writes, has FK operation_run_id (nullable) High — scheduler writes here every minute
4 restore_runs Domain entity (NOT purely legacy tracking) Active writes, has FK operation_run_id (nullable), SoftDeletes High — 1,980-line resource, wizard, 20+ tests
5 bulk_operation_runs Already dropped Migration 2026_01_18 drops table; guard test prevents references None — done

Biggest Risks

  1. restore_runs is a domain entity, not just run tracking — it holds requested_items, preview, results, group_mapping, metadata. Its execution tracking (status, timestamps, counts) should come from operation_runs, but the entity itself must survive. It cannot be dropped.
  2. Drift detection depends on InventorySyncRun as a domain entity for baseline/current run references (Finding.baseline_run_id, Finding.current_run_id, InventoryItem.last_seen_run_id). Dropping this table requires migrating those FKs to operation_run_id.
  3. BackupScheduleRun is written by RunBackupScheduleJob on every scheduled execution — the scheduler produces ~N rows/minute where N = active schedules. Backfill must handle high volume.
  4. Graph API calls in UI are limited to TenantResource.php (RBAC setup modal, not in run rendering) — no run-related UI makes Graph calls.

Quick Wins

  • EntraGroupSyncRunResource ($shouldRegisterNavigation = false) — lowest friction to remove.
  • BulkOperationRun — already dropped, guard test exists.
  • Both InventorySyncRunResource.ViewInventorySyncRun and EntraGroupSyncRunResource.ViewEntraGroupSyncRun already redirect to canonical TenantlessOperationRunViewer when operation_run_id is set.

2) Inventory: Table-by-Table

2a) inventory_sync_runs

Aspect Detail
Migration 2026_01_07_142719_create_inventory_sync_runs_table.php
Columns id, tenant_id (FK), user_id (FK, nullable, added 2026_01_09), selection_hash (64), selection_payload (jsonb, nullable), status (string), had_errors (bool), error_codes (jsonb), error_context (jsonb), started_at (timestampTz), finished_at (timestampTz), items_observed_count, items_upserted_count, errors_count, operation_run_id (FK, nullable, added 2026_02_10), created_at, updated_at
FK Relationships tenant_id → tenants, user_id → users, operation_run_id → operation_runs
Indexes (tenant_id, selection_hash), (tenant_id, status), (tenant_id, finished_at), (tenant_id, user_id), operation_run_id
Model App\Models\InventorySyncRun (59 lines)
Status constants STATUS_PENDING, STATUS_RUNNING, STATUS_SUCCESS, STATUS_PARTIAL, STATUS_FAILED, STATUS_SKIPPED
Factory InventorySyncRunFactory
Row count unknown (SQL: SELECT COUNT(*) FROM inventory_sync_runs;)
Classification Hybrid — legacy run tracking BUT also used as domain entity by Drift (Finding.baseline_run_id, Finding.current_run_id, InventoryItem.last_seen_run_id)
Inbound FKs findings.baseline_run_id, findings.current_run_id, inventory_items.last_seen_run_id (all → inventory_sync_runs.id), operation_runs ← via inventory_sync_runs.operation_run_id

Start surfaces that write to this table:

Surface File Line(s)
InventorySyncService::startSync() app/Services/Inventory/InventorySyncService.php L43, L62, L68

Filament UI:

Component File Lines
InventorySyncRunResource app/Filament/Resources/InventorySyncRunResource.php 231
ListInventorySyncRuns …/Pages/ListInventorySyncRuns.php 19
ViewInventorySyncRun (redirects to OpRun) …/Pages/ViewInventorySyncRun.php 24
InventoryKpiHeader widget (reads last sync) app/Filament/Widgets/Inventory/InventoryKpiHeader.php 163
Nav: registered in Inventory cluster, sort=2, label="Sync History"

2b) entra_group_sync_runs

Aspect Detail
Migration 2026_01_11_120004_create_entra_group_sync_runs_table.php
Columns id, tenant_id (FK, cascadeOnDelete), selection_key, slot_key (nullable), status, error_code (nullable), error_category (nullable), error_summary (text, nullable), safety_stop_triggered (bool), safety_stop_reason (nullable), pages_fetched, items_observed_count, items_upserted_count, error_count, initiator_user_id (FK users, nullable), started_at (timestampTz), finished_at (timestampTz), operation_run_id (FK, nullable, added 2026_02_10), created_at, updated_at
FK Relationships tenant_id → tenants, initiator_user_id → users, operation_run_id → operation_runs
Indexes (tenant_id, selection_key), (tenant_id, status), (tenant_id, finished_at), UNIQUE(tenant_id, selection_key, slot_key), operation_run_id
Model App\Models\EntraGroupSyncRun (40 lines)
Status constants STATUS_PENDING, STATUS_RUNNING, STATUS_SUCCEEDED, STATUS_FAILED, STATUS_PARTIAL
Factory EntraGroupSyncRunFactory
Row count unknown (SQL: SELECT COUNT(*) FROM entra_group_sync_runs;)
Classification Pure legacy run tracking — no inbound FKs from other domain tables
Inbound FKs None

Start surfaces that write to this table:

Surface File Line(s)
EntraGroupSyncJob::handle() app/Jobs/EntraGroupSyncJob.php L56-L227

Filament UI:

Component File Lines
EntraGroupSyncRunResource app/Filament/Resources/EntraGroupSyncRunResource.php 168
ListEntraGroupSyncRuns …/Pages/ListEntraGroupSyncRuns.php 11
ViewEntraGroupSyncRun (redirects to OpRun) …/Pages/ViewEntraGroupSyncRun.php 24
Nav: hidden ($shouldRegisterNavigation = false)

2c) backup_schedule_runs

Aspect Detail
Migration 2026_01_05_011034_create_backup_schedule_runs_table.php
Columns id, backup_schedule_id (FK, cascadeOnDelete), tenant_id (FK, cascadeOnDelete), scheduled_for (datetime), started_at (datetime, nullable), finished_at (datetime, nullable), status (enum: running/success/partial/failed/canceled/skipped), summary (json, nullable), error_code (nullable), error_message (text, nullable), backup_set_id (FK, nullable), user_id (FK, added 2026_01_06), operation_run_id (FK, nullable, added 2026_02_10), created_at, updated_at
FK Relationships backup_schedule_id → backup_schedules, tenant_id → tenants, backup_set_id → backup_sets, user_id → users, operation_run_id → operation_runs
Indexes UNIQUE(backup_schedule_id, scheduled_for), (backup_schedule_id, scheduled_for), (tenant_id, created_at), operation_run_id
Model App\Models\BackupScheduleRun (53 lines)
Status constants STATUS_RUNNING, STATUS_SUCCESS, STATUS_PARTIAL, STATUS_FAILED, STATUS_CANCELED, STATUS_SKIPPED
Factory None
Row count unknown (SQL: SELECT COUNT(*) FROM backup_schedule_runs;)
Classification Legacy run tracking — but references backup_set_id (produced artifact)
Inbound FKs None

Start surfaces that write to this table:

Surface File Line(s)
RunBackupScheduleJob::handle() app/Jobs/RunBackupScheduleJob.php L40, L80, L119-L1035

Filament UI:

Component File Lines
BackupScheduleRunsRelationManager app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php 107
BackupScheduleOperationRunsRelationManager (canonical) …/RelationManagers/BackupScheduleOperationRunsRelationManager.php 96
Blade view: backup-schedule-run-view.blade.php (modal) resources/views/filament/modals/backup-schedule-run-view.blade.php 48
Nav: no dedicated nav; shown as RelationManager tab on BackupSchedule

2d) restore_runs

Aspect Detail
Migration 2025_12_10_000150_create_restore_runs_table.php + several amendments
Columns id, tenant_id (FK, cascadeOnDelete), backup_set_id (FK, cascadeOnDelete), requested_by (nullable), is_dry_run (bool), status (string, uses RestoreRunStatus enum), requested_items (json), preview (json), results (json), failure_reason (text, nullable), metadata (json), group_mapping (json), idempotency_key (string, nullable), started_at, completed_at, operation_run_id (FK, nullable, added 2026_02_10), deleted_at (soft deletes), created_at, updated_at
FK Relationships tenant_id → tenants, backup_set_id → backup_sets, operation_run_id → operation_runs
Indexes (tenant_id, status), started_at, completed_at, operation_run_id
Model App\Models\RestoreRun (157 lines, SoftDeletes)
Status enum RestoreRunStatus (13 cases: Draft→Scoped→Checked→Previewed→Pending→Queued→Running→Completed→Partial→Failed→Cancelled→Aborted→CompletedWithErrors)
Factory RestoreRunFactory
Row count unknown (SQL: SELECT COUNT(*) FROM restore_runs;)
Classification Domain entity — NOT droppable. Holds wizard state, preview diffs, group mappings, results. Execution tracking portion migrates to operation_runs.
Inbound FKs None from other tables (but BackupSet.restoreRuns() hasMany)

Start surfaces that write to this table:

Surface File Line(s)
RestoreService::startRestore() app/Services/Intune/RestoreService.php L120, L208, L213
RestoreRunResource wizard app/Filament/Resources/RestoreRunResource.php ~1,980 lines total
RestoreRunObserverSyncRestoreRunToOperationRun app/Observers/RestoreRunObserver.php L13-L30
ExecuteRestoreRunJob app/Jobs/ExecuteRestoreRunJob.php L29-L155

Filament UI:

Component File Lines
RestoreRunResource (largest resource) app/Filament/Resources/RestoreRunResource.php 1,980
ListRestoreRuns …/Pages/ListRestoreRuns.php 21
CreateRestoreRun (wizard) …/Pages/CreateRestoreRun.php 167
ViewRestoreRun …/Pages/ViewRestoreRun.php 11
Blade: restore-run-preview.blade.php resources/views/filament/forms/components/restore-run-preview.blade.php 180
Blade: restore-run-checks.blade.php resources/views/filament/forms/components/restore-run-checks.blade.php 121
Nav: registered in tenant panel under "Backups & Restore"

2e) bulk_operation_runsALREADY DROPPED

Aspect Detail
Drop migration 2026_01_18_000001_drop_bulk_operation_runs_table.php
Model Deleted — BulkOperationRun class does not exist
Guard test tests/Feature/Guards/NoLegacyBulkOperationsTest.php — scans codebase for \bBulkOperationRun\b
Status Complete. No action needed.

3) Backfill Plan per Source

Existing Infrastructure

All 4 legacy tables already have operation_run_id FK (nullable, added 2026_02_10_*). This means:

  • New runs may already link to operation_runs (depends on whether the write path sets the FK).
  • Historical runs (before 2026-02-10) have operation_run_id = NULL and need backfill.

3a) inventory_sync_runsoperation_runs Backfill

OperationRun mapping:
├── type             = 'inventory.sync' (OperationRunType::InventorySync)
├── status           = legacy.status mapped:
│   ├── pending  → queued
│   ├── running  → running
│   ├── success  → completed
│   ├── partial  → completed
│   ├── failed   → completed
│   └── skipped  → completed
├── outcome          = legacy.status mapped:
│   ├── pending  → pending
│   ├── running  → pending
│   ├── success  → succeeded
│   ├── partial  → partially_succeeded
│   ├── failed   → failed
│   └── skipped  → cancelled
├── run_identity_hash = sha256("{tenant_id}|inventory.sync|{selection_hash}")
├── summary_counts   = {
│   total:     legacy.items_observed_count,
│   succeeded: legacy.items_upserted_count,
│   failed:    legacy.errors_count
│   }
├── failure_summary  = legacy.error_codes?.map(c => {code: c, reason_code: 'unknown', message: ''}) ?? []
├── context          = {
│   inputs: { selection_hash: legacy.selection_hash },
│   selection_payload: legacy.selection_payload,
│   legacy: { source: 'inventory_sync_runs', id: legacy.id }
│   }
├── tenant_id        = legacy.tenant_id
├── user_id          = legacy.user_id
├── initiator_name   = User.name ?? 'system'
├── started_at       = legacy.started_at
├── completed_at     = legacy.finished_at
├── created_at       = legacy.created_at
└── updated_at       = legacy.updated_at

Missing data: None significant. user_id may be NULL for system-initiated syncs.

Post-backfill: Set inventory_sync_runs.operation_run_id = created_operation_run.id.

Drift FK migration: After backfill, update:

  • findings.baseline_run_id → new column findings.baseline_operation_run_id
  • findings.current_run_id → new column findings.current_operation_run_id
  • inventory_items.last_seen_run_id → new column inventory_items.last_seen_operation_run_id

3b) entra_group_sync_runsoperation_runs Backfill

OperationRun mapping:
├── type             = 'directory_groups.sync' (OperationRunType::DirectoryGroupsSync)
├── status           = legacy.status mapped:
│   ├── pending   → queued
│   ├── running   → running
│   ├── succeeded → completed
│   ├── failed    → completed
│   └── partial   → completed
├── outcome          = legacy.status mapped:
│   ├── pending   → pending
│   ├── running   → pending
│   ├── succeeded → succeeded
│   ├── failed    → failed
│   └── partial   → partially_succeeded
├── run_identity_hash = sha256("{tenant_id}|directory_groups.sync|{selection_key}:{slot_key}")
├── summary_counts   = {
│   total:     legacy.items_observed_count,
│   succeeded: legacy.items_upserted_count,
│   failed:    legacy.error_count,
│   items:     legacy.pages_fetched
│   }
├── failure_summary  = legacy.error_code ?
│   [{code: legacy.error_code, reason_code: legacy.error_category ?? 'unknown',
│     message: substr(legacy.error_summary, 0, 120)}] : []
├── context          = {
│   inputs: { selection_key: legacy.selection_key, slot_key: legacy.slot_key },
│   safety_stop: { triggered: legacy.safety_stop_triggered, reason: legacy.safety_stop_reason },
│   legacy: { source: 'entra_group_sync_runs', id: legacy.id }
│   }
├── tenant_id        = legacy.tenant_id
├── user_id          = legacy.initiator_user_id
├── initiator_name   = User.name ?? 'scheduler'
├── started_at       = legacy.started_at
├── completed_at     = legacy.finished_at
├── created_at       = legacy.created_at
└── updated_at       = legacy.updated_at

Missing data: None — all fields map cleanly.

Post-backfill: Set entra_group_sync_runs.operation_run_id = created_operation_run.id.


3c) backup_schedule_runsoperation_runs Backfill

OperationRun mapping:
├── type             = legacy→type mapping:
│   └── All historical: 'backup_schedule.scheduled' (OperationRunType::BackupScheduleScheduled)
│   NOTE: run_now/retry distinction is lost in historical data
├── status           = legacy.status mapped:
│   ├── running  → running
│   ├── success  → completed
│   ├── partial  → completed
│   ├── failed   → completed
│   ├── canceled → completed
│   └── skipped  → completed
├── outcome          = legacy.status mapped:
│   ├── running  → pending
│   ├── success  → succeeded
│   ├── partial  → partially_succeeded
│   ├── failed   → failed
│   ├── canceled → cancelled
│   └── skipped  → cancelled
├── run_identity_hash = sha256("{tenant_id}|backup_schedule.scheduled|{schedule_id}:{scheduled_for_iso}")
├── summary_counts   = legacy.summary (JSON) — extract from structure:
│   { total: summary.total_policies ?? 0, succeeded: summary.backed_up ?? 0,
│     failed: summary.errors ?? 0 }
├── failure_summary  = legacy.error_code ?
│   [{code: legacy.error_code, reason_code: 'unknown', message: substr(legacy.error_message, 0, 120)}] : []
├── context          = {
│   inputs: {
│     backup_schedule_id: legacy.backup_schedule_id,
│     scheduled_for: legacy.scheduled_for
│   },
│   backup_set_id: legacy.backup_set_id,
│   legacy: { source: 'backup_schedule_runs', id: legacy.id }
│   }
├── tenant_id        = legacy.tenant_id
├── user_id          = legacy.user_id
├── initiator_name   = User.name ?? 'scheduler'
├── started_at       = legacy.started_at
├── completed_at     = legacy.finished_at
├── created_at       = legacy.created_at
└── updated_at       = legacy.updated_at

Missing data:

  • type distinction (run_now vs retry vs scheduled) is lost for historical rows — all backfilled as backup_schedule.scheduled.
  • summary JSON structure varies — needs defensive parsing.

Post-backfill: Set backup_schedule_runs.operation_run_id = created_operation_run.id.


3d) restore_runsoperation_runs Backfill

NOTE: restore_runs is a domain entity. Only the execution-tracking portion (status, timestamps, counts) is backfilled to operation_runs. The entity itself persists.

OperationRun mapping:
├── type             = 'restore.execute' (OperationRunType::RestoreExecute)
│   NOTE: only Queued→Running→Completed states get an OpRun.
│   Draft/Scoped/Checked/Previewed are wizard states, NOT execution runs.
├── SKIP condition   = legacy.status IN (Draft, Scoped, Checked, Previewed, Pending) → do NOT backfill
├── status           = legacy.status mapped:
│   ├── Queued        → queued
│   ├── Running       → running
│   ├── Completed     → completed
│   ├── Partial       → completed
│   ├── Failed        → completed
│   ├── Cancelled     → completed
│   ├── Aborted       → completed
│   └── CompletedWithErrors → completed
├── outcome          = legacy.status mapped:
│   ├── Queued        → pending
│   ├── Running       → pending
│   ├── Completed     → succeeded
│   ├── Partial       → partially_succeeded
│   ├── Failed        → failed
│   ├── Cancelled     → cancelled
│   ├── Aborted       → failed
│   └── CompletedWithErrors → partially_succeeded
├── run_identity_hash = sha256("{tenant_id}|restore.execute|{restore_run_id}")
├── summary_counts   = derived from legacy.results JSON:
│   { total: count(results.items), succeeded: count(results where ok), failed: count(results where !ok) }
├── failure_summary  = legacy.failure_reason ?
│   [{code: 'restore.failed', reason_code: 'unknown', message: substr(legacy.failure_reason, 0, 120)}] : []
├── context          = {
│   inputs: { restore_run_id: legacy.id, backup_set_id: legacy.backup_set_id },
│   is_dry_run: legacy.is_dry_run,
│   legacy: { source: 'restore_runs', id: legacy.id }
│   }
├── tenant_id        = legacy.tenant_id
├── user_id          = NULL (legacy has requested_by as string, not FK)
├── initiator_name   = legacy.requested_by ?? 'system'
├── started_at       = legacy.started_at
├── completed_at     = legacy.completed_at
├── created_at       = legacy.created_at
└── updated_at       = legacy.updated_at

Missing data:

  • user_idrequested_by is a string name, not an FK. Backfill can attempt User::where('name', requested_by)->first() but may not resolve.
  • summary_countsresults JSON structure varies; needs defensive parsing.

Post-backfill: Set restore_runs.operation_run_id = created_operation_run.id.

Existing sync infrastructure:

  • RestoreRunObserver already syncs RestoreRun → OperationRun on create/update via SyncRestoreRunToOperationRun.
  • AdapterRunReconciler reconciles RestoreRun status → OperationRun via scheduled ReconcileAdapterRunsJob (every 30 min).

All 4 legacy tables already have operation_run_id FK (nullable):

Table Migration FK constraint On delete
inventory_sync_runs 2026_02_10_090213 constrained('operation_runs') nullOnDelete
entra_group_sync_runs 2026_02_10_090214 constrained('operation_runs') nullOnDelete
backup_schedule_runs 2026_02_10_090215 constrained('operation_runs') nullOnDelete
restore_runs 2026_02_10_115908 constrained('operation_runs') nullOnDelete

Current behavior: New write paths (post 2026-02-10) should be populating this FK, but needs verification per table:

  • InventorySyncService — needs audit (may not set FK yet)
  • EntraGroupSyncJob — needs audit
  • RunBackupScheduleJob — needs audit (reconcile command exists for this)
  • RestoreRunObserverSyncRestoreRunToOperationRunactively syncs (confirmed)

SQL to verify:

SELECT 'inventory_sync_runs' AS tbl, COUNT(*) AS total,
       COUNT(operation_run_id) AS linked, COUNT(*) - COUNT(operation_run_id) AS unlinked
FROM inventory_sync_runs
UNION ALL
SELECT 'entra_group_sync_runs', COUNT(*), COUNT(operation_run_id), COUNT(*) - COUNT(operation_run_id)
FROM entra_group_sync_runs
UNION ALL
SELECT 'backup_schedule_runs', COUNT(*), COUNT(operation_run_id), COUNT(*) - COUNT(operation_run_id)
FROM backup_schedule_runs
UNION ALL
SELECT 'restore_runs', COUNT(*), COUNT(operation_run_id), COUNT(*) - COUNT(operation_run_id)
FROM restore_runs;

4) Cleanup Plan

4a) Code Removal

Phase 1: EntraGroupSyncRunResource (Quick Win)

What File Action
Resource app/Filament/Resources/EntraGroupSyncRunResource.php (168 lines) Delete
List page …/Pages/ListEntraGroupSyncRuns.php (11 lines) Delete
View page …/Pages/ViewEntraGroupSyncRun.php (24 lines) Delete
Policy app/Policies/EntraGroupSyncRunPolicy.php Delete
Badge class app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php Delete
Badge enum case BadgeDomain::EntraGroupSyncRun → remove case Edit
Badge catalog mapping BadgeCatalog → remove mapping Edit
Notification link RunStatusChangedNotification.php → remove entra branch Edit

Phase 2: InventorySyncRunResource (Medium — drift dependency)

What File Action
Resource app/Filament/Resources/InventorySyncRunResource.php (231 lines) Delete
List page …/Pages/ListInventorySyncRuns.php (19 lines) Delete
View page …/Pages/ViewInventorySyncRun.php (24 lines) Delete
Badge class app/Support/Badges/Domains/InventorySyncRunStatusBadge.php Delete
Badge enum case BadgeDomain::InventorySyncRun → remove case Edit
Badge catalog mapping BadgeCatalog → remove mapping Edit
KPI widget refs InventoryKpiHeader.php → update to query operation_runs only Edit
Nav item Remove "Sync History" from Inventory cluster

Prerequisite: Drift tables must migrate FKs from inventory_sync_runs.idoperation_runs.id:

  • findings.baseline_run_id + findings.current_run_idfindings.baseline_operation_run_id + findings.current_operation_run_id
  • inventory_items.last_seen_run_idinventory_items.last_seen_operation_run_id

Phase 3: BackupScheduleRunsRelationManager (Medium)

What File Action
Legacy RM BackupScheduleRunsRelationManager.php (107 lines) Delete
Blade modal backup-schedule-run-view.blade.php (48 lines) Delete
Badge class app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php Delete
Badge enum case BadgeDomain::BackupScheduleRun → remove case Edit
Badge catalog mapping BadgeCatalog → remove mapping Edit
BackupSchedule resource Remove legacy RM, keep BackupScheduleOperationRunsRelationManager Edit
Reconcile command TenantpilotReconcileBackupScheduleOperationRuns → keep for transition, then archive

Phase 4: RestoreRun (Largest — domain entity)

RestoreRun is NOT removed — it is a domain entity. The cleanup targets:

What File Action
Status tracking columns Remove status duplication → read from operationRun.status Deprecate, keep column
started_at, completed_at Redundant with operationRun.started_at/completed_at Deprecate, keep column
failure_reason Move to operationRun.failure_summary Deprecate, keep column
RestoreRunObserver sync Keep (ensures OpRun is created on RestoreRun lifecycle) Keep
AdapterRunReconciler Eventually remove once all status reads go through OpRun Keep for transition
Badge class app/Support/Badges/Domains/RestoreRunBadges → derive from OpRun status Edit

4b) Service/Job Cleanup

File Action
InventorySyncService.php Stop writing InventorySyncRun::create(), create only OperationRun
EntraGroupSyncJob.php Stop writing EntraGroupSyncRun::create(), use only OperationRun
RunBackupScheduleJob.php Stop writing BackupScheduleRun::create(), use only OperationRun
ApplyBackupScheduleRetentionJob.php Update to read retention data from OperationRun context
TenantpilotPurgeNonPersistentData.php Remove legacy table references
DriftRunSelector, DriftScopeKey, DriftFindingGenerator Update to use OperationRun (after FK migration)

4c) Model Cleanup (after drop)

Model Action
InventorySyncRun Delete (after drift FK migration + table drop)
EntraGroupSyncRun Delete (after table drop)
BackupScheduleRun Delete (after table drop)
RestoreRun Keep — domain entity
Factories for above Delete respective factories

5) Redirect Strategy

Existing Routes for Legacy Run Views

Pattern Name Current Handler
GET /admin/t/{tenant}/inventory-sync-runs/{record} filament.tenant.resources.inventory-sync-runs.view ViewInventorySyncRun
GET /admin/t/{tenant}/inventory-sync-runs filament.tenant.resources.inventory-sync-runs.index ListInventorySyncRuns
GET /admin/entra-group-sync-runs/{record} filament.admin.resources.entra-group-sync-runs.view ViewEntraGroupSyncRun
GET /admin/entra-group-sync-runs filament.admin.resources.entra-group-sync-runs.index ListEntraGroupSyncRuns
GET /admin/t/{tenant}/restore-runs filament.tenant.resources.restore-runs.index ListRestoreRuns
GET /admin/t/{tenant}/restore-runs/create filament.tenant.resources.restore-runs.create CreateRestoreRun
GET /admin/t/{tenant}/restore-runs/{record} filament.tenant.resources.restore-runs.view ViewRestoreRun

Redirect Logic

For dropped resources (inventory sync, entra group sync):

// Register in routes/web.php after Resources are deleted
Route::get('/admin/t/{tenant}/inventory-sync-runs/{record}', function ($tenant, $record) {
    $opRunId = DB::table('inventory_sync_runs')
        ->where('id', $record)->value('operation_run_id');
    if ($opRunId) {
        return redirect()->route('admin.operations.view', ['run' => $opRunId]);
    }
    abort(404);
});

// List pages → redirect to Operations index
Route::get('/admin/t/{tenant}/inventory-sync-runs', fn() =>
    redirect()->route('admin.operations.index'));

// Same pattern for entra-group-sync-runs

Constraint: Redirect must NEVER create new OperationRun rows — only lookup + redirect.

For backup_schedule_runs: No direct URL exists (only RelationManager modal), so no redirect needed.

For restore_runs: Not dropped — resource stays. No redirect needed.


6) Drop Plan

Tables to Drop (in order)

# Table Preconditions Migration name
1 entra_group_sync_runs All rows have operation_run_id ≠ NULL; no inbound FKs drop_entra_group_sync_runs_table
2 backup_schedule_runs All rows have operation_run_id ≠ NULL; no inbound FKs drop_backup_schedule_runs_table
3 inventory_sync_runs All rows have operation_run_id ≠ NULL; drift FKs migrated to operation_run_id drop_inventory_sync_runs_table

NOT dropped: restore_runs (domain entity), operation_runs (canonical).

Preconditions per Table

entra_group_sync_runs

  • Backfill complete: SELECT COUNT(*) FROM entra_group_sync_runs WHERE operation_run_id IS NULL = 0
  • Verify count matches: SELECT COUNT(*) FROM entra_group_sync_runsSELECT COUNT(*) FROM operation_runs WHERE type = 'directory_groups.sync' AND context->>'legacy'->>'source' = 'entra_group_sync_runs'
  • No code writes to table: arch guard test passes (grep -r 'EntraGroupSyncRun' app/ | wc -l = 0, excluding model file)
  • Filament resource deleted
  • Redirect route registered
  • DB snapshot taken

backup_schedule_runs

  • Backfill complete: SELECT COUNT(*) FROM backup_schedule_runs WHERE operation_run_id IS NULL = 0
  • RunBackupScheduleJob writes only to operation_runs
  • TenantpilotReconcileBackupScheduleOperationRuns command removed/archived
  • ApplyBackupScheduleRetentionJob reads from operation_runs
  • DB snapshot taken

inventory_sync_runs

  • Backfill complete: all rows linked
  • Drift FK migration complete: findings.*_run_id + inventory_items.last_seen_run_idoperation_run_id columns
  • InventorySyncService writes only to operation_runs
  • All Drift services read from OperationRun
  • DB snapshot taken

Release Gates

Each drop migration should:

  1. Check precondition (backfill count) in up() — abort if unlinked rows exist
  2. Create backup table _archive_{table} before dropping (for rollback)
  3. Drop FK constraints before dropping table
  4. Be reversible: down() recreates table from archive

7) Hotspot Files (Top 15)

# File Lines Impact Why it's hot
1 app/Filament/Resources/RestoreRunResource.php 1,980 High Largest resource; full wizard, execution tracking, must decouple status reads
2 app/Jobs/RunBackupScheduleJob.php ~1,035 High Heavy schedule→BackupScheduleRun writer; must rewrite to OpRun-only
3 app/Services/OperationRunService.php 808 Medium Canonical service; backfill must use this; no breaking changes allowed
4 app/Filament/Resources/OperationRunResource.php 483 Medium Canonical viewer; must absorb data previously shown in legacy resources
5 app/Services/Inventory/InventorySyncService.php ~100 Medium Creates InventorySyncRun; must rewrite to OpRun-only
6 app/Jobs/EntraGroupSyncJob.php ~227 Medium Creates EntraGroupSyncRun; must rewrite to OpRun-only
7 app/Filament/Resources/InventorySyncRunResource.php 231 Low Will be deleted entirely
8 app/Jobs/ExecuteRestoreRunJob.php ~155 Medium Bridges RestoreRun↔OpRun; sync logic must be verified
9 app/Filament/Resources/EntraGroupSyncRunResource.php 168 Low Will be deleted entirely
10 app/Filament/Pages/Monitoring/Operations.php 149 Low Must ensure all legacy run data is visible here after migration
11 app/Filament/Pages/Operations/TenantlessOperationRunViewer.php 142 Low Canonical viewer page; needs "related links" for all legacy sources
12 app/Filament/Widgets/Inventory/InventoryKpiHeader.php 163 Medium Reads InventorySyncRun; must switch to OpRun queries
13 app/Listeners/SyncRestoreRunToOperationRun.php ~21 Low Existing bridge; must be verified/kept during transition
14 app/Services/AdapterRunReconciler.php ~253 Medium Reconciles RestoreRun→OpRun; may be simplified or removed
15 app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php ~130 Low Reconcile command; kept during transition, then archived

8) Test Impact Map

Tests That WILL Break (must be updated/deleted)

# Test File Legacy Model Action Required
1 tests/Feature/Filament/InventorySyncRunResourceTest.php InventorySyncRun Delete
2 tests/Feature/Filament/EntraGroupSyncRunResourceTest.php EntraGroupSyncRun Delete
3 tests/Feature/Filament/InventoryPagesTest.php InventorySyncRunResource URL Update URLs
4 tests/Feature/Filament/InventoryHubDbOnlyTest.php InventorySyncRunResource URL Update URLs
5 tests/Feature/Guards/ActionSurfaceContractTest.php InventorySyncRunResource Remove references
6 tests/Feature/Operations/LegacyRunRedirectTest.php Both sync resources Update to test redirect routes
7 tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php BackupScheduleRun Delete or convert
8 tests/Feature/Console/PurgeNonPersistentDataCommandTest.php BackupScheduleRun + RestoreRun Update to remove legacy refs
9 tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php EntraGroupSyncRunResource Delete
10 tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php EntraGroupSyncRun counts Update assertions
11 tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php EntraGroupSyncRun counts Update assertions
12 tests/Feature/RunStartAuthorizationTest.php All legacy models Update to assert OpRun creation

Tests That STAY (RestoreRun is domain entity):

All 20 RestoreRun test files survive because RestoreRun persists. But tests that check RestoreRun.status directly may need to verify via OperationRun instead.

Tests That STAY (Drift uses InventorySyncRun):

15 Drift test files — until the Drift FK migration is complete, these stay. After migration, they update to use OperationRun factories.

3 New Tests Proposed

Test 1: Backfill Idempotency

it('backfill is idempotent — running twice produces the same operation_run links', function () {
    // Create legacy rows, run backfill command, verify links
    // Run backfill again, verify no duplicate OperationRuns created
    // Assert operation_run_id unchanged on legacy rows
});

Test 2: Verify Counts Match

it('all legacy runs are linked to exactly one operation run after backfill', function () {
    // For each legacy table:
    // Assert COUNT(*) WHERE operation_run_id IS NULL = 0
    // Assert COUNT(DISTINCT operation_run_id) = COUNT(*)
    // Assert OpRun count for type matches legacy count
});

Test 3: Redirect Resolution

it('legacy run view URLs redirect to canonical operation run viewer', function () {
    $syncRun = InventorySyncRun::factory()->create(['operation_run_id' => $opRun->id]);

    $this->get("/admin/t/{$tenant->external_id}/inventory-sync-runs/{$syncRun->id}")
        ->assertRedirect(route('admin.operations.view', ['run' => $opRun->id]));
});

it('returns 404 when legacy run has no operation_run_id', function () {
    $syncRun = InventorySyncRun::factory()->create(['operation_run_id' => null]);

    $this->get("/admin/t/{$tenant->external_id}/inventory-sync-runs/{$syncRun->id}")
        ->assertNotFound();
});

9) Graph/HTTP Call Sites in UI (Guardrail Audit)

Run-related rendering has ZERO Graph/HTTP calls.

The only Graph call sites in Filament UI are in TenantResource.php (RBAC setup modal):

Call Site File Lines Risk
searchRoleDefinitionsDelegated() app/Filament/Resources/TenantResource.php L1267-L1271 HIGH (keystroke-triggered)
groupSearchOptions() (scope_group_id) app/Filament/Resources/TenantResource.php L1387-L1395 HIGH (keystroke-triggered)
groupSearchOptions() (existing_group_id) app/Filament/Resources/TenantResource.php L1387-L1395 HIGH (keystroke-triggered)

These are not run-related and are not in scope for Spec 087, but documented for completeness.


10) Search Queries Used

Key ripgrep/search queries used to produce this report:

rg 'create_.*runs.*_table|_runs_table' database/migrations/
rg 'operation_run' database/migrations/
rg 'InventorySyncRun' app/ tests/ --files-with-matches
rg 'EntraGroupSyncRun' app/ tests/ --files-with-matches
rg 'BackupScheduleRun' app/ tests/ --files-with-matches
rg 'RestoreRun' app/ tests/ --files-with-matches
rg 'BulkOperationRun' app/ tests/ --files-with-matches
rg 'getSearchResultsUsing|getOptionLabelUsing' app/Filament/
rg 'GraphClient|Http::' app/Filament/
rg 'OperationRunType' app/ --files-with-matches
rg '::create\(' app/Services/Inventory/ app/Jobs/EntraGroupSyncJob.php app/Jobs/RunBackupScheduleJob.php
find app/Models -name '*Run*.php'
find app/Filament -name '*Run*' -o -name '*Operation*'
find tests/ -name '*Run*' -o -name '*run*'

Phase 0: Audit & verify current operation_run_id fill rates (SQL queries above)
          ↓
Phase 1: Backfill command (artisan tenantpilot:backfill-legacy-runs)
         - Process each table independently, idempotent
         - Write operation_run_id back to legacy rows
         - Verify counts
          ↓
Phase 2: Cleanup Code — Quick Wins
         2a) Delete EntraGroupSyncRunResource + tests + badge + policy
         2b) Register redirect routes
         2c) Update guard tests
          ↓
Phase 3: Cleanup Code — Medium
         3a) Rewrite EntraGroupSyncJob to use OperationRun only
         3b) Delete InventorySyncRunResource + tests + badge
         3c) Migrate Drift FKs (new migration)
         3d) Rewrite InventorySyncService to use OperationRun only
         3e) Update InventoryKpiHeader widget
          ↓
Phase 4: Cleanup Code — Heavy
         4a) Rewrite RunBackupScheduleJob to use OperationRun only
         4b) Delete BackupScheduleRunsRelationManager + blade modal + badge
         4c) Delete reconcile command
          ↓
Phase 5: RestoreRun Decoupling
         5a) Deprecate status/timestamp reads from RestoreRun → read from OpRun
         5b) Keep RestoreRun entity (wizard state, results, group_mapping)
         5c) Simplify/remove AdapterRunReconciler
          ↓
Phase 6: Drop Migrations (with gates)
         6a) Drop entra_group_sync_runs (quick — no inbound FKs)
         6b) Drop backup_schedule_runs (medium — verify retention logic)
         6c) Drop inventory_sync_runs (last — after drift FK migration)
          ↓
Phase 7: Final Cleanup
         7a) Delete legacy models (InventorySyncRun, EntraGroupSyncRun, BackupScheduleRun)
         7b) Delete factories
         7c) Remove BadgeDomain enum cases
         7d) Update NoLegacyBulkOperationsTest guard scope to cover all dropped models