# Research: 109 — Tenant Review Pack Export v1 (CSV + ZIP)

**Date**: 2026-02-23
**Branch**: `109-review-pack-export`

---

## 1. OperationRun Integration

### Decision
Add `ReviewPackGenerate` case with value `tenant.review_pack.generate` to `OperationRunType` enum.

### Rationale
- Follows established enum pattern: 14 existing cases in `app/Support/OperationRunType.php`.
- Active-run dedupe is enforced at dispatch time via `scopeActive()` (`whereIn('status', ['queued', 'running'])`). The existing DB partial unique index on `operation_runs` (`operation_runs_active_unique`) serves as a safety backstop. Same pattern reused.
- Status lifecycle uses existing `OperationRunStatus` enum: `Queued`, `Running`, `Completed`.
- Outcome is set on the run record; failure details go into `context` JSONB (no `reason_code` column — stored as `context['reason_code']` and `context['error_message']`).

### Alternatives Considered
- Adding a DB partial unique index for active-run dedupe → Rejected; existing code uses application-level guard consistently. Adding a DB constraint would be a cross-cutting architectural change.

---

## 2. RBAC Capabilities

### Decision
Add two constants to `app/Support/Auth/Capabilities.php`:
- `REVIEW_PACK_VIEW` = `'review_pack.view'`
- `REVIEW_PACK_MANAGE` = `'review_pack.manage'`

### Rationale
- 35 existing capability constants follow the pattern `domain.action` (e.g., `tenant.view`, `entra_roles.manage`).
- Capabilities are discovered via `Capabilities::all()` using reflection — new constants are auto-discovered.
- No separate enum needed; the constants-class pattern is canonical.
- `REVIEW_PACK_MANAGE` covers generate + expire/delete (per clarification Q2). Confirmation dialog is the safety gate for destructive actions.

### Alternatives Considered
- Separate `REVIEW_PACK_DELETE` capability → Deferred; can be split out later with no migration needed.

---

## 3. StoredReport & Finding Data Access

### Decision
Query existing models directly; no new scopes or accessors needed for v1.

### Rationale
- `StoredReport` has `report_type` constants (`permission_posture`, `entra.admin_roles`), `payload` (array cast), `fingerprint`, `previous_fingerprint`. Query: latest per report_type per tenant.
- `Finding` has `finding_type` constants (`drift`, `permission_posture`, `entra_admin_roles`), `status` (new/acknowledged/resolved), `severity`, `fingerprint`, `evidence_jsonb`. Export scope: `status IN (new, acknowledged)` — resolved findings excluded.
- Both use `DerivesWorkspaceIdFromTenant` trait for workspace_id auto-derivation.
- CSV column mapping is straightforward from model attributes; no transformation layer needed.

### Alternatives Considered
- Adding dedicated export scopes on the models → Over-engineering for v1; simple where clauses suffice. Can be extracted later.

---

## 4. Tenant Hardening Fields

### Decision
Export the following fields to `hardening.json`:
- `rbac_last_checked_at` (datetime)
- `rbac_last_setup_at` (datetime)
- `rbac_canary_results` (array)
- `rbac_last_warnings` (array — includes computed `scope_limited` warning)
- `rbac_scope_mode` (string)

### Rationale
- These are the tenant RBAC hardening/write-safety fields per the Tenant model.
- The `getRbacLastWarningsAttribute` accessor enriches the raw array with `scope_limited` when `rbac_scope_mode === 'scope_group'` — need to use the accessor, not raw DB value.
- `app_client_secret` is encrypted and MUST NOT be exported (FR-008 compliance).

### Alternatives Considered
- Including ProviderConnection health in hardening.json → Deferred to v2; adds complexity and a separate model dependency.

---

## 5. Badge Integration

### Decision
Add `ReviewPackStatus` case to `BadgeDomain` enum and create `ReviewPackStatusBadge` mapper.

### Rationale
- 35 existing badge domains in `app/Support/Badges/BadgeDomain.php`.
- Pattern: enum case → mapper class implementing `BadgeMapper::spec()` returning `BadgeSpec(label, color, ?icon)`.
- Status values: `queued` (warning), `generating` (info), `ready` (success), `failed` (danger), `expired` (gray).
- Colors align with existing palette: `gray`, `info`, `success`, `warning`, `danger`, `primary`.

### Alternatives Considered
- Reusing existing badge domains → Not applicable; review pack status is a new domain with distinct semantics.

---

## 6. Filesystem Disk

### Decision
Add `exports` disk to `config/filesystems.php` using the `local` driver at `storage_path('app/private/exports')`.

### Rationale
- Existing `local` disk pattern: `storage_path('app/private')`, driver `local`, `serve: true`.
- `exports` is private (non-public URL); downloads go through signed route controller.
- Path `storage/app/private/exports/` keeps exports co-located with other private storage.
- In Dokploy deployments, `storage/app/` is typically volume-mounted; no extra volume config needed.

### Alternatives Considered
- Using S3 from the start → Deferred per clarification Q5; local disk for v1, S3 swappable later.
- Using existing `local` disk with a subdirectory → Rejected; dedicated disk gives cleaner config and allows independent retention/backup settings.

---

## 7. Console Schedule

### Decision
Add three new schedule entries to `routes/console.php`:
1. `tenantpilot:review-pack:prune` → `daily()` + `withoutOverlapping()`
2. `tenantpilot:posture:dispatch` → **Does not exist yet.** Must be created as a new Artisan command OR schedule existing job dispatch directly.
3. `tenantpilot:entra-roles:dispatch` → **Does not exist yet.** Same as above.

### Rationale
- Currently: Entra admin roles scan is dispatched as a closure via `daily()` + `withoutOverlapping()` (iterates connected tenants). Permission posture generation is event-driven from `ProviderConnectionHealthCheckJob`, not scheduled.
- For FR-015: the spec requires both to be command-based and scheduled. However, creating new Artisan dispatch commands is out of scope for spec 109 if they require significant scanning infrastructure changes.
- **Pragmatic approach**: FR-015 is listed as P3 (lowest priority). If the dispatch commands don't exist yet, wire what exists (the Entra roles closure is already daily) and defer `tenantpilot:posture:dispatch` creation to a separate spec/task. Document this in the plan.

### Alternatives Considered
- Creating full dispatch commands in this spec → Scope creep; the scan orchestration is a separate concern.

---

## 8. AlertRule `sla_due` Cleanup

### Decision
Remove `sla_due` from the AlertRuleResource form dropdown options; keep the `EVENT_SLA_DUE` constant on the model for backward compatibility.

### Rationale
- `sla_due` is defined as a constant on `AlertRule` and appears in the form dropdown at `AlertRuleResource.php` line ~379.
- No producer dispatches `sla_due` events — it's a dead option.
- Removing from the form prevents new rules from selecting it. Existing rules with `sla_due` continue to exist in the DB but won't match any events (harmless).

### Alternatives Considered
- Hard-deleting existing `sla_due` rules via migration → Too aggressive for v1; rules are workspace-owned data.

---

## 9. Download via Signed URL

### Decision
Implement `URL::signedRoute()` with configurable TTL (default: 60 minutes). Download controller validates signature, streams file from `exports` disk.

### Rationale
- No existing signed-URL or download-streaming pattern in the codebase — greenfield.
- Laravel's built-in `URL::signedRoute()` + `hasValidSignature()` middleware is production-proven.
- Download controller is a simple registered route (not Filament); validates signature + checks pack status (must be `ready`, not `expired`).
- TTL configurable via `config('tenantpilot.review_pack.download_url_ttl_minutes')`.

### Alternatives Considered
- Session-authenticated stream → Rejected per clarification Q1; notification links must be self-contained.

---

## 10. Notification

### Decision
Use Laravel's built-in database notification channel. Create `ReviewPackReadyNotification` and `ReviewPackFailedNotification` (or a single `ReviewPackStatusNotification` with context).

### Rationale
- Existing pattern: the app uses Filament's notification system (DB-backed) for user-facing notifications.
- Notification includes: pack status, generated_at, download URL (signed, for `ready`), or failure reason (sanitized, for `failed`).
- Single notification class with conditional rendering based on status is simpler than two separate classes.

### Alternatives Considered
- Two separate notification classes → Slightly cleaner typing but more boilerplate for identical structure. Single class preferred.

---

## 11. ZIP Assembly

### Decision
Use PHP `ZipArchive` with deterministic alphabetical file insertion order. Temporary file written to `sys_get_temp_dir()`, then moved to exports disk.

### Rationale
- `ZipArchive` is available in all PHP 8.4 builds (ext-zip).
- Deterministic order: files added alphabetically by path ensures same content → same ZIP bytes → stable sha256 for fingerprint verification.
- Write to temp first, then `Storage::disk('exports')->put()` — atomic; no partial files on disk if job fails mid-write.
- SHA-256 computed on the final file before persistence.

### Alternatives Considered
- Streaming ZIP (no temp file) → PHP ZipArchive doesn't support true streaming; would need a library like `maennchen/zipstream-php`. Deferred; temp file is fine for expected pack sizes.
