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