# Phase 6.2: Close v1 sharing + cloud-delete + CSV export gaps — Research

**Researched:** 2026-05-31
**Domain:** FastAPI PATCH endpoints, cloud storage backend routing, Fetch API blob download, MinIO list_objects, SQLAlchemy async JOIN
**Confidence:** HIGH (all findings verified against the live codebase)

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-00:** `create_user` 500 fix already committed — `await session.flush()` before `write_audit_log` in `backend/api/admin.py`.
- **D-01:** Default delete propagates to cloud provider. `delete_document()` in `services/storage.py` must route to `get_storage_backend_for_document(doc, user, session)` for non-MinIO docs.
- **D-02:** "Remove from app only" is a separate, distinct action that deletes the DB record while leaving the cloud file intact.
- **D-03:** Cloud provider delete failure: warning modal "Cloud delete failed. Remove from app anyway?" — user chooses. Backend returns structured error distinguishable from hard 4xx/5xx.
- **D-04:** Cloud documents do NOT touch MinIO quota on delete (unchanged from existing design).
- **D-05:** Persistent local Celery cache for cloud docs — DEFERRED. Not this phase.
- **D-06:** `DocumentCard.vue:31` — change `v-if="doc.share_count > 0"` to `v-if="doc.is_shared"`.
- **D-07:** Permission levels: `view` and `edit`. `Share.permission` column already exists. No migration.
- **D-08:** Permission set at share creation: add view/edit dropdown to `ShareModal.vue` before submitting. Existing POST endpoint already accepts `permission` field.
- **D-09:** Permission changeable after creation: add View/Edit toggle per share row in `ShareModal.vue`. Calls new `PATCH /api/shares/{id}` with `{ permission: "view" | "edit" }`. Owner-only, 404 for wrong owner.
- **D-10:** Fix `shares.py` POST hardcoded `permission="view"` (line 97) — read from request body instead.
- **D-11:** `_audit_to_dict()` — extend to return `user_handle` and `actor_handle` via JOIN on `User` table.
- **D-12:** `user_id` filter in `GET /api/admin/audit-log` — accept handle string, resolve to UUID via `User.handle == handle`, return empty if not found.
- **D-13:** Replace `window.location.href` in `AuditLogTab.vue:exportCsv()` with `fetch()` + Blob URL pattern.
- **D-14:** Add `adminExportAuditLogCsv(params)` to `client.js` — must call `res.text()` (or `res.blob()`), NOT `res.json()`. Create object URL, trigger `<a>` click download.
- **D-15:** Add `GET /api/admin/audit-log/daily-exports` — lists MinIO `audit-logs` bucket contents. Returns `[{ date, key }]` sorted descending.
- **D-16:** Add `GET /api/admin/audit-log/daily-exports/{date}` — streams specific daily export from MinIO as `text/csv`. Auth: `get_current_admin`.
- **D-17:** Frontend `AuditLogTab.vue`: date dropdown from `adminListDailyExports()`, "Download" button uses `fetch()` + Blob URL.

### Claude's Discretion

- Exact API shape for "remove from app only" vs "delete from provider" (`?cloud_only=false` query param vs separate endpoint).
- Whether `PATCH /api/shares/{id}` accepts full body or just `{ permission: "view"|"edit" }` — minimal body preferred.
- Exact error response shape for cloud delete failure — must be distinguishable from hard 4xx/5xx.
- MinIO `list_objects` pagination — handle if `audit-logs` bucket has >1000 files.

### Deferred Ideas (OUT OF SCOPE)

- Persistent local Celery cache for cloud docs with quota tracking.
- Celery local cache "Remove download" button.
- SHARE-03 permission levels beyond view/edit.

</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| SHARE-03 | Shared access is view-only by default; owner controls permission level | D-07..D-10: `Share.permission` column exists; POST hardcodes view; PATCH endpoint needed; frontend dropdown needed |
| SHARE-05 | Documents shared with others display "shared" indicator | D-06: backend sends `is_shared`; frontend checks wrong field `share_count` |
| CLOUD (del) | Cloud document deletion propagates to provider | D-01..D-04: `delete_document()` must route to `get_storage_backend_for_document()`; structured error response needed |
| ADMIN-06 | Admin audit log with filters and export | D-11..D-17: user handles in responses, handle filter, CSV fetch download, daily export list + stream |

</phase_requirements>

---

## Summary

Phase 6.2 is a brownfield gap-closure sprint with four independent feature areas. All four areas have the backend plumbing already present — the gaps are surgical additions and fixes, not architectural changes.

**Area 1 — Cloud-delete propagation:** `services/storage.py:delete_document()` unconditionally calls `_backend().delete_object()` (MinIO singleton). For cloud documents (`doc.storage_backend != "minio"`) it must instead call `get_storage_backend_for_document(doc, user, session)`. This function is already in `backend/storage/__init__.py` and used by both the content proxy and `admin.py:delete_user()`. The wrinkle is that `delete_document()` currently does not receive the `user` ORM object or session — the service layer only receives `session` and `doc_id`. The cleanest fix is to extend the function signature or to perform the cloud routing in `api/documents.py:delete_document` before calling `storage.delete_document()`. The admin delete_user pattern (lines 527-539) is the canonical reference: call `get_storage_backend_for_document`, then `backend.delete_object`, wrapped in `try/except`. A separate "remove from app only" action (D-02) needs an endpoint that skips cloud deletion entirely — a `?remove_only=true` query parameter on `DELETE /api/documents/{id}` is the cleanest route since it shares the auth/ownership gate without duplicating a whole endpoint.

**Area 2 — SHARE-03/05 fixes:** Three micro-changes. (1) `DocumentCard.vue` line 31: `share_count > 0` to `is_shared`. (2) `shares.py` POST: read `permission` from request body (add to `ShareCreate` model). (3) New `PATCH /api/shares/{id}` endpoint with minimal body `{ permission: "view" | "edit" }`, owner-enforced IDOR (404 on mismatch, mirroring the DELETE pattern). (4) Frontend `ShareModal.vue`: dropdown before submit + toggle per row.

**Area 3 — Audit log user handles:** `_audit_to_dict()` is a pure function operating on a loaded `AuditLog` ORM object — it does not have access to a session. Two options: (a) change it to accept optional pre-fetched handle dicts alongside the entry, or (b) make the list/export endpoints JOIN `User` as an alias twice (once for `user_id`, once for `actor_id`) and pass the handles as extra arguments. Option (b) is cleaner and avoids N+1 queries. The `_build_filtered_query()` helper returns a `select(AuditLog)` — it needs to be extended to JOIN User twice (with SQLAlchemy aliases) and yield `(AuditLog, user_handle, actor_handle)` tuples. The `user_id` filter must change from accepting `Optional[uuid.UUID]` to accepting `Optional[str]` and resolving the handle in a preliminary query.

**Area 4 — CSV export + daily exports:** The export endpoint is already a `StreamingResponse` — the only change is on the frontend where `window.location.href` must become `fetch()` + Blob URL. The daily export listing uses `Minio.list_objects(bucket_name="audit-logs", prefix="audit-logs/")` which returns a synchronous iterator — it must be consumed in `asyncio.to_thread()`. The key pattern `audit-logs/{date}.csv` is guaranteed by `audit_tasks.py:79`.

**Primary recommendation:** Implement in four vertical slices, each deployable independently. Start with SHARE-05 (one-line fix), then SHARE-03, then cloud-delete, then audit log cluster (user handles + CSV + daily exports).

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Cloud-delete routing | API / Backend | — | Must happen server-side where credentials are decrypted; cannot be client-driven |
| "Remove from app" vs "delete from cloud" | API / Backend | Browser / Client | Query param distinction at API layer; UX confirmation modal at client layer |
| Share permission PATCH | API / Backend | Browser / Client | IDOR enforcement is a backend concern; toggle UI is client |
| "Shared" badge display | Browser / Client | — | Trivially reads `doc.is_shared` field from list response |
| Audit log handle enrichment | API / Backend | — | JOIN happens in DB query layer; frontend only receives enriched response |
| CSV export download | Browser / Client | API / Backend | Authentication requires `fetch()` with Bearer header — browser nav cannot send it |
| Daily export listing | API / Backend | Browser / Client | MinIO query is server-side; dropdown is client |
| Daily export stream | API / Backend | Browser / Client | Streaming response with auth gate; download trigger at client |

---

## Standard Stack

This phase adds no new packages. All functionality uses the existing stack.

### Core (already installed)
| Library | Version | Purpose | Relevance to Phase |
|---------|---------|---------|-------------------|
| FastAPI | 0.136+ | API framework | PATCH endpoint, query params, StreamingResponse |
| SQLAlchemy 2.0 async | 2.x | ORM | JOIN with aliased User twice for handle enrichment |
| Minio (Python SDK) | current | MinIO S3 client | `list_objects`, `get_object` for daily exports |
| Pydantic v2 | 2.x | Request validation | `SharePermissionPatch` minimal body model |
| Vue 3 (Options API/Composition) | 3.x | Frontend framework | Dropdown, toggle, fetch+Blob download |

### No new packages required

All operations — cloud backend routing, streaming responses, fetch+Blob download — use code already present in the project.

## Package Legitimacy Audit

Not applicable — no new packages installed in this phase.

---

## Architecture Patterns

### System Architecture Diagram

```
DELETE /api/documents/{id}?remove_only=false (default)
  └── api/documents.py:delete_document
        ├── ownership assert (doc.user_id == current_user.id)
        ├── if doc.storage_backend == "minio":
        │     └── services/storage.py:delete_document()  ← (unchanged path)
        │           ├── _backend().delete_object(key)
        │           └── quota decrement (STORE-06)
        └── else (cloud backend):
              ├── get_storage_backend_for_document(doc, user, session)
              │     └── decrypts HKDF credentials, returns cloud backend
              ├── try: cloud_backend.delete_object(doc.object_key)
              │   except: return {"cloud_delete_failed": true, "detail": "..."}  ← 207/200 structured
              └── if cloud delete succeeded (or user confirmed remove_only):
                    └── services/storage.py:_db_only_delete()  ← quota skipped for cloud docs

PATCH /api/shares/{id}
  └── api/shares.py:update_share_permission
        ├── UUID parse (404 on invalid)
        ├── session.get(Share, sid)
        ├── assert share.owner_id == current_user.id → 404 on mismatch (IDOR)
        ├── validate body.permission in {"view", "edit"}
        └── share.permission = body.permission; commit

GET /api/admin/audit-log (with handle enrichment)
  └── api/audit.py:list_audit_log
        ├── if user_handle param: resolve handle → UUID, or empty result if not found
        ├── _build_filtered_query_with_handles(start, end, user_uuid, event_type)
        │     └── select(AuditLog, user_alias.handle, actor_alias.handle)
        │           .outerjoin(user_alias, user_alias.id == AuditLog.user_id)
        │           .outerjoin(actor_alias, actor_alias.id == AuditLog.actor_id)
        └── _audit_to_dict(entry, user_handle, actor_handle) → dict with both handles

GET /api/admin/audit-log/export (CSV with auth)
  └── StreamingResponse (unchanged backend)
  ← client: fetch() + res.text() + Blob URL + <a> click download

GET /api/admin/audit-log/daily-exports
  └── asyncio.to_thread(minio_client.list_objects, "audit-logs", prefix="audit-logs/")
        └── returns [{date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv"}, ...]

GET /api/admin/audit-log/daily-exports/{date}
  └── key = f"audit-logs/{date}.csv"
        ├── asyncio.to_thread(minio_client.get_object, "audit-logs", key)
        └── StreamingResponse(iter([csv_bytes]), media_type="text/csv")
```

### Recommended Project Structure (changes only)

```
backend/
├── api/
│   ├── shares.py          # + PATCH /{id} endpoint; fix ShareCreate.permission
│   └── audit.py           # + handle JOIN; + 2 daily-export endpoints; fix user filter
├── services/
│   └── storage.py         # + cloud routing in delete_document; + _db_only_delete()
frontend/src/
├── api/
│   └── client.js          # + adminExportAuditLogCsv(); + adminListDailyExports(); + adminDownloadDailyExport()
├── components/
│   ├── admin/
│   │   └── AuditLogTab.vue  # fix exportCsv(); + daily exports date dropdown
│   ├── sharing/
│   │   └── ShareModal.vue  # + permission dropdown on share; + toggle per row
│   └── documents/
│       └── DocumentCard.vue  # line 31: share_count → is_shared
```

### Pattern 1: Cloud-delete routing in services/storage.py

The cleanest approach to the signature problem: `delete_document()` gets two new optional parameters `user` and `session_for_cloud`. When `user` is provided and `doc.storage_backend != "minio"`, cloud routing fires. The caller in `api/documents.py` already has both the `current_user` and `session` in scope.

**Alternative (preferred for simplicity):** Move the cloud routing entirely into `api/documents.py:delete_document`, before the call to `storage.delete_document()`. The service layer function handles only DB + MinIO quota. The API layer decides routing. This keeps `services/storage.py` free of cloud-backend imports and mirrors how `admin.py:delete_user()` works — the API layer calls `get_storage_backend_for_document()` and the service layer is unaware of cloud backends.

```python
# api/documents.py — delete_document (modified)
@router.delete("/{doc_id}")
async def delete_document(
    doc_id: str,
    remove_only: bool = Query(default=False),  # D-02: skip cloud delete
    request: Request,
    session: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_regular_user),
):
    uid = uuid.UUID(doc_id)
    doc = await session.get(Document, uid)
    if doc is None or doc.user_id != current_user.id:
        raise HTTPException(404, "Document not found")

    cloud_delete_failed = False
    if doc.storage_backend != "minio" and not remove_only:
        try:
            cloud_backend = await get_storage_backend_for_document(doc, current_user, session)
            await cloud_backend.delete_object(doc.object_key)
        except Exception as exc:
            # D-03: structured error; frontend shows warning modal
            cloud_delete_failed = True
            # Caller (frontend) decides whether to retry with remove_only=true

    if cloud_delete_failed and not remove_only:
        return JSONResponse(
            status_code=200,
            content={
                "success": False,
                "cloud_delete_failed": True,
                "detail": "Cloud provider delete failed. You can remove from app only.",
            }
        )

    # DB delete + quota decrement (cloud docs skip quota — D-04)
    ok = await storage.delete_document(session, doc_id, skip_quota=doc.storage_backend != "minio")
    # ... audit log + commit
```

**Key insight on quota:** Cloud docs already skip quota at upload time (no `UPDATE quotas` in cloud upload path). The `delete_document()` in `services/storage.py` currently always decrements quota. It needs a `skip_quota: bool = False` guard for cloud documents.

Source: `[VERIFIED: codebase]` — confirmed by reading `services/storage.py:163-175` and `api/documents.py:269-291`.

### Pattern 2: PATCH /api/shares/{id} — minimal IDOR-safe pattern

```python
# api/shares.py — new endpoint
class SharePermissionPatch(BaseModel):
    permission: str  # validated: "view" | "edit"

    @field_validator("permission")
    @classmethod
    def validate_permission(cls, v: str) -> str:
        if v not in {"view", "edit"}:
            raise ValueError("permission must be 'view' or 'edit'")
        return v

# CRITICAL: must be defined BEFORE DELETE /{share_id} to avoid path conflict
@router.patch("/{share_id}", status_code=status.HTTP_200_OK)
async def update_share_permission(
    share_id: str,
    body: SharePermissionPatch,
    session: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_regular_user),
) -> dict:
    try:
        sid = uuid.UUID(share_id)
    except ValueError:
        raise HTTPException(status_code=404, detail="Share not found")

    share = await session.get(Share, sid)
    # IDOR: 404 on mismatch — mirrors DELETE pattern (T-04-04-02)
    if share is None or share.owner_id != current_user.id:
        raise HTTPException(status_code=404, detail="Share not found")

    share.permission = body.permission
    await session.commit()
    return {"id": str(share.id), "permission": share.permission}
```

Source: `[VERIFIED: codebase]` — mirrors `revoke_share` IDOR pattern at lines 240-265 in `backend/api/shares.py`.

### Pattern 3: Handle enrichment via SQLAlchemy aliased JOIN

```python
# api/audit.py — handle enrichment
from sqlalchemy.orm import aliased

UserAsSubject = aliased(User)   # for user_id FK
UserAsActor   = aliased(User)   # for actor_id FK

def _build_filtered_query_with_handles(start, end, user_uuid, event_type):
    q = (
        select(AuditLog, UserAsSubject.handle, UserAsActor.handle)
        .outerjoin(UserAsSubject, UserAsSubject.id == AuditLog.user_id)
        .outerjoin(UserAsActor, UserAsActor.id == AuditLog.actor_id)
        .order_by(AuditLog.created_at.desc())
    )
    if start:   q = q.where(AuditLog.created_at >= start)
    if end:     q = q.where(AuditLog.created_at <= end)
    if user_uuid: q = q.where(AuditLog.user_id == user_uuid)
    if event_type: q = q.where(AuditLog.event_type == event_type)
    return q

def _audit_to_dict_with_handles(entry, user_handle, actor_handle) -> dict:
    d = {
        "id": entry.id,
        "event_type": entry.event_type,
        "user_id":   str(entry.user_id)  if entry.user_id  else None,
        "actor_id":  str(entry.actor_id) if entry.actor_id else None,
        "user_handle":  user_handle  or None,
        "actor_handle": actor_handle or None,
        "resource_id": str(entry.resource_id) if entry.resource_id else None,
        "ip_address": str(entry.ip_address) if entry.ip_address else None,
        "metadata_": entry.metadata_,
        "created_at": entry.created_at.isoformat(),
    }
    return d
```

Result rows from a multi-column select in SQLAlchemy 2.0 async are `Row` tuples — access as `row[0]` (AuditLog), `row[1]` (user_handle), `row[2]` (actor_handle). `result.all()` returns `list[Row]`.

Source: `[ASSUMED]` — SQLAlchemy 2.0 aliased JOIN pattern is well-documented but not verified via Context7 in this session.

### Pattern 4: Handle-to-UUID resolution for user filter

```python
# In list_audit_log / export_audit_log endpoint handlers:
user_uuid: Optional[uuid.UUID] = None
if user_handle_param:   # new str param replacing Optional[uuid.UUID]
    handle_result = await session.execute(
        select(User.id).where(User.handle == user_handle_param)
    )
    uid = handle_result.scalar_one_or_none()
    if uid is None:
        # No user with that handle → return empty results (D-12)
        return {"items": [], "total": 0, "page": page, "per_page": per_page}
    user_uuid = uid
```

Source: `[VERIFIED: codebase]` — `User.handle` is a unique indexed column (models.py line 52).

### Pattern 5: Fetch + Blob URL download in Vue 3 (CSV export)

```javascript
// frontend/src/api/client.js — new function
export async function adminExportAuditLogCsv(params = {}) {
  const { useAuthStore } = await import('../stores/auth.js')
  const authStore = useAuthStore()

  const searchParams = new URLSearchParams({ format: 'csv' })
  if (params.start)      searchParams.set('start',      params.start)
  if (params.end)        searchParams.set('end',        params.end)
  if (params.user_handle) searchParams.set('user_handle', params.user_handle)
  if (params.event_type) searchParams.set('event_type', params.event_type)

  const headers = {}
  if (authStore.accessToken) {
    headers['Authorization'] = `Bearer ${authStore.accessToken}`
  }

  const res = await fetch(`/api/admin/audit-log/export?${searchParams}`, {
    headers,
    credentials: 'include',
  })
  if (!res.ok) throw new Error(`Export failed: ${res.status}`)

  const text = await res.text()                   // CSV is text, not JSON
  const blob = new Blob([text], { type: 'text/csv' })
  const url  = URL.createObjectURL(blob)
  const a    = document.createElement('a')
  a.href     = url
  a.download = 'audit-export.csv'
  a.click()
  URL.revokeObjectURL(url)
}
```

This is the same pattern as `fetchDocumentContent()` already in `client.js` — raw `fetch()` with Authorization header, NOT using the shared `request()` wrapper (which always calls `res.json()`).

Source: `[VERIFIED: codebase]` — `fetchDocumentContent` at lines 399-428 of `client.js` is the exact precedent.

### Pattern 6: MinIO daily export listing (asyncio.to_thread)

```python
# api/audit.py — new endpoint
@router.get("/audit-log/daily-exports")
async def list_daily_exports(
    session: AsyncSession = Depends(get_db),
    _admin: User = Depends(get_current_admin),
) -> dict:
    """List available daily audit export files from MinIO audit-logs bucket."""
    from storage import get_storage_backend
    from storage.minio_backend import MinIOBackend

    backend = get_storage_backend()
    if not isinstance(backend, MinIOBackend):
        return {"items": []}

    def _list() -> list[dict]:
        objects = backend._client.list_objects(
            "audit-logs", prefix="audit-logs/", recursive=False
        )
        items = []
        for obj in objects:
            if obj.object_name and obj.object_name.endswith(".csv"):
                # key: "audit-logs/2026-05-30.csv" → date: "2026-05-30"
                filename = obj.object_name.removeprefix("audit-logs/").removesuffix(".csv")
                items.append({"date": filename, "key": obj.object_name})
        items.sort(key=lambda x: x["date"], reverse=True)
        return items

    items = await asyncio.to_thread(_list)
    return {"items": items}
```

`Minio.list_objects()` returns a synchronous iterator; consuming it inside `asyncio.to_thread` blocks the thread (not the event loop). The iterator is lazy — it pages automatically. For fewer than ~1000 objects (years of daily exports) it completes in a single S3 ListObjectsV2 call. `[VERIFIED: codebase]` — confirmed by inspecting Minio SDK `list_objects` signature at runtime; `obj.object_name` and `obj.is_dir` are the key attributes.

### Pattern 7: MinIO daily export streaming download

```python
@router.get("/audit-log/daily-exports/{date}")
async def download_daily_export(
    date: str,
    session: AsyncSession = Depends(get_db),
    _admin: User = Depends(get_current_admin),
) -> StreamingResponse:
    import re, asyncio
    if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
        raise HTTPException(status_code=404, detail="Invalid date format")

    from storage import get_storage_backend
    backend = get_storage_backend()
    key = f"audit-logs/{date}.csv"

    def _get() -> bytes:
        response = backend._client.get_object("audit-logs", key)
        try:
            return response.read()
        finally:
            response.close()
            response.release_conn()

    try:
        csv_bytes = await asyncio.to_thread(_get)
    except Exception:
        raise HTTPException(status_code=404, detail="Export not found")

    return StreamingResponse(
        iter([csv_bytes]),
        media_type="text/csv",
        headers={"Content-Disposition": f'attachment; filename="audit-{date}.csv"'},
    )
```

Note: The `get_object` call uses `"audit-logs"` bucket (separate from documents bucket), mirroring `audit_tasks.py`. Date path parameter must be validated against `YYYY-MM-DD` regex to prevent path traversal.

Source: `[VERIFIED: codebase]` — `MinIOBackend.get_object()` pattern at lines 110-121 of `minio_backend.py`.

### Anti-Patterns to Avoid

- **Calling `_backend()` singleton for cloud deletes:** `_backend()` in `services/storage.py` always returns MinIOBackend. Cloud docs must use `get_storage_backend_for_document()`. Using the singleton for cloud docs would silently succeed (MinIO would return no error for a key that doesn't exist there) while leaving the cloud file orphaned.
- **Defining `PATCH /{share_id}` AFTER `DELETE /{share_id}`:** FastAPI routes earlier-defined paths first. A `PATCH /{share_id}` defined after `DELETE /{share_id}` on the same router will work correctly for PATCH (different HTTP method). The ordering concern only affects routes with overlapping path patterns (e.g., `/received` vs `/{share_id}` GET routes). For PATCH vs DELETE on `/{share_id}`, method discrimination is unambiguous. The existing comment in `shares.py` confirms `/received` must come before `/{share_id}` for GET routes only.
- **Calling `res.json()` for CSV download:** The existing `request()` function in `client.js` always calls `res.json()` and will throw on `text/csv` responses. Any CSV or binary endpoint MUST use a separate function that calls `res.text()` or `res.blob()`.
- **Quota decrement on cloud document delete:** Cloud docs never touched quota at upload time — decrementing quota on delete would corrupt the counter. The `skip_quota` guard must be explicit.
- **Passing `user_id` as a UUID query param for handle-based filter:** The current endpoint signature accepts `Optional[uuid.UUID]` via Query, which causes FastAPI to 422 on non-UUID strings. Changing to `Optional[str]` and doing handle resolution in the handler body is required.
- **Blocking the event loop with Minio iterator:** `list_objects()` is synchronous. Consuming it directly in an async handler without `asyncio.to_thread` would block the FastAPI event loop.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Cloud backend instantiation | Custom credential decrypt + backend init | `get_storage_backend_for_document(doc, user, session)` | Already handles HKDF decrypt, lazy imports, 503 on inactive connection |
| HKDF key derivation | Custom Fernet wrapper | `storage.cloud_utils.decrypt_credentials()` | One implementation, already tested |
| Streaming CSV response | Custom write loop | `io.StringIO` + `csv.DictWriter` + `StreamingResponse(iter([output.getvalue()]))` | Exact pattern already in `api/audit.py:export_audit_log` |
| Blob download trigger | Custom download UI | `Blob` + `URL.createObjectURL` + `<a>` click | Exact pattern used by `fetchDocumentContent` in `client.js` |
| MinIO audit-logs GET | Custom HTTP call | `MinIOBackend._client.get_object("audit-logs", key)` in `asyncio.to_thread` | Already used for doc content; same `response.read()` + `response.close()` pattern |

---

## Common Pitfalls

### Pitfall 1: delete_document() quota decrement for cloud docs
**What goes wrong:** The existing `delete_document()` in `services/storage.py` always runs `UPDATE quotas SET used_bytes = ...`. Cloud documents were never counted against quota at upload time (`api/documents.py:upload_cloud()` does not call the confirm endpoint that updates quota). Decrementing quota on cloud doc delete would underflow the counter.
**Why it happens:** The service was written for MinIO only. The quota decrement was correct when there were no cloud docs.
**How to avoid:** Add `skip_quota: bool = False` parameter. Set to `True` when `doc.storage_backend != "minio"`. Alternatively, perform the DB delete separately for cloud docs without calling `delete_document()` at all — use the admin.py pattern.
**Warning signs:** `quotas.used_bytes` becomes negative in testing after cloud doc delete.

### Pitfall 2: Cloud delete success even though the file remains
**What goes wrong:** MinIO's `remove_object()` is a no-op for non-existent keys. If cloud backend routing is broken and the call falls through to the MinIO singleton, it will return without error while leaving the cloud file intact.
**Why it happens:** MinIO SDK does not raise on missing keys by default.
**How to avoid:** Log the `doc.storage_backend` value before the delete call during testing. Assert in tests that cloud backend's `delete_object` mock was called, not MinIO's.

### Pitfall 3: PATCH route ordering / path conflict
**What goes wrong:** If a PATCH endpoint at `/{share_id}` is somehow confused with another route.
**Why it happens:** FastAPI path routing is order-dependent for same-method same-prefix routes.
**How to avoid:** For `PATCH /{share_id}`, no conflict exists with the current `GET /received` special case (different method). Simply add PATCH before DELETE as a matter of style.

### Pitfall 4: AuditLog COUNT query broken after JOIN
**What goes wrong:** The count query `select(func.count()).select_from(base_q.subquery())` works when `base_q` selects a single entity. After adding a multi-column JOIN, the subquery shape changes and the count may become a count of tuples rather than a count of AuditLog rows.
**Why it happens:** SQLAlchemy 2.0 multi-column select wrapped in a subquery; `func.count()` on an ambiguous subquery.
**How to avoid:** Use a separate count query that does NOT join User: `select(func.count(AuditLog.id)).where(<same filters>)`. Keep the count query and the data query separate.

### Pitfall 5: `res.text()` vs `res.blob()` for CSV in Vue
**What goes wrong:** Calling `res.json()` on a CSV response raises a JSON parse error.
**Why it happens:** The shared `request()` wrapper always calls `res.json()`.
**How to avoid:** The CSV download function must be a standalone `fetch()` call (not via `request()`) that calls `res.text()`. This mirrors the existing `fetchDocumentContent` pattern exactly.

### Pitfall 6: Date path parameter traversal in daily-export download
**What goes wrong:** `GET /api/admin/audit-log/daily-exports/../../etc/passwd` could construct a malicious MinIO key.
**Why it happens:** Path parameters are URL-decoded before reaching the handler.
**How to avoid:** Validate `date` against `r"\d{4}-\d{2}-\d{2}"` regex before constructing `f"audit-logs/{date}.csv"`. FastAPI path parameters with slashes are unusual, but hyphens can still be used in injection attempts.

### Pitfall 7: `_audit_to_dict()` called from two places (viewer + export)
**What goes wrong:** If only the viewer endpoint is updated to use the new handle-enriched function, the CSV export still emits raw UUIDs.
**Why it happens:** The existing `_audit_to_dict()` is shared by both endpoints. Both must be updated to the new function signature.
**How to avoid:** Update `_audit_to_dict_with_handles` is used in BOTH `list_audit_log` and `export_audit_log`. Preserve the original `_audit_to_dict` as a fallback only if needed.

---

## Code Examples

### SQLAlchemy 2.0 async aliased double-JOIN (verified pattern)

```python
# Source: [VERIFIED: codebase] — mirrors Share→User join at shares.py:155-161
from sqlalchemy.orm import aliased
from sqlalchemy import select

UserSubject = aliased(User)
UserActor   = aliased(User)

stmt = (
    select(AuditLog, UserSubject.handle.label("user_handle"), UserActor.handle.label("actor_handle"))
    .outerjoin(UserSubject, UserSubject.id == AuditLog.user_id)
    .outerjoin(UserActor,   UserActor.id   == AuditLog.actor_id)
    .order_by(AuditLog.created_at.desc())
)
result = await session.execute(stmt)
rows = result.all()
for row in rows:
    entry, user_handle, actor_handle = row
```

### Existing cloud-backend pattern (admin.py reference)

```python
# Source: [VERIFIED: codebase] — admin.py lines 527-539
for doc in cloud_docs:
    try:
        backend = await get_storage_backend_for_document(doc, user, session)
        await backend.delete_object(doc.object_key)
    except Exception:
        pass  # best-effort; deletion proceeds regardless
```

### Existing fetch+Blob pattern (client.js reference)

```javascript
// Source: [VERIFIED: codebase] — client.js lines 399-428 (fetchDocumentContent)
const res = await fetch(`/api/documents/${docId}/content`, {
  headers: { 'Authorization': `Bearer ${authStore.accessToken}` },
  credentials: 'include',
})
// For CSV, use res.text() instead of res.blob()
const text = await res.text()
const blob = new Blob([text], { type: 'text/csv' })
const url  = URL.createObjectURL(blob)
// ... <a> click + revokeObjectURL
```

### Existing StreamingResponse for CSV (audit.py reference)

```python
# Source: [VERIFIED: codebase] — audit.py lines 158-162
return StreamingResponse(
    iter([output.getvalue()]),
    media_type="text/csv",
    headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
)
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `window.location.href` for auth downloads | `fetch()` + Blob URL | This phase | Enables Bearer token on every request |
| `user_id` UUID filter in audit log | `user_handle` string filter | This phase | Human-friendly; no UUID knowledge required |
| Audit log shows raw UUIDs | Shows `user_handle` / `actor_handle` | This phase | Admin can read who did what without cross-referencing user list |
| Cloud delete only removes DB record | Cloud delete propagates to provider | This phase | CLOUD provider storage reclaimed; no orphaned files |

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | SQLAlchemy 2.0 multi-column aliased JOIN returns `Row` tuples accessible as `row[0]`, `row[1]`, `row[2]` | Architecture Patterns §3 | Query rows parsed incorrectly; `_audit_to_dict_with_handles` receives wrong arguments |
| A2 | `Minio.list_objects()` iterator is safe to consume inside `asyncio.to_thread` without additional threading concerns | Architecture Patterns §6 | If the SDK has internal async state, `to_thread` could cause issues — unlikely given SDK is sync-only |
| A3 | `is_dir` objects from `list_objects` can be filtered by checking `obj.object_name.endswith(".csv")` | Architecture Patterns §6 | Directory marker objects (is_dir=True) have object_name ending in "/"; `.endswith(".csv")` check correctly excludes them |

**If this table is empty:** All claims in this research were verified or cited — no user confirmation needed.

---

## Open Questions (RESOLVED)

1. **Cloud delete partial failure: HTTP status code choice**
   - What we know: D-03 says return a structured error the frontend can distinguish from hard failure.
   - What's unclear: Should the endpoint return HTTP 200 with `{"cloud_delete_failed": true}` or a specific non-error HTTP status like 207 (Multi-Status)?
   - Recommendation: Return HTTP 200 with `{"success": false, "cloud_delete_failed": true, "detail": "..."}`. The frontend checks `data.cloud_delete_failed` to show the warning modal. HTTP 207 is unusual for REST APIs and harder for `client.js` to handle given it calls `res.ok` check. 200 with a distinguishing body flag is simpler and consistent with the existing pattern where `ok = False` from `storage.delete_document` returns 404 — this case is distinct (partial success).

2. **Audit export query param rename: `user_id` → `user_handle`**
   - What we know: The current endpoint accepts `user_id: Optional[uuid.UUID]` via Query. Changing this to `Optional[str]` changes the public API.
   - What's unclear: Existing tests pass a UUID string as `user_id`; if the param is renamed to `user_handle`, tests need updating.
   - Recommendation: Keep `user_id` as the query param name but change its type to `Optional[str]`. If the value parses as a valid UUID, treat it as a direct UUID filter (backward compat). Otherwise treat as a handle. This avoids breaking any existing automation. Or, add `user_handle` as a new param alongside `user_id` and deprecate `user_id`. The decision affects the existing `test_audit.py` tests.

---

## Environment Availability

No external tools or services beyond what the project already uses. All dependencies are present.

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Minio Python SDK | Daily export listing/streaming | Yes | current | — |
| FastAPI | PATCH endpoint, query params | Yes | 0.136+ | — |
| SQLAlchemy async | Handle JOIN | Yes | 2.x | — |
| Vue 3 / Pinia | Frontend changes | Yes | 3.x | — |

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | pytest + pytest-asyncio (backend); Vitest 4.1.7 (frontend) |
| Config file | `backend/pytest.ini` or `backend/pyproject.toml` |
| Quick run command | `cd backend && pytest tests/test_shares.py tests/test_audit.py -x -q` |
| Full suite command | `cd backend && pytest -v` |

Current baseline: **310 passed, 1 pre-existing failure** (`test_extractor.py::test_extract_docx` — unrelated ModuleNotFoundError), 5 skipped, 10 xfailed.

### Phase Requirements to Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SHARE-03 | POST /api/shares respects `permission` field | integration | `pytest tests/test_shares.py::test_share_create_with_permission -x` | Wave 0 gap |
| SHARE-03 | PATCH /api/shares/{id} changes permission | integration | `pytest tests/test_shares.py::test_share_patch_permission -x` | Wave 0 gap |
| SHARE-03 | PATCH /api/shares/{id} wrong owner → 404 | integration | `pytest tests/test_shares.py::test_share_patch_idor -x` | Wave 0 gap |
| SHARE-05 | `is_shared` field (not `share_count`) drives badge | unit/integration | `pytest tests/test_shares.py::test_share_indicator_in_owner_list -x` | Exists (passes) |
| CLOUD-del | delete_document routes to cloud backend | integration (mock) | `pytest tests/test_documents.py::test_delete_cloud_document_propagates -x` | Wave 0 gap |
| CLOUD-del | Cloud delete failure returns structured error | integration (mock) | `pytest tests/test_documents.py::test_delete_cloud_document_failure -x` | Wave 0 gap |
| CLOUD-del | remove_only=true skips cloud, removes DB | integration (mock) | `pytest tests/test_documents.py::test_delete_cloud_remove_only -x` | Wave 0 gap |
| ADMIN-06 | Audit log response includes user_handle | integration | `pytest tests/test_audit.py::test_audit_log_includes_user_handle -x` | Wave 0 gap |
| ADMIN-06 | user_handle filter resolves to correct entries | integration | `pytest tests/test_audit.py::test_audit_log_filter_by_handle -x` | Wave 0 gap |
| ADMIN-06 | unknown handle filter returns empty | integration | `pytest tests/test_audit.py::test_audit_log_filter_unknown_handle -x` | Wave 0 gap |
| ADMIN-06 | Daily exports list endpoint returns keys | integration (mock) | `pytest tests/test_audit.py::test_daily_exports_list -x` | Wave 0 gap |
| ADMIN-06 | Daily export download returns CSV bytes | integration (mock) | `pytest tests/test_audit.py::test_daily_export_download -x` | Wave 0 gap |

### Sampling Rate

- **Per task commit:** `cd backend && pytest tests/test_shares.py tests/test_audit.py tests/test_documents.py -x -q`
- **Per wave merge:** `cd backend && pytest -v`
- **Phase gate:** Full suite green (excluding pre-existing `test_extract_docx` failure) before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `tests/test_shares.py::test_share_create_with_permission` — covers SHARE-03 POST permission field
- [ ] `tests/test_shares.py::test_share_patch_permission` — covers SHARE-03 PATCH endpoint
- [ ] `tests/test_shares.py::test_share_patch_idor` — covers IDOR security invariant on PATCH
- [ ] `tests/test_documents.py::test_delete_cloud_document_propagates` — covers cloud delete routing
- [ ] `tests/test_documents.py::test_delete_cloud_document_failure` — covers D-03 structured error response
- [ ] `tests/test_documents.py::test_delete_cloud_remove_only` — covers D-02 remove_only path
- [ ] `tests/test_audit.py::test_audit_log_includes_user_handle` — covers D-11 handle enrichment
- [ ] `tests/test_audit.py::test_audit_log_filter_by_handle` — covers D-12 handle filter
- [ ] `tests/test_audit.py::test_audit_log_filter_unknown_handle` — covers D-12 empty result
- [ ] `tests/test_audit.py::test_daily_exports_list` — covers D-15 listing endpoint
- [ ] `tests/test_audit.py::test_daily_export_download` — covers D-16 streaming endpoint

---

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V4 Access Control | Yes | `share.owner_id == current_user.id` on PATCH (IDOR, 404 on mismatch) |
| V4 Access Control | Yes | `get_current_admin` on all audit-log endpoints including new daily-export ones |
| V5 Input Validation | Yes | `permission` enum validated in Pydantic; `date` regex-validated before MinIO key construction |
| V13 API | Yes | No new public endpoints; all new endpoints gated by existing auth deps |

### Known Threat Patterns

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| IDOR on PATCH /shares/{id} | Elevation of Privilege | `share.owner_id == current_user.id` → 404 on mismatch (T-04-04-02 pattern) |
| Path traversal in daily export date param | Tampering | `re.fullmatch(r"\d{4}-\d{2}-\d{2}", date)` before key construction |
| Token bypass via window.location.href | Info Disclosure | Replace with `fetch()` + `Authorization` header (D-13) |
| Cloud credential exposure in error response | Info Disclosure | Cloud delete exception caught generically; `str(exc)` must NOT include credential data — catch as `Exception`, log internally, return generic message |
| Quota underflow on cloud delete | Tampering | `skip_quota=True` for cloud documents; cloud docs never had quota charged |
| Mass assignment on SharePermissionPatch | Tampering | Minimal Pydantic model with explicit `permission` field only — no `**kwargs` passthrough |

---

## Sources

### Primary (HIGH confidence)

- `[VERIFIED: codebase]` — `backend/services/storage.py` lines 143-179 — `delete_document()` implementation; quota decrement pattern; MinIO-only delete
- `[VERIFIED: codebase]` — `backend/storage/__init__.py` — `get_storage_backend_for_document()` factory; cloud credential routing
- `[VERIFIED: codebase]` — `backend/api/admin.py` lines 527-539 — canonical cloud delete pattern (per-doc) to follow
- `[VERIFIED: codebase]` — `backend/api/shares.py` — existing IDOR pattern; `permission="view"` hardcode at line 97; route ordering constraint
- `[VERIFIED: codebase]` — `backend/api/audit.py` — `_audit_to_dict()` pure function; `_build_filtered_query()`; both endpoints
- `[VERIFIED: codebase]` — `backend/tasks/audit_tasks.py` — key pattern `audit-logs/{date}.csv`; `put_object_raw` usage
- `[VERIFIED: codebase]` — `frontend/src/api/client.js` lines 399-428 — `fetchDocumentContent` as fetch+Blob precedent
- `[VERIFIED: codebase]` — `frontend/src/components/admin/AuditLogTab.vue` lines 185-191 — broken `window.location.href` export
- `[VERIFIED: codebase]` — `frontend/src/components/documents/DocumentCard.vue` line 31 — `share_count > 0` bug
- `[VERIFIED: npm registry]` — Minio Python SDK `list_objects` signature confirmed at runtime; `Object.object_name`, `Object.is_dir` attributes verified

### Secondary (MEDIUM confidence)

- `[ASSUMED]` — SQLAlchemy 2.0 aliased double-JOIN returns `Row` tuples with positional access — documented in SA2.0 changelog but not verified via Context7 in this session

---

## Metadata

**Confidence breakdown:**

- Standard stack: HIGH — all packages already in use; no new dependencies
- Architecture: HIGH — all patterns drawn from existing codebase code; no external documentation required
- Pitfalls: HIGH — each pitfall is backed by specific code paths read directly from the source files
- Test gaps: HIGH — based on reading existing test files and counting what is not yet covered

**Research date:** 2026-05-31
**Valid until:** 2026-07-01 (stable brownfield — no fast-moving dependencies)
