# Data Model: 109 — Tenant Review Pack Export v1

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

---

## New Entities

### 1. `review_packs` Table

| Column | Type | Constraints | Notes |
|--------|------|-------------|-------|
| `id` | `bigint` | PK, auto-increment | |
| `workspace_id` | `bigint` | FK → workspaces, NOT NULL, cascadeOnDelete | Auto-derived via `DerivesWorkspaceIdFromTenant` |
| `tenant_id` | `bigint` | FK → tenants, NOT NULL, cascadeOnDelete | |
| `operation_run_id` | `bigint` | FK → operation_runs, nullable, nullOnDelete | Link to generating run; null if run is purged |
| `initiated_by_user_id` | `bigint` | FK → users, nullable, nullOnDelete | User who triggered generation |
| `status` | `string` | NOT NULL, default `'queued'` | Enum string: queued, generating, ready, failed, expired |
| `fingerprint` | `string(64)` | nullable | SHA-256 of inputs for content dedupe |
| `previous_fingerprint` | `string(64)` | nullable | Prior pack fingerprint for lineage |
| `summary` | `jsonb` | default `'{}'` | `data_freshness`, counts, etc. |
| `options` | `jsonb` | default `'{}'` | `include_pii`, `include_operations` flags |
| `file_disk` | `string` | nullable | Storage disk name (e.g., `exports`) |
| `file_path` | `string` | nullable | Relative file path within disk |
| `file_size` | `bigint` | nullable | File size in bytes |
| `sha256` | `string(64)` | nullable | SHA-256 of the generated ZIP file |
| `generated_at` | `timestampTz` | nullable | When generation completed |
| `expires_at` | `timestampTz` | nullable | Retention deadline |
| `created_at` | `timestampTz` | auto | |
| `updated_at` | `timestampTz` | auto | |

**Indexes:**

| Index | Type | Purpose |
|-------|------|---------|
| `(workspace_id, tenant_id, fingerprint)` | unique (where fingerprint IS NOT NULL) | Content dedupe within a tenant |
| `(workspace_id, tenant_id, generated_at)` | btree | List page pagination/sort |
| `(status, expires_at)` | btree | Prune command query |

**Partial Unique Index (PostgreSQL):**
```sql
CREATE UNIQUE INDEX review_packs_fingerprint_unique
ON review_packs (workspace_id, tenant_id, fingerprint)
WHERE fingerprint IS NOT NULL AND status NOT IN ('expired', 'failed');
```
Rationale: Only active (non-expired, non-failed) packs participate in fingerprint uniqueness. Expired and failed packs can share a fingerprint with newer ready packs.

---

### 2. `ReviewPack` Model

**Namespace:** `App\Models\ReviewPack`

**Traits:**
- `HasFactory`
- `DerivesWorkspaceIdFromTenant` (auto-sets `workspace_id` from tenant)

**Guarded:** `[]` (mass assignment protection via policy layer)

**Casts:**
```php
protected function casts(): array
{
    return [
        'summary' => 'array',
        'options' => 'array',
        'generated_at' => 'datetime',
        'expires_at' => 'datetime',
        'file_size' => 'integer',
    ];
}
```

**Relationships:**
```php
public function workspace(): BelongsTo     → Workspace::class
public function tenant(): BelongsTo        → Tenant::class
public function operationRun(): BelongsTo  → OperationRun::class
public function initiator(): BelongsTo     → User::class (FK: initiated_by_user_id)
```

**Scopes:**
```php
public function scopeReady(Builder $q): Builder        → where('status', 'ready')
public function scopeExpired(Builder $q): Builder       → where('status', 'expired')
public function scopePastRetention(Builder $q): Builder → where('expires_at', '<', now())
public function scopeForTenant(Builder $q, int $tenantId): Builder
public function scopeLatestReady(Builder $q): Builder   → ready()->latest('generated_at')
```

**Constants:**
```php
const STATUS_QUEUED = 'queued';
const STATUS_GENERATING = 'generating';
const STATUS_READY = 'ready';
const STATUS_FAILED = 'failed';
const STATUS_EXPIRED = 'expired';
```

---

### 3. `ReviewPackStatus` Enum

**Namespace:** `App\Support\ReviewPackStatus`

```php
enum ReviewPackStatus: string
{
    case Queued = 'queued';
    case Generating = 'generating';
    case Ready = 'ready';
    case Failed = 'failed';
    case Expired = 'expired';
}
```

---

## Modified Entities

### 4. `OperationRunType` Enum — Add case

```php
case ReviewPackGenerate = 'tenant.review_pack.generate';
```

Added after `EntraAdminRolesScan`. Value follows existing `namespace.action` convention.

---

### 5. `BadgeDomain` Enum — Add case

```php
case ReviewPackStatus = 'review_pack_status';
```

**Badge Mapper:** `App\Support\Badges\Mappers\ReviewPackStatusBadge`

| Value | Label | Color |
|-------|-------|-------|
| `queued` | Queued | warning |
| `generating` | Generating | info |
| `ready` | Ready | success |
| `failed` | Failed | danger |
| `expired` | Expired | gray |

---

### 6. `Capabilities` — Add constants

```php
// Review packs
public const REVIEW_PACK_VIEW = 'review_pack.view';
public const REVIEW_PACK_MANAGE = 'review_pack.manage';
```

Added after Entra roles section. Auto-discovered by `Capabilities::all()` via reflection.

---

### 7. `config/filesystems.php` — Add `exports` disk

```php
'exports' => [
    'driver' => 'local',
    'root' => storage_path('app/private/exports'),
    'serve' => false,
    'throw' => true,
],
```

---

### 8. `config/tenantpilot.php` — Add `review_pack` section

```php
'review_pack' => [
    'retention_days' => (int) env('TENANTPILOT_REVIEW_PACK_RETENTION_DAYS', 90),
    'hard_delete_grace_days' => (int) env('TENANTPILOT_REVIEW_PACK_HARD_DELETE_GRACE_DAYS', 30),
    'download_url_ttl_minutes' => (int) env('TENANTPILOT_REVIEW_PACK_DOWNLOAD_URL_TTL_MINUTES', 60),
    'include_pii_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_PII_DEFAULT', true),
    'include_operations_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true),
],
```

---

## Entity Relationship Diagram (Simplified)

```
Workspace (1) ──< ReviewPack (N)
Tenant (1) ──< ReviewPack (N)
OperationRun (1) ──< ReviewPack (0..1)
User (1) ──< ReviewPack (N) [initiated_by_user_id]
```

ReviewPack reads (but does not mutate) during generation:
- StoredReport (via workspace_id + tenant_id + report_type)
- Finding (via tenant_id + status + severity)
- Tenant (RBAC hardening fields)
- OperationRun (recent runs, last 30 days)

---

## State Machine: `review_packs.status`

```
                 ┌──────────┐
    Create ─────>│  queued   │
                 └─────┬────┘
                       │ Job starts
                       v
                 ┌──────────────┐
                 │  generating  │
                 └──┬────────┬──┘
        Success     │        │    Failure
                    v        v
             ┌───────┐  ┌────────┐
             │ ready  │  │ failed │
             └───┬───┘  └────────┘
                 │ expires_at passed (prune cmd)
                 v
             ┌─────────┐
             │ expired  │
             └─────────┘
```

Transitions are one-way. A failed pack is never retried (user triggers a new generation). An expired pack is never un-expired.

---

## Migration Order

1. `YYYY_MM_DD_HHMMSS_create_review_packs_table.php` — Creates table, indexes, partial unique index.

No modifications to existing tables required. `OperationRunType`, `BadgeDomain`, `Capabilities`, and config changes are code-only (no migrations).

---

## Factory: `ReviewPackFactory`

```php
# database/factories/ReviewPackFactory.php

class ReviewPackFactory extends Factory
{
    protected $model = ReviewPack::class;

    public function definition(): array
    {
        return [
            'tenant_id' => Tenant::factory(),
            'status' => ReviewPackStatus::Ready->value,
            'fingerprint' => fake()->sha256(),
            'summary' => ['data_freshness' => [...]],
            'options' => ['include_pii' => true, 'include_operations' => true],
            'file_disk' => 'exports',
            'file_path' => 'packs/' . fake()->uuid() . '.zip',
            'file_size' => fake()->numberBetween(1024, 1024 * 1024),
            'sha256' => fake()->sha256(),
            'generated_at' => now(),
            'expires_at' => now()->addDays(90),
        ];
    }

    // States
    public function queued(): static    → status queued, nullify file fields + generated_at
    public function generating(): static → status generating, nullify file fields + generated_at
    public function ready(): static      → status ready (default)
    public function failed(): static     → status failed, nullify file fields
    public function expired(): static    → status expired, expires_at in the past
}
```
