# Implementation Plan: Async “Add Policies” to Backup Set (052) **Branch**: `feat/052-async-add-policies` | **Date**: 2026-01-14 | **Spec**: [specs/052-async-add-policies/spec.md](spec.md) **Input**: Feature specification from `/specs/052-async-add-policies/spec.md` ## Summary Convert Backup Set → Add Policies (“Add selected”) to job-only execution by creating/reusing a `BulkOperationRun`, dispatching a queued job to do Graph/snapshot work, and returning immediately with observable feedback and a “View run” link. ## Technical Context **Language/Version**: PHP 8.4 (Laravel 12) **Admin UI**: Filament v4 + Livewire v3 **Storage**: PostgreSQL **Queue**: Laravel queues (Sail-first locally) **Testing**: Pest v4 **Constraints**: No Graph calls during interactive request/response; tenant isolation; idempotent dedupe; safe error persistence. ## Constitution Check - Inventory-first: this feature updates backup storage (explicit snapshot capture), never “inventory on render”. - Read/write separation: interactive action is “write intent only” (enqueue); job performs DB writes with auditability. - Graph contract path: any Graph reads happen only inside the job via `GraphClientInterface` (indirectly via existing services). - Deterministic capabilities: selection hashing and idempotency key are deterministic. - Tenant isolation: run records and backup set mutations are scoped to the active tenant. - Automation: job has run record lifecycle + counts + failures; dedupe prevents duplicates. - Data minimization: failures and notifications store sanitized messages only. ## Project Structure ### Documentation (this feature) ```text specs/052-async-add-policies/ ├── plan.md ├── spec.md ├── tasks.md └── checklists/ └── requirements.md ``` ### Source Code (planned touch points) ```text app/Livewire/BackupSetPolicyPickerTable.php # “Add selected” handler becomes enqueue-only app/Jobs/AddPoliciesToBackupSetJob.php # new queued job (name may vary) app/Models/BulkOperationRun.php # reused run record app/Services/BulkOperationService.php # reused counters + failure sanitization app/Support/RunIdempotency.php # reused deterministic key builder + active-run lookup app/Filament/Resources/BulkOperationRunResource.php # existing run UI used for “View run” tests/Feature/Filament/BackupSetPolicyPickerTableTest.php # updated for async behavior tests/Feature/* # new/updated tests for idempotency + tenant isolation ``` ## Execution Model ### Action Handler (UI request) Responsibilities (must remain fast): 1. Resolve tenant + user context and authorize “add policies to backup set”. 2. Convert selected policy records into an ID list (no Graph/snapshot work). 3. Compute a deterministic idempotency key using: - tenant id - target backup_set_id - operation type (e.g., `backup_set.add_policies`) - context payload containing (sorted) policy IDs + option flags (include assignments/scope tags/foundations) 4. Reuse an active run if one exists; otherwise create a new `BulkOperationRun` with `resource=backup_set`, `action=add_policies`, `item_ids` = selected policy IDs, `total_items` = count, and `idempotency_key` set. 5. Dispatch a queued job (always async; no sync shortcut) with the run id + backup_set_id + option flags. 6. Return immediately with a Filament notification and a “View run” link (DB notification preferred). ### Queued Job Responsibilities (all heavy work): 1. Load the `BulkOperationRun` and ensure it is still active (`pending` → `running`). 2. Validate the backup set exists and belongs to the run tenant. 3. Process the run’s stored policy IDs deterministically (sequentially or chunked): - Add/capture each policy into the backup set using existing capture services. - Update counts and record per-item failures with safe reasons. - Continue through all items (no circuit-breaker abort). 4. Complete the run with: - `completed` if no failures - `completed_with_errors` (partial) if some failures - `failed` if the job cannot proceed (e.g., backup set missing) 5. Emit a DB notification to the initiating user summarizing the outcome and linking to the run. ### Idempotency & Concurrency - Primary dedupe mechanism: `RunIdempotency::findActiveBulkOperationRun(tenant_id, idempotency_key)`. - Secondary guard: existing partial unique index on `(tenant_id, idempotency_key)` for active statuses. - Race handling: if concurrent submissions collide, prefer “find existing run and redirect” over throwing. - Dedupe scope: tenant-wide (idempotency key does not include `user_id`). ## Testing Strategy (Pest) Minimum required tests for 052: - Action dispatch test: `Add selected` queues the job and creates/reuses a run. - Fail-hard Graph guard test: binding `GraphClientInterface` (or a capture service) to a mock that must not be called during the action. - Idempotency test: calling the action twice with the same selection queues only one job and creates only one active run. - Tenant isolation test: run view under another tenant context is forbidden (403). Target commands: - `./vendor/bin/sail artisan test --filter=BackupSetAddPolicies` - `./vendor/bin/pint --dirty` ## Rollout Notes - Requires queue workers running for background processing (Sail locally; Dokploy workers in staging/prod). - No destructive migrations expected; if schema is extended for better observability, it must remain backwards compatible.