# 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 |
| `RestoreRunObserver` → `SyncRestoreRunToOperationRun` | `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_runs` — **ALREADY 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_runs` → `operation_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_runs` → `operation_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_runs` → `operation_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_runs` → `operation_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_id` — `requested_by` is a string name, not an FK. Backfill can attempt `User::where('name', requested_by)->first()` but may not resolve.
- `summary_counts` — `results` 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).

---

### 3e) Existing `operation_run_id` Link Check

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)
- `RestoreRunObserver` → `SyncRestoreRunToOperationRun` — **actively syncs** (confirmed)

SQL to verify:
```sql
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.id` → `operation_runs.id`:
- `findings.baseline_run_id` + `findings.current_run_id` → `findings.baseline_operation_run_id` + `findings.current_operation_run_id`
- `inventory_items.last_seen_run_id` → `inventory_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):

```php
// 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_runs` ≈ `SELECT 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_id` → `operation_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
```php
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
```php
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
```php
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:

```bash
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*'
```

---

## 11) Implementation Sequence (Recommended)

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