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

10 KiB
Raw Blame History

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)

/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 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