TenantAtlas/specs/091-backupschedule-retention-lifecycle/plan.md
ahmido 1c098441aa feat(spec-091): BackupSchedule lifecycle + create-CTA placement rule (#109)
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
2026-02-14 13:46:06 +00:00

198 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 schedules `last_run_status` to `skipped` (and `last_run_at` to “now”) so operators can see why the last queued run didnt 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`