40 KiB
Spec 087 — Legacy Runs Removal: Analysis Report
Report Date: 2026-02-11 Scope: Analyse → Backfill → Cleanup → Drop for all non-canonical run tracking tables. Canonical Run System:
operation_runstable +OperationRunmodel +/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
restore_runsis a domain entity, not just run tracking — it holdsrequested_items,preview,results,group_mapping,metadata. Its execution tracking (status, timestamps, counts) should come fromoperation_runs, but the entity itself must survive. It cannot be dropped.- Drift detection depends on
InventorySyncRunas 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 tooperation_run_id. - BackupScheduleRun is written by
RunBackupScheduleJobon every scheduled execution — the scheduler produces ~N rows/minute where N = active schedules. Backfill must handle high volume. - 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.ViewInventorySyncRunandEntraGroupSyncRunResource.ViewEntraGroupSyncRunalready redirect to canonicalTenantlessOperationRunViewerwhenoperation_run_idis 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 = NULLand 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 columnfindings.baseline_operation_run_idfindings.current_run_id→ new columnfindings.current_operation_run_idinventory_items.last_seen_run_id→ new columninventory_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:
typedistinction (run_now vs retry vs scheduled) is lost for historical rows — all backfilled asbackup_schedule.scheduled.summaryJSON 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_byis a string name, not an FK. Backfill can attemptUser::where('name', requested_by)->first()but may not resolve.summary_counts—resultsJSON structure varies; needs defensive parsing.
Post-backfill: Set restore_runs.operation_run_id = created_operation_run.id.
Existing sync infrastructure:
RestoreRunObserveralready syncs RestoreRun → OperationRun on create/update viaSyncRestoreRunToOperationRun.AdapterRunReconcilerreconciles RestoreRun status → OperationRun via scheduledReconcileAdapterRunsJob(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 auditRunBackupScheduleJob— needs audit (reconcile command exists for this)RestoreRunObserver→SyncRestoreRunToOperationRun— actively syncs (confirmed)
SQL to verify:
SELECT 'inventory_sync_runs' AS tbl, COUNT(*) AS total,
COUNT(operation_run_id) AS linked, COUNT(*) - COUNT(operation_run_id) AS unlinked
FROM inventory_sync_runs
UNION ALL
SELECT 'entra_group_sync_runs', COUNT(*), COUNT(operation_run_id), COUNT(*) - COUNT(operation_run_id)
FROM entra_group_sync_runs
UNION ALL
SELECT 'backup_schedule_runs', COUNT(*), COUNT(operation_run_id), COUNT(*) - COUNT(operation_run_id)
FROM backup_schedule_runs
UNION ALL
SELECT 'restore_runs', COUNT(*), COUNT(operation_run_id), COUNT(*) - COUNT(operation_run_id)
FROM restore_runs;
4) Cleanup Plan
4a) Code Removal
Phase 1: EntraGroupSyncRunResource (Quick Win)
| What | File | Action |
|---|---|---|
| Resource | app/Filament/Resources/EntraGroupSyncRunResource.php (168 lines) |
Delete |
| List page | …/Pages/ListEntraGroupSyncRuns.php (11 lines) |
Delete |
| View page | …/Pages/ViewEntraGroupSyncRun.php (24 lines) |
Delete |
| Policy | app/Policies/EntraGroupSyncRunPolicy.php |
Delete |
| Badge class | app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php |
Delete |
| Badge enum case | BadgeDomain::EntraGroupSyncRun → remove case |
Edit |
| Badge catalog mapping | BadgeCatalog → remove mapping |
Edit |
| Notification link | RunStatusChangedNotification.php → remove entra branch |
Edit |
Phase 2: InventorySyncRunResource (Medium — drift dependency)
| What | File | Action |
|---|---|---|
| Resource | app/Filament/Resources/InventorySyncRunResource.php (231 lines) |
Delete |
| List page | …/Pages/ListInventorySyncRuns.php (19 lines) |
Delete |
| View page | …/Pages/ViewInventorySyncRun.php (24 lines) |
Delete |
| Badge class | app/Support/Badges/Domains/InventorySyncRunStatusBadge.php |
Delete |
| Badge enum case | BadgeDomain::InventorySyncRun → remove case |
Edit |
| Badge catalog mapping | BadgeCatalog → remove mapping |
Edit |
| KPI widget refs | InventoryKpiHeader.php → update to query operation_runs only |
Edit |
| Nav item | Remove "Sync History" from Inventory cluster | — |
Prerequisite: Drift tables must migrate FKs from inventory_sync_runs.id → operation_runs.id:
findings.baseline_run_id+findings.current_run_id→findings.baseline_operation_run_id+findings.current_operation_run_idinventory_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):
// 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 RunBackupScheduleJobwrites only tooperation_runsTenantpilotReconcileBackupScheduleOperationRunscommand removed/archivedApplyBackupScheduleRetentionJobreads fromoperation_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_idcolumns InventorySyncServicewrites only tooperation_runs- All Drift services read from
OperationRun - DB snapshot taken
Release Gates
Each drop migration should:
- Check precondition (backfill count) in
up()— abort if unlinked rows exist - Create backup table
_archive_{table}before dropping (for rollback) - Drop FK constraints before dropping table
- Be reversible:
down()recreates table from archive
7) Hotspot Files (Top 15)
| # | File | Lines | Impact | Why it's hot |
|---|---|---|---|---|
| 1 | app/Filament/Resources/RestoreRunResource.php |
1,980 | High | Largest resource; full wizard, execution tracking, must decouple status reads |
| 2 | app/Jobs/RunBackupScheduleJob.php |
~1,035 | High | Heavy schedule→BackupScheduleRun writer; must rewrite to OpRun-only |
| 3 | app/Services/OperationRunService.php |
808 | Medium | Canonical service; backfill must use this; no breaking changes allowed |
| 4 | app/Filament/Resources/OperationRunResource.php |
483 | Medium | Canonical viewer; must absorb data previously shown in legacy resources |
| 5 | app/Services/Inventory/InventorySyncService.php |
~100 | Medium | Creates InventorySyncRun; must rewrite to OpRun-only |
| 6 | app/Jobs/EntraGroupSyncJob.php |
~227 | Medium | Creates EntraGroupSyncRun; must rewrite to OpRun-only |
| 7 | app/Filament/Resources/InventorySyncRunResource.php |
231 | Low | Will be deleted entirely |
| 8 | app/Jobs/ExecuteRestoreRunJob.php |
~155 | Medium | Bridges RestoreRun↔OpRun; sync logic must be verified |
| 9 | app/Filament/Resources/EntraGroupSyncRunResource.php |
168 | Low | Will be deleted entirely |
| 10 | app/Filament/Pages/Monitoring/Operations.php |
149 | Low | Must ensure all legacy run data is visible here after migration |
| 11 | app/Filament/Pages/Operations/TenantlessOperationRunViewer.php |
142 | Low | Canonical viewer page; needs "related links" for all legacy sources |
| 12 | app/Filament/Widgets/Inventory/InventoryKpiHeader.php |
163 | Medium | Reads InventorySyncRun; must switch to OpRun queries |
| 13 | app/Listeners/SyncRestoreRunToOperationRun.php |
~21 | Low | Existing bridge; must be verified/kept during transition |
| 14 | app/Services/AdapterRunReconciler.php |
~253 | Medium | Reconciles RestoreRun→OpRun; may be simplified or removed |
| 15 | app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php |
~130 | Low | Reconcile command; kept during transition, then archived |
8) Test Impact Map
Tests That WILL Break (must be updated/deleted)
| # | Test File | Legacy Model | Action Required |
|---|---|---|---|
| 1 | tests/Feature/Filament/InventorySyncRunResourceTest.php |
InventorySyncRun | Delete |
| 2 | tests/Feature/Filament/EntraGroupSyncRunResourceTest.php |
EntraGroupSyncRun | Delete |
| 3 | tests/Feature/Filament/InventoryPagesTest.php |
InventorySyncRunResource URL | Update URLs |
| 4 | tests/Feature/Filament/InventoryHubDbOnlyTest.php |
InventorySyncRunResource URL | Update URLs |
| 5 | tests/Feature/Guards/ActionSurfaceContractTest.php |
InventorySyncRunResource | Remove references |
| 6 | tests/Feature/Operations/LegacyRunRedirectTest.php |
Both sync resources | Update to test redirect routes |
| 7 | tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php |
BackupScheduleRun | Delete or convert |
| 8 | tests/Feature/Console/PurgeNonPersistentDataCommandTest.php |
BackupScheduleRun + RestoreRun | Update to remove legacy refs |
| 9 | tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php |
EntraGroupSyncRunResource | Delete |
| 10 | tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php |
EntraGroupSyncRun counts | Update assertions |
| 11 | tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php |
EntraGroupSyncRun counts | Update assertions |
| 12 | tests/Feature/RunStartAuthorizationTest.php |
All legacy models | Update to assert OpRun creation |
Tests That STAY (RestoreRun is domain entity):
All 20 RestoreRun test files survive because RestoreRun persists. But tests that check RestoreRun.status directly may need to verify via OperationRun instead.
Tests That STAY (Drift uses InventorySyncRun):
15 Drift test files — until the Drift FK migration is complete, these stay. After migration, they update to use OperationRun factories.
3 New Tests Proposed
Test 1: Backfill Idempotency
it('backfill is idempotent — running twice produces the same operation_run links', function () {
// Create legacy rows, run backfill command, verify links
// Run backfill again, verify no duplicate OperationRuns created
// Assert operation_run_id unchanged on legacy rows
});
Test 2: Verify Counts Match
it('all legacy runs are linked to exactly one operation run after backfill', function () {
// For each legacy table:
// Assert COUNT(*) WHERE operation_run_id IS NULL = 0
// Assert COUNT(DISTINCT operation_run_id) = COUNT(*)
// Assert OpRun count for type matches legacy count
});
Test 3: Redirect Resolution
it('legacy run view URLs redirect to canonical operation run viewer', function () {
$syncRun = InventorySyncRun::factory()->create(['operation_run_id' => $opRun->id]);
$this->get("/admin/t/{$tenant->external_id}/inventory-sync-runs/{$syncRun->id}")
->assertRedirect(route('admin.operations.view', ['run' => $opRun->id]));
});
it('returns 404 when legacy run has no operation_run_id', function () {
$syncRun = InventorySyncRun::factory()->create(['operation_run_id' => null]);
$this->get("/admin/t/{$tenant->external_id}/inventory-sync-runs/{$syncRun->id}")
->assertNotFound();
});
9) Graph/HTTP Call Sites in UI (Guardrail Audit)
Run-related rendering has ZERO Graph/HTTP calls. ✅
The only Graph call sites in Filament UI are in TenantResource.php (RBAC setup modal):
| Call Site | File | Lines | Risk |
|---|---|---|---|
searchRoleDefinitionsDelegated() |
app/Filament/Resources/TenantResource.php |
L1267-L1271 | HIGH (keystroke-triggered) |
groupSearchOptions() (scope_group_id) |
app/Filament/Resources/TenantResource.php |
L1387-L1395 | HIGH (keystroke-triggered) |
groupSearchOptions() (existing_group_id) |
app/Filament/Resources/TenantResource.php |
L1387-L1395 | HIGH (keystroke-triggered) |
These are not run-related and are not in scope for Spec 087, but documented for completeness.
10) Search Queries Used
Key ripgrep/search queries used to produce this report:
rg 'create_.*runs.*_table|_runs_table' database/migrations/
rg 'operation_run' database/migrations/
rg 'InventorySyncRun' app/ tests/ --files-with-matches
rg 'EntraGroupSyncRun' app/ tests/ --files-with-matches
rg 'BackupScheduleRun' app/ tests/ --files-with-matches
rg 'RestoreRun' app/ tests/ --files-with-matches
rg 'BulkOperationRun' app/ tests/ --files-with-matches
rg 'getSearchResultsUsing|getOptionLabelUsing' app/Filament/
rg 'GraphClient|Http::' app/Filament/
rg 'OperationRunType' app/ --files-with-matches
rg '::create\(' app/Services/Inventory/ app/Jobs/EntraGroupSyncJob.php app/Jobs/RunBackupScheduleJob.php
find app/Models -name '*Run*.php'
find app/Filament -name '*Run*' -o -name '*Operation*'
find tests/ -name '*Run*' -o -name '*run*'
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