10 KiB
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\Capabilitiesconstants 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)
/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)
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 idempotentOperationRunidentities.
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
BackupScheduleto 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.
- Archive / Restore require
- Force delete is blocked if historical runs exist. In this codebase, “historical runs” are represented by
OperationRunrecords withcontext->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
- Add
SoftDeletestrait toApp\Models\BackupSchedule. - Create a migration to add
deleted_attobackup_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
TrashedFilterto the table filters with “Archived” labels matching the repo conventions. - Remove/disable bulk destructive actions for schedules (per spec).
- Replace any
DeleteActionusage 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
Moremenu (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
- Archive/Restore →
- 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
OperationRunrecords inBackupSchedule::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_deletedaudit event.
- In UI: show a deterministic danger notification and do not delete.
Step 5 — Audit logging for lifecycle actions
Use App\Services\Intune\AuditLogger to emit stable action IDs:
backup_schedule.archivedbackup_schedule.restoredbackup_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
RunBackupScheduleJobto explicitly detect a schedule that becomes archived after being queued and skip execution (no Graph calls):- Mark the
OperationRunas terminalcompletedwith outcomeblocked. - 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_statustoskipped(andlast_run_atto “now”) so operators can see why the last queued run didn’t execute. - Emit the existing operational audit event
backup_schedule.run_skippedwithreason=schedule_archived(consistent with the existingconcurrent_runskip path).
- Mark the
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_atand does not changeis_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.phpmust be removed or rewritten, since bulk destructive actions are forbidden by Spec 091.
Step 8 — Formatting
vendor/bin/sail bin pint --dirty