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