# Implementation Plan: Provider-Missing Policy Visibility & Restore Continuity v1 **Branch**: `261-provider-missing-policy-visibility` | **Date**: 2026-05-01 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from [spec.md](spec.md) ## Summary Prepare one bounded policy-truth correction that separates provider-missing presence from intentional local suppression without opening the broader workspace or tenant lifecycle program. The narrow implementation path is to add `missing_from_provider_at` on `policies`, reserve `ignored_at` for explicit user suppression only, stop sync and type-filter logic from writing `ignored_at` for provider absence, keep missing policies visible as historical local truth, block current-state backup/export actions for missing policies, and preserve restore continuity for historical `BackupItem` records. Repo truth already exposes the exact seams this slice needs: [../../apps/platform/app/Models/Policy.php](../../apps/platform/app/Models/Policy.php) has `ignored_at` but no provider-presence field; [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) currently clears `ignored_at` on `updateOrCreate()` and marks reclassified or filtered records ignored; [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) only distinguishes active versus ignored; [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) selects backup candidates with `whereNull('ignored_at')`; [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) filters restore-option continuity through the same ignored check; and current tests such as [../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php](../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php) codify the conflated behavior that this spec intentionally corrects. V1 therefore stays narrow: no new panel or provider, no global-search change, no new asset registration, no `SoftDeletes`, no lifecycle engine, no provider-deleted distinction, no purge flow, no multi-object rollout, and no cross-tenant workflow. The work is a policy-only truth correction that reuses the existing policy, backup, restore, audit, and sync seams. ## Technical Context **Language/Version**: PHP 8.4, Laravel 12 **Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `PolicySyncService`, `BackupService`, `PolicyResource`, `RestoreRunResource`, policy bulk jobs, audit infrastructure, and policy badge helpers **Storage**: PostgreSQL via existing `policies`, `policy_versions`, `backup_sets`, `backup_items`, `restore_runs`, `operation_runs`, and `audit_logs`; one nullable timestamp is planned on `policies` **Testing**: Pest v4 feature and focused unit coverage **Validation Lanes**: fast-feedback, confidence **Target Platform**: Laravel monolith in `apps/platform`, existing admin and tenant-scoped Filament surfaces only **Project Type**: Web application (Laravel monolith with Filament resources and pages) **Performance Goals**: keep provider-missing detection inside existing sync result processing, avoid extra Graph calls, keep policy list and restore option queries eager-load safe, and avoid widening queue or asset cost **Constraints**: no `SoftDeletes`, no `provider_deleted_at`, no new lifecycle registry/service, no panel/provider changes, no asset strategy change, no new globally searchable resource, and no customer-facing portal work **Scale/Scope**: 1 model/migration, 1 sync service cluster, 1 policy resource, 1 backup service seam, 1 restore selection seam, bounded audit updates, and targeted policy/backup/restore tests ## Likely Affected Repo Surfaces - [../../apps/platform/app/Models/Policy.php](../../apps/platform/app/Models/Policy.php) for `missing_from_provider_at`, casts, scopes, and derived provider-presence helpers. - [../../apps/platform/database/migrations](../../apps/platform/database/migrations) for one migration adding `missing_from_provider_at` to `policies`. - [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) for provider-missing detection, reappearance clearing, subtype filtering, and reclassification semantics. - [../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php](../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php) and existing unignore flows as the retained local-suppression path that must stay on `ignored_at` only. - [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) plus its `Pages/ListPolicies.php` and `Pages/ViewPolicy.php` surfaces for badges, filters, helper copy, and action availability. - [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) and current backup/export actions that choose policies from current local truth. - [../../apps/platform/app/Filament/Resources/BackupSetResource.php](../../apps/platform/app/Filament/Resources/BackupSetResource.php) or related picker helpers if the current backup-set policy picker needs provider-missing reason text. - [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) for restore item option continuity and provider-missing messaging. - [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) and the existing audit logger path for provider-missing and reappeared events if no existing audit action is sufficient. - [../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php](../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php), [../../apps/platform/tests/Feature/PolicySyncServiceTest.php](../../apps/platform/tests/Feature/PolicySyncServiceTest.php), [../../apps/platform/tests/Feature/BulkDeletePoliciesTest.php](../../apps/platform/tests/Feature/BulkDeletePoliciesTest.php), [../../apps/platform/tests/Feature/BulkUnignorePoliciesTest.php](../../apps/platform/tests/Feature/BulkUnignorePoliciesTest.php), [../../apps/platform/tests/Feature/BulkExportToBackupTest.php](../../apps/platform/tests/Feature/BulkExportToBackupTest.php), [../../apps/platform/tests/Feature/Filament/BackupCreationTest.php](../../apps/platform/tests/Feature/Filament/BackupCreationTest.php), [../../apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php](../../apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php), [../../apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php](../../apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php), [../../apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php](../../apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php), and [../../apps/platform/tests/Unit/Badges/PolicyBadgesTest.php](../../apps/platform/tests/Unit/Badges/PolicyBadgesTest.php) for bounded regression proof. ## Policy Presence / Sync Fit - Treat provider presence as a property of the local policy row, not a second entity or lifecycle engine. The current narrow truth is `ignored_at` plus `missing_from_provider_at`. - The derived state family stays bounded: - active: `ignored_at = null` and `missing_from_provider_at = null` - ignored locally: `ignored_at != null` and `missing_from_provider_at = null` - provider missing: `ignored_at = null` and `missing_from_provider_at != null` - ignored locally + provider missing: both timestamps present, with local suppression as the primary local-control meaning - Policy list filtering stays deterministic: the combined state belongs to both the `ignored` and `provider_missing` filter views so operators can reach it from either workflow. - [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) must stop blindly resetting `ignored_at` on `updateOrCreate()`. - Any sync path that currently uses `ignored_at` for subtype filtering or reclassification must instead either reclassify the row to a supported canonical type or mark `missing_from_provider_at` when the object falls out of the supported provider-backed result set. - Reappearance must clear `missing_from_provider_at` without automatically clearing `ignored_at`. - Existing local delete/restore semantics in `ignore()` and `unignore()` remain unchanged and stay rooted in `ignored_at`. ## Backup / Restore Continuity Fit - [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) and any current backup/export picker that selects policies for fresh capture must keep provider-missing policies visible but blocked from current-state capture. - When both `ignored_at` and `missing_from_provider_at` are present, current backup/export uses `provider_missing` as the primary blocked reason because the product cannot truthfully claim fresh provider-backed capture is possible. - Existing historical `BackupItem` truth remains authoritative for restore continuity. The system should not require a live provider-present policy row to keep a historical restore item selectable. - [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) should keep continuity messaging secondary and truthful: the backup item is historical and selectable, while the live policy is currently provider-missing. - Current-state backup blocking should prefer calm explanatory copy and zero new run creation. Historical restore remains a separate allowed path. ## UI / Filament & Livewire Fit - Filament remains v5 on Livewire v4. No new panel or provider is planned, and provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php). - [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) already exposes a list and a view page and explicitly sets `$isGloballySearchable = false`; this feature does not change global-search posture. - Keep the existing policy resource as the primary current-state decision surface. Do not add a new ghost-policy page, special diagnostics page, or alternate provider-missing registry. - Any destructive action that survives this slice remains the existing local ignore/restore family and must continue to use confirmation and server-side authorization. This feature does not introduce a new destructive action. - Asset strategy remains unchanged. No new Filament assets or deployment `filament:assets` steps are expected beyond the existing platform process. - If status badges or helper copy expand, reuse the existing badge and policy presentation seam instead of adding local ad-hoc mappings. ## RBAC / Authorization Fit - Workspace membership and tenant entitlement remain the first boundary. Nothing in provider-missing semantics changes `404` versus `403` scope handling. - Existing policy, backup, and restore capabilities remain authoritative. No new capability string or role check is justified. - Backup blocking for provider-missing policies is business-state gating, not authorization. In-scope operators still see the policy and the historical restore path; out-of-scope actors still receive deny-as-not-found. - Any reused detail or action route must keep current server-side authorization checks and confirmation behavior. ## Audit / Logging Fit - Reuse the existing audit infrastructure; do not create a second lifecycle-log family. - The narrow expectation is one explicit audit event when a previously observed policy becomes provider-missing and one when it later reappears. - Audit payload should stay tenant-safe and minimal: policy id, policy external id, canonical policy type, transition timestamp, and sync source context are sufficient. - No new `OperationRun` type is planned. Current sync actions may continue to use the existing run UX only when a sync actually starts. ## Data & Query Fit - Add one nullable timestamp column: `policies.missing_from_provider_at`. - Keep `workspace_id` and `tenant_id` as required anchors on policy truth; this slice does not weaken tenant ownership. - Prefer deriving provider-presence state via model helpers or narrow query scopes instead of storing a second status enum. - Update current policy-eligibility queries to use both `ignored_at` and `missing_from_provider_at` where current-state capture must stay live-only. - Combined-state filter membership is derived, not stored: ignored views include `ignored_locally_provider_missing`, provider-missing views include `ignored_locally_provider_missing`, and current backup/export blocked-reason precedence resolves to `provider_missing` when both timestamps are present. - Keep restore queries historical-first: if a `BackupItem` remains eligible, provider-missing context is descriptive rather than disqualifying unless a separate restore rule already blocks it. - Avoid adding speculative `provider_state_reason` or lifecycle-history tables in v1. ## UI / Surface Guardrail Plan - **Guardrail scope**: changed surfaces - **Native vs custom classification summary**: native Filament - **Shared-family relevance**: status messaging, badges, row/detail actions, backup eligibility messaging, restore continuity descriptions, and audit labels - **State layers in scope**: page, detail, action modal, picker description, query/model state - **Audience modes in scope**: operator-MSP, support-platform - **Decision/diagnostic/raw hierarchy plan**: decision-first current policy meaning, diagnostics-second sync/history context, raw/support third and only where existing surfaces already expose it - **Raw/support gating plan**: no new raw payload disclosure; any provider detail remains secondary on existing policy/version evidence surfaces only - **One-primary-action / duplicate-truth control**: policy list/detail own current-state meaning; backup flows own current capture eligibility; restore flows own historical continuity; no surface should restate another surface's primary truth as an equal-priority summary - **Handling modes by drift class or surface**: review-mandatory - **Repository-signal treatment**: review-mandatory because this slice changes a shared state vocabulary across policy, backup, and restore seams - **Special surface test profiles**: standard-native-filament, shared-detail-family - **Required tests or manual smoke**: functional-core, state-contract - **Exception path and spread control**: none planned; any attempt to add a new provider-missing page, new lifecycle registry, or new panel becomes exception-required drift - **Active feature PR close-out entry**: Guardrail / State Vocabulary ## Shared Pattern & System Fit - **Cross-cutting feature marker**: yes - **Systems touched**: `Policy`, `PolicySyncService`, `PolicyResource`, `BackupService`, `BackupSetResource` picker helpers if needed, `RestoreRunResource`, audit action ids, and the policy badge/presentation seam - **Shared abstractions reused**: existing policy model truth, shared policy filters/actions, existing backup selection path, existing restore-item option builders, existing audit logger, and existing badge rendering tests - **New abstraction introduced? why?**: none planned. If implementation needs a tiny provider-presence helper, keep it on `Policy` or an existing policy-presenter seam instead of creating a new lifecycle framework. - **Why the existing abstraction was sufficient or insufficient**: current shared seams already own the right surfaces. They are simply fed by the wrong state vocabulary today. - **Bounded deviation / spread control**: none planned. Any proposal for a separate ghost-policy registry, dedicated diagnostics page, or generic managed-object lifecycle layer should be deferred to the broader lifecycle taxonomy follow-up. ## OperationRun UX Impact - **Touches OperationRun start/completion/link UX?**: yes, by narrowing when current backup/export actions may start and by leaving sync-retry behavior on the existing shared path - **Central contract reused**: existing policy sync and backup/export start UX - **Delegated UX behaviors**: allowed sync actions keep the current queued toast/link behavior; blocked provider-missing current backup/export actions stop before run creation and explain why locally - **Surface-owned behavior kept local**: policy, backup, and restore surfaces own provider-missing messaging only; they do not create a new run type or monitoring surface - **Queued DB-notification policy**: unchanged - **Terminal notification path**: unchanged - **Exception path**: none ## Provider Boundary & Portability Fit - **Shared provider/platform boundary touched?**: yes - **Provider-owned seams**: Microsoft Graph list results, subtype inclusion/exclusion, and canonical-type mapping remain provider-owned evidence in sync - **Platform-core seams**: local suppression truth, provider-missing truth, current backup eligibility, historical restore continuity, and operator-facing policy vocabulary - **Neutral platform terms / contracts preserved**: `provider missing`, `ignored locally`, `current backup eligibility`, and `restore continuity` - **Retained provider-specific semantics and why**: subtype filtering remains provider-specific inside sync because it already reflects supported endpoint scope - **Bounded extraction or follow-up path**: `document-in-feature` for the narrow provider-presence wording now; `follow-up-spec` later if the broader lifecycle taxonomy or explicit provider-deleted distinction is approved ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - Inventory-first / snapshot truth: PASS. The slice keeps historical `PolicyVersion` and `BackupItem` truth authoritative and does not invent a new shadow registry. - Read/write separation: PASS. No new destructive flow, purge path, or customer-facing mutation surface is introduced. - Graph contract path: PASS. The slice reuses the existing provider sync boundary and adds no new Graph family. - Deterministic capabilities: PASS. Existing capability registries remain canonical. - Workspace and tenant isolation: PASS. `workspace_id` and `tenant_id` remain required anchors on policy truth. - RBAC-UX plane separation: PASS. The slice stays inside existing admin and tenant-scoped policy, backup, and restore surfaces. - Destructive confirmation standard: PASS. Existing local ignore/restore destructive actions remain the only destructive family and keep confirmation plus server-side authorization. - Global search safety: PASS. `PolicyResource` is already globally disabled and remains so. - OperationRun / Ops-UX: PASS. No new run type is introduced; blocked backup/export states stop before run creation. - Data minimization: PASS. Provider-missing copy stays calm and decision-first; no new raw payload exposure is added. - Test governance (TEST-GOV-001): PASS. Planned proof stays inside focused feature and unit lanes. - Proportionality / no premature abstraction: PASS. One timestamp and derived state family are the narrowest defensible shape. - Persisted truth (PERSIST-001): PASS. One field is added to existing policy truth; no new table or artifact family is created. - Behavioral state (STATE-001): PASS. The state family is derived from existing and new timestamps rather than adding a new enum/persistence layer. - Provider boundary (PROV-001): PASS. Provider-specific result interpretation stays in sync; operator-facing language stays platform-neutral. - Filament / Laravel planning contract: PASS. Filament remains v5 on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no panel change is planned, `PolicyResource` global search remains disabled, and no assets are expected. **Gate evaluation**: PASS. - The narrow path is defensible if implementation keeps `ignored_at` user-owned and `missing_from_provider_at` provider-observation-only. - The plan fails the gate if it drifts into `SoftDeletes`, a new lifecycle service, a provider-deleted taxonomy, or multi-object rollout. **Post-design re-check**: PASS. [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), and [contracts/provider-missing-policy-visibility.openapi.yaml](contracts/provider-missing-policy-visibility.openapi.yaml) are present and aligned with the spec package. ## Test Governance Check - **Test purpose / classification by changed surface**: Feature for sync behavior, policy UI, backup eligibility, restore continuity, and authorization continuity; Unit for policy badge/state or narrow derived helper behavior if one is introduced - **Affected validation lanes**: fast-feedback, confidence - **Why this lane mix is the narrowest sufficient proof**: the behavior is server-side and already anchored in existing sync, policy, backup, and restore tests. Focused feature coverage plus one small unit seam is enough without browser cost. - **Narrowest proving command(s)**: - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php tests/Feature/PolicySyncServiceTest.php tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php tests/Feature/PolicySyncStartSurfaceTest.php` - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyProviderMissingUiTest.php tests/Feature/PolicyGeneralViewTest.php tests/Feature/BulkDeletePoliciesTest.php tests/Feature/BulkUnignorePoliciesTest.php tests/Unit/Badges/PolicyBadgesTest.php` - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BulkExportToBackupTest.php tests/Feature/Filament/BackupCreationTest.php tests/Feature/Filament/BackupSetPolicyPickerTableTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php` - **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse existing tenant, policy, backup-set, backup-item, restore-run, and policy-version fixtures - **Expensive defaults or shared helper growth introduced?**: none expected; any new helper should stay policy-local and explicit - **Heavy-family additions, promotions, or visibility changes**: none - **Surface-class relief / special coverage rule**: standard-native-filament relief for policy and backup surfaces; shared-detail-family proof for restore continuity - **Closing validation and reviewer handoff**: rerun the commands above, verify sync never writes `ignored_at` for provider absence, verify missing policies remain visible, verify current backup/export blocks without starting a run, verify historical restore continuity remains selectable, and verify non-members or out-of-scope actors still resolve as `404` - **Budget / baseline / trend follow-up**: none expected beyond small feature-local test additions - **Review-stop questions**: hidden `ignored_at` writes left in sync, blocked backup/export starting a run anyway, restore continuity silently filtered, or provider-specific wording leaking into platform truth - **Escalation path**: `document-in-feature` for bounded wording or audit metadata notes; `follow-up-spec` for broader lifecycle rollout; `reject-or-split` for SoftDeletes or new framework drift - **Active feature PR close-out entry**: Guardrail / State Vocabulary ## Rollout & Risk Controls - Keep policy truth anchored to the existing `policies` row; do not create a side table or ghost-policy registry. - Keep local ignore and provider-missing semantics orthogonal. Sync may clear provider-missing on reappearance, but only an explicit user action clears local ignore. - Keep current-state capture conservative. If the provider cannot currently supply live state for a policy, current backup/export should explain that and stop. - Keep historical restore continuity permissive where `BackupItem` truth already exists. - Keep global search posture, panel registration, and asset strategy unchanged. ## Project Structure ### Documentation (this feature) ```text specs/261-provider-missing-policy-visibility/ ├── checklists/ │ └── requirements.md ├── plan.md ├── research.md ├── data-model.md ├── quickstart.md ├── contracts/ │ └── provider-missing-policy-visibility.openapi.yaml └── tasks.md ``` ### Source Code (repository root) ```text apps/platform/ ├── app/ │ ├── Filament/ │ │ └── Resources/ │ │ ├── BackupSetResource.php │ │ ├── PolicyResource.php │ │ └── RestoreRunResource.php │ ├── Jobs/ │ │ └── Operations/PolicyBulkDeleteWorkerJob.php │ ├── Models/ │ │ └── Policy.php │ ├── Services/ │ │ ├── Audit/ │ │ └── Intune/ │ │ ├── BackupService.php │ │ └── PolicySyncService.php │ └── Support/ │ ├── Audit/AuditActionId.php │ └── Ui/ ├── database/ │ └── migrations/ └── tests/ ├── Feature/ │ ├── Filament/ │ ├── Jobs/ │ └── ... └── Unit/ └── Badges/ ``` ## Complexity Tracking - **New persistence**: 1 nullable timestamp on `policies` - **New derived state family**: 1 provider-presence family layered onto the existing local ignore semantics - **New routes/pages/panels/providers**: none - **New assets**: none - **New queues or long-running operations**: none - **Expected implementation risk**: moderate, because multiple existing tests currently encode the conflated semantics and must be corrected together