# Implementation Plan: Tenant Review Pack Export v1 (CSV + ZIP) **Branch**: `109-review-pack-export` | **Date**: 2026-02-23 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from `/specs/109-review-pack-export/spec.md` ## Summary Implement an exportable audit artifact (ZIP with CSV + JSON) for MSP/Enterprise tenant reviews. The system collects pre-cached stored reports, findings, tenant hardening status, and recent operations from the database (no Graph API calls), assembles them into a deterministic ZIP archive, and stores it on a private local disk. Downloads use signed temporary URLs. Features include fingerprint-based content deduplication, RBAC-gated access (`REVIEW_PACK_VIEW` / `REVIEW_PACK_MANAGE`), retention pruning with configurable expiry, and OperationRun-based observability. The Filament UI provides a ReviewPackResource (list + view), a Tenant Dashboard card, and a modal-based generation trigger. ## Technical Context **Language/Version**: PHP 8.4 (Laravel 12) **Primary Dependencies**: Filament v5, Livewire v4, Laravel Framework v12 **Storage**: PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts **Testing**: Pest v4 (Feature tests with Livewire component testing) **Target Platform**: Linux server (Sail/Docker local, Dokploy VPS staging/production) **Project Type**: web (Laravel monolith with Filament admin panel) **Performance Goals**: Pack generation < 60s for 1,000 findings + 10 stored reports; download streaming with no memory loading of full file **Constraints**: DB-only generation (no Graph API calls); chunked iteration for large finding sets; temp file for ZIP assembly (no full in-memory ZIP) **Scale/Scope**: Multi-workspace, multi-tenant; packs are tenant-scoped; typical pack size < 10 MB ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | Rule | Status | Notes | |------|--------|-------| | **Inventory-first** | PASS | ReviewPack reads from existing stored_reports + findings (pre-cached inventory data). No new inventory sources introduced. | | **Read/write separation** | PASS | Generation is a queued job (async write). User action is enqueue-only. Destructive actions (expire/delete) require `->requiresConfirmation()`. All writes audit-logged via OperationRun. | | **Graph contract path** | PASS | No Graph API calls in this feature. All data sourced from DB. FR-006 explicitly forbids Graph calls during generation. | | **Deterministic capabilities** | PASS | `REVIEW_PACK_VIEW` and `REVIEW_PACK_MANAGE` added to canonical `Capabilities.php` registry. Discoverable via `Capabilities::all()` reflection. No raw strings. | | **RBAC-UX: two planes** | PASS | All routes under `/admin` plane. Tenant-context routes scoped via `/admin/t/{tenant}/...`. No `/system` plane involvement. | | **RBAC-UX: non-member = 404** | PASS | Non-member workspace/tenant access returns 404 (deny-as-not-found). Policy enforces workspace_id + tenant_id before any data returned. Download endpoint returns 404 for non-existent/inaccessible packs. | | **RBAC-UX: member missing cap = 403** | PASS | Members without `REVIEW_PACK_VIEW` get 403 on list/download. Members without `REVIEW_PACK_MANAGE` get 403 on generate/expire. | | **RBAC-UX: destructive confirmation** | PASS | Expire and Delete actions use `->requiresConfirmation()` with clear warning text. Regenerate requires confirmation if a ready pack exists. | | **RBAC-UX: global search** | N/A | ReviewPackResource does not participate in global search (no `$recordTitleAttribute`). Intentionally excluded. | | **Workspace isolation** | PASS | `DerivesWorkspaceIdFromTenant` auto-sets workspace_id. All queries scope by workspace_id + tenant_id. Download controller validates pack ownership before streaming. | | **Tenant isolation** | PASS | Partial unique index scoped to (workspace_id, tenant_id, fingerprint). All scopes include tenant_id. Dashboard widget reads only current tenant packs. | | **Run observability** | PASS | `OperationRun` of type `tenant.review_pack.generate` tracks lifecycle. Active-run dedupe via partial unique index. Failure reason stored in `context['reason_code']`. | | **Automation** | PASS | Prune command uses `withoutOverlapping()`. GenerateReviewPackJob unique via OperationRun active dedupe (existing pattern). | | **Data minimization** | PASS | FR-008: no webhooks, tokens, secrets, or raw Graph dumps exported. FR-009: PII redaction via `include_pii` toggle. | | **BADGE-001** | PASS | `ReviewPackStatus` added to `BadgeDomain` enum with `ReviewPackStatusBadge` mapper. All 5 statuses mapped. | | **Filament Action Surface Contract** | PASS | UI Action Matrix in spec. List: header action (Generate), row actions (Download + Expire), empty state CTA. View: header actions (Download + Regenerate). Card: inline actions. Destructive actions confirmed. | | **UX-001** | PASS | View page uses Infolist. Empty state has specific title + explanation + 1 CTA. Status badges use BADGE-001. Generate modal fields in Sections. Table has search/sort/filters. | **Post-design re-evaluation**: All checks PASS. No violations found. ## Project Structure ### Documentation (this feature) ```text specs/109-review-pack-export/ ├── plan.md # This file ├── spec.md # Feature specification ├── research.md # Phase 0: resolved unknowns ├── data-model.md # Phase 1: entity design ├── quickstart.md # Phase 1: implementation guide ├── contracts/ # Phase 1: API + action contracts │ └── api-contracts.md ├── checklists/ │ └── requirements.md └── tasks.md # Phase 2 output (created by /speckit.tasks) ``` ### Source Code (repository root) ```text app/ ├── Console/Commands/ │ └── PruneReviewPacksCommand.php ├── Filament/ │ ├── Resources/ │ │ └── ReviewPackResource.php │ │ └── Pages/ │ │ ├── ListReviewPacks.php │ │ └── ViewReviewPack.php │ └── Widgets/ │ └── TenantReviewPackCard.php ├── Http/Controllers/ │ └── ReviewPackDownloadController.php ├── Jobs/ │ └── GenerateReviewPackJob.php ├── Models/ │ └── ReviewPack.php ├── Notifications/ │ └── ReviewPackStatusNotification.php ├── Services/ │ └── ReviewPackService.php └── Support/ ├── ReviewPackStatus.php └── Badges/Mappers/ └── ReviewPackStatusBadge.php config/ ├── filesystems.php # Modified: add exports disk └── tenantpilot.php # Modified: add review_pack section database/ ├── factories/ │ └── ReviewPackFactory.php └── migrations/ └── XXXX_create_review_packs_table.php routes/ ├── web.php # Modified: add download route └── console.php # Modified: add prune schedule entry tests/Feature/ReviewPack/ ├── ReviewPackGenerationTest.php ├── ReviewPackDownloadTest.php ├── ReviewPackRbacTest.php ├── ReviewPackPruneTest.php ├── ReviewPackResourceTest.php └── ReviewPackWidgetTest.php ``` **Structure Decision**: Standard Laravel monolith structure. All new files follow existing directory conventions. No new base folders introduced. ## Complexity Tracking No Constitution violations detected. No complexity justifications needed. ## Filament v5 Agent Output Contract 1. **Livewire v4.0+ compliance**: Yes. Filament v5 requires Livewire v4 — all components are Livewire v4 compatible. 2. **Provider registration**: Panel provider already registered in `bootstrap/providers.php`. No new panel required. 3. **Global search**: ReviewPackResource does NOT participate in global search. No `$recordTitleAttribute` set. Intentional. 4. **Destructive actions**: Expire action uses `->action(...)` + `->requiresConfirmation()` + `->color('danger')`. Regenerate uses `->requiresConfirmation()` when a ready pack exists. Hard-delete in prune command requires explicit `--hard-delete` flag. 5. **Asset strategy**: No custom frontend assets. Standard Filament components only. `php artisan filament:assets` already in deploy pipeline. 6. **Testing plan**: Pest Feature tests covering: generation job (happy + failure paths), download controller (signed URL, expired, RBAC), RBAC enforcement (404/403 matrix), prune command (expire + hard-delete), Filament Resource (Livewire component tests for list/view pages + actions), Dashboard widget (Livewire component test).