# Implementation Plan: BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete) (Spec 091) **Branch**: `091-backupschedule-retention-lifecycle` | **Date**: 2026-02-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/spec.md` **Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/spec.md` ## Summary Introduce an enterprise lifecycle for tenant backup schedules: - **Archive** is the default destructive action (soft delete), removing schedules from the active list and preventing execution. - **Restore** re-activates archived schedules without changing their enabled/disabled state. - **Force delete** permanently removes archived schedules, but is strictly capability-gated and **blocked if the schedule has historical runs**. All lifecycle actions: - enforce tenant isolation + capability-first RBAC (non-member → 404, member missing capability → 403), - require confirmation for destructive actions (archive + force delete), - write stable audit events. ## Technical Context **Language/Version**: PHP 8.4.15 **Framework**: Laravel 12 **Admin UI**: Filament v5 + Livewire v4.0+ **Storage**: PostgreSQL (Sail) **Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`) **Target Platform**: Docker/Sail local; Dokploy staging/prod **Project Type**: Laravel monolith **Performance Goals**: Ensure dispatch due schedules remains streaming (`cursor()`), no N+1. **Constraints**: - No new dependencies - No bulk destructive actions for BackupSchedule lifecycle (per spec) - Archived schedules must never be dispatched or executed ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - ✅ Inventory-first vs backups: This feature affects backups scheduling only, not inventory state. - ✅ Read/write separation: Lifecycle mutations are DB-only, confirmed, audited, and tested. - ✅ Graph contract path: No Graph calls introduced. - ✅ Deterministic capabilities: Use `App\Support\Auth\Capabilities` constants only. - ✅ RBAC-UX semantics: non-member tenant access → 404; member missing capability → 403; actions use central helpers. - ✅ Destructive confirmation: archive + force delete require `->requiresConfirmation()`. - ✅ Filament Action Surface Contract: list keeps clickable row inspect; max 2 visible row actions; destructive actions grouped under “More”; empty-state CTA exists. ## Project Structure ### Documentation (this feature) ```text /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/ ├── spec.md ├── plan.md ├── research.md ├── data-model.md ├── quickstart.md └── contracts/ └── backup-schedule-lifecycle-v1.openapi.yaml ``` ### Source Code (relevant to this spec) ```text app/ ├── Console/Commands/TenantpilotDispatchBackupSchedules.php ├── Filament/Resources/BackupScheduleResource.php ├── Filament/Resources/BackupScheduleResource/Pages/* ├── Jobs/RunBackupScheduleJob.php ├── Models/BackupSchedule.php ├── Services/BackupScheduling/BackupScheduleDispatcher.php ├── Services/Intune/AuditLogger.php └── Support/Rbac/UiEnforcement.php database/migrations/ └── 2026_01_05_011014_create_backup_schedules_table.php tests/Feature/BackupScheduling/ ├── BackupScheduleBulkDeleteTest.php ├── BackupScheduleCrudTest.php ├── DispatchIdempotencyTest.php ├── RunBackupScheduleJobTest.php └── RunNowRetryActionsTest.php ``` **Structure Decision**: Single Laravel monolith; changes are limited to existing model/migration, the Filament resource/pages, the dispatcher/job safety behavior, and targeted Pest tests. ## Phase 0 — Outline & Research (DOCS COMPLETE) Completed in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/research.md`. Key findings: - The repo already uses soft-delete lifecycle patterns in Filament (e.g., Backup Sets) via `TrashedFilter` + custom actions. - Central RBAC enforcement exists for Filament actions via `App\Support\Rbac\UiEnforcement` (membership-driven hidden UI + capability-driven disabled UI + server-side guard). - Scheduler dispatch is centralized in `BackupScheduleDispatcher::dispatchDue()` and already uses idempotent `OperationRun` identities. ## Phase 1 — Design & Contracts (DOCS COMPLETE) Outputs: - `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/data-model.md` - `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/contracts/backup-schedule-lifecycle-v1.openapi.yaml` - `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/091-backupschedule-retention-lifecycle/quickstart.md` Design choices: - Use **Eloquent SoftDeletes** on `BackupSchedule` to represent “Archived”. - Default list shows active schedules; archived schedules are accessible via a labeled `TrashedFilter`. - Lifecycle actions are implemented as Filament actions (not controllers) and wrapped via `UiEnforcement`: - Archive / Restore require `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE`. - Force delete requires `Capabilities::TENANT_DELETE`. - Force delete is blocked if historical runs exist. In this codebase, “historical runs” are represented by `OperationRun` records with `context->backup_schedule_id`. - Scheduler ignores archived schedules by default via SoftDeletes query scoping; job execution path also skips if a schedule is archived between dispatch and execution. ## Phase 1 — Agent Context Update (DONE) The agent context update script is executed after Phase 1 artifacts are generated: - `.specify/scripts/bash/update-agent-context.sh copilot` ## Constitution Check (Post-Design) - ✅ Livewire v4.0+ compliant (Filament v5) - ✅ No Graph calls introduced - ✅ Lifecycle actions are confirmed + audited - ✅ RBAC-UX membership/capability semantics remain enforced by central helpers + existing tenant routing - ✅ Action Surface Contract remains satisfied (no bulk destructive actions introduced) ## Phase 2 — Implementation Plan ### Step 1 — Add SoftDeletes to BackupSchedule 1) Add `SoftDeletes` trait to `App\Models\BackupSchedule`. 2) Create a migration to add `deleted_at` to `backup_schedules` (with an index that supports common queries, e.g. `(tenant_id, deleted_at)` or similar). ### Step 2 — Update the Filament BackupSchedule Resource to reflect lifecycle Primary goal: replace “hard delete” semantics with an **Archive / Restore / Force delete** lifecycle, while staying compliant with the Action Surface Contract. Work items: - Add `TrashedFilter` to the table filters with “Archived” labels matching the repo conventions. - Remove/disable bulk destructive actions for schedules (per spec). - Replace any `DeleteAction` usage for schedules with an “Archive” action that calls `$record->delete()`. - Add “Restore” action (`$record->restore()`), visible only for archived records, with **no required confirmation**. - Add “Force delete” action (`$record->forceDelete()`), visible only for archived records, with confirmation required. - Keep at most 2 visible row actions; group lifecycle actions under a `More` menu (`ActionGroup`). - Ensure edit remains the primary inspection affordance (`recordUrl()` to Edit). ### Step 3 — Enforce RBAC semantics consistently - Wrap all lifecycle actions using `UiEnforcement`: - Archive/Restore → `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` - Force delete → `Capabilities::TENANT_DELETE` - Preserve visibility conditions (e.g., only show Restore/Force delete when `trashed()`). ### Step 4 — Implement force delete blocking (historical runs) - Define “historical runs exist” as: schedule has `OperationRun` records in `BackupSchedule::operationRuns()`. - Force delete must fail closed: - In UI: show a deterministic danger notification and do not delete. - Title: “Cannot force delete backup schedule” - Body: “Backup schedules referenced by historical runs cannot be removed.” - In server-side action handler: early return without deleting and without writing the `backup_schedule.force_deleted` audit event. ### Step 5 — Audit logging for lifecycle actions Use `App\Services\Intune\AuditLogger` to emit stable action IDs: - `backup_schedule.archived` - `backup_schedule.restored` - `backup_schedule.force_deleted` Include tenant context, actor, resource type/id, and metadata (schedule id, schedule name, and an “archived_at/restored_at” timestamp if helpful). ### Step 6 — Operational correctness: archived schedules never execute - Verify `BackupScheduleDispatcher::dispatchDue()` excludes archived schedules (SoftDeletes default scoping). - Update `RunBackupScheduleJob` to explicitly detect a schedule that becomes archived after being queued and **skip execution** (no Graph calls): - Mark the `OperationRun` as terminal `completed` with outcome `blocked`. - Use deterministic summary counts for the skip case (mirrors existing skip patterns): `total=0`, `processed=0`, `failed=0`, `skipped=1`. - Add a deterministic failure_summary entry: `code=schedule_archived`, `message=Schedule is archived; run will not execute.` - Update the schedule’s `last_run_status` to `skipped` (and `last_run_at` to “now”) so operators can see why the last queued run didn’t execute. - Emit the existing operational audit event `backup_schedule.run_skipped` with `reason=schedule_archived` (consistent with the existing `concurrent_run` skip path). ### Step 7 — Testing plan (Pest) Minimum programmatic verification (update existing tests where applicable): - Archive schedule: sets `deleted_at`, excludes from active list, audit event written. - Restore schedule: clears `deleted_at` and does not change `is_enabled`, audit event written. - Force delete: - only available when archived - requires `Capabilities::TENANT_DELETE` - blocked when historical runs exist - audit event written on success - Scheduler safety: archived schedules are not dispatched. Expected test updates: - `tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php` must be removed or rewritten, since bulk destructive actions are forbidden by Spec 091. ### Step 8 — Formatting - `vendor/bin/sail bin pint --dirty`