# 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).
