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

771 lines
40 KiB
Markdown

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