Implements Spec 091 “BackupSchedule Retention & Lifecycle (Archive/Restore/Force Delete)”.
- BackupSchedule lifecycle:
- Archive (soft delete) with confirmation; restores via Restore action; Force delete with confirmation and strict gating.
- Force delete blocked when historical runs exist.
- Archived schedules never dispatch/execute (dispatcher + job guard).
- Audit events emitted for archive/restore/force delete.
- RBAC UX semantics preserved (non-member hidden/404; member w/o capability disabled + server-side 403).
- Filament UX contract update:
- Create CTA placement rule across create-enabled list pages:
- Empty list: only large centered empty-state Create CTA.
- Non-empty list: only header Create action.
- Tests added/updated to enforce the rule.
Verification:
- `vendor/bin/sail bin pint --dirty`
- Focused tests: BackupScheduling + RBAC enforcement + EmptyState CTAs + Create CTA placement
Notes:
- Filament v5 / Livewire v4 compliant.
- Manual quickstart verification in `specs/091-backupschedule-retention-lifecycle/quickstart.md` remains to be checked (T031).
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #109
198 lines
10 KiB
Markdown
198 lines
10 KiB
Markdown
|
||
# 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`
|