771 lines
40 KiB
Markdown
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
|
|
```
|