---
phase: "06.2"
plan: "03"
type: execute
wave: 1
depends_on:
  - "06.2-01"
files_modified:
  - backend/api/documents.py
  - backend/services/storage.py
  - frontend/src/views/DocumentView.vue
  - frontend/src/api/client.js
  - backend/tests/test_documents.py
autonomous: true
requirements: []
  # cloud-delete (D-01..D-04) is covered via phase success criteria; no named REQUIREMENTS.md ID
must_haves:
  truths:
    - "Deleting a cloud document calls the cloud provider's delete_object, not MinIO's"
    - "Cloud delete failure returns HTTP 200 with cloud_delete_failed: true in the response body (not a hard 4xx/5xx)"
    - "remove_only=true deletes only the DB row, leaving the cloud file intact, and skips quota decrement"
    - "Cloud document deletes do NOT decrement the user's quota (cloud docs never charged quota at upload)"
    - "Frontend shows CloudDeleteWarningModal when cloud_delete_failed response is received"
    - "User can confirm 'Remove from app' which calls DELETE ?remove_only=true and navigates away"
  artifacts:
    - path: "backend/api/documents.py"
      provides: "cloud-aware delete_document endpoint with remove_only query param"
      contains: "remove_only"
    - path: "backend/services/storage.py"
      provides: "skip_quota guard in delete_document service function"
      contains: "skip_quota"
    - path: "frontend/src/views/DocumentView.vue"
      provides: "CloudDeleteWarningModal inline block; remove_only confirm path"
      contains: "showCloudDeleteWarning"
  key_links:
    - from: "backend/api/documents.py"
      to: "storage.get_storage_backend_for_document()"
      via: "cloud routing before MinIO path"
      pattern: "get_storage_backend_for_document"
    - from: "backend/api/documents.py"
      to: "services/storage.delete_document(skip_quota=True)"
      via: "skip_quota parameter for cloud docs"
      pattern: "skip_quota"
    - from: "frontend/src/views/DocumentView.vue"
      to: "DELETE /api/documents/{id}?remove_only=true"
      via: "confirmRemoveOnly() handler called from modal CTA"
      pattern: "remove_only=true"
---

<objective>
Close the cloud-delete propagation gap in a single vertical slice. The default delete button now propagates to the cloud provider. A structured error response (cloud_delete_failed: true) triggers a warning modal in the frontend. The "Remove from app" path uses ?remove_only=true to delete only the DB record. Cloud docs skip quota decrement.

Purpose: Users who delete cloud-stored documents no longer create orphaned files on the provider. This is a correctness fix: the app claimed to delete documents but only removed the DB row.

Output: Modified api/documents.py (cloud routing + remove_only param), modified services/storage.py (skip_quota guard), modified DocumentView.vue (warning modal + remove_only path), new client.js deleteDocument function variant, three promoted test stubs.
</objective>

<execution_context>
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md
</execution_context>

<context>
@/Users/nik/Documents/Progamming/document_scanner/.planning/ROADMAP.md
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-CONTEXT.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From backend/services/storage.py:delete_document (current signature):
  async def delete_document(session: AsyncSession, doc_id: str) -> bool:
      # Always calls _backend().delete_object() (MinIO singleton)
      # Always runs quota decrement UPDATE
      # Returns False if doc not found, True on success

From backend/storage/__init__.py:
  async def get_storage_backend_for_document(
      document: Document,
      user: User,
      session: AsyncSession,
  ) -> StorageBackend:
      # Returns MinIOBackend for storage_backend == "minio"
      # For cloud docs: loads CloudConnection, decrypts HKDF creds, returns cloud backend
      # Raises HTTPException(503) if connection not found/inactive

From backend/api/admin.py lines 527-539 (canonical cloud delete pattern):
  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

From backend/api/documents.py (existing delete endpoint stub — find the @router.delete("/{doc_id}") handler):
  # Existing handler: calls await storage.delete_document(session, doc_id)
  # Must be extended with remove_only query param and cloud routing

From frontend/src/api/client.js (existing deleteDocument function — search for "deleteDocument"):
  # Current implementation calls DELETE /api/documents/{id} via request() wrapper
  # request() calls res.json() — this is correct for 200 responses
  # New behavior: parse response body for cloud_delete_failed flag

From frontend/src/views/DocumentView.vue (existing confirmDelete pattern — search for "confirmDelete" or "handleDelete"):
  # Existing: window.confirm() then deleteDocument() then router.push('/')
  # New: after API call, check response.cloud_delete_failed — if true, show modal

From .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md C-3:
  Cloud Delete Warning Modal — inline in DocumentView.vue:
    Fixed overlay: bg-black/40 flex items-center justify-center z-50
    Panel: bg-white rounded-2xl shadow-xl p-6 max-w-sm w-full mx-4
    Heading: "Cloud delete failed" (text-lg font-semibold text-gray-900 mb-2)
    Body: "The file could not be deleted from {provider}. Remove it from DocuVault anyway? The file will remain on {provider}."
    Warning icon: Heroicons ExclamationTriangleIcon inline SVG, w-5 h-5 text-amber-500
    Primary CTA: "Remove from app" — bg-red-600 hover:bg-red-700 text-white text-sm px-4 py-2 rounded-lg
    Secondary: "Cancel" — border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50
    role="dialog" aria-modal="true" aria-labelledby="cloud-delete-modal-title"
    @click.self closes modal; Cancel abandons delete; "Remove from app" calls DELETE ?remove_only=true
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Backend — cloud-aware delete routing + skip_quota + remove_only param</name>
  <files>backend/api/documents.py, backend/services/storage.py, backend/tests/test_documents.py</files>
  <read_first>
    - backend/services/storage.py — read lines 143-179 (full delete_document function) to understand current MinIO path and quota decrement logic that must be preserved for MinIO docs
    - backend/api/documents.py — find and read the existing @router.delete("/{doc_id}") handler (search for "router.delete" or "delete_document") to see its current signature and body
    - backend/storage/__init__.py — read get_storage_backend_for_document signature (lines 53-132) to confirm it takes (document: Document, user: User, session: AsyncSession)
    - backend/api/admin.py — read lines 520-545 to see the canonical cloud delete pattern (get_storage_backend_for_document + backend.delete_object in try/except)
    - backend/tests/test_documents.py — read the full file to understand conftest fixtures (async_client, auth_user, db_session) and _make_doc or equivalent helper patterns
    - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md — Pattern 1 (cloud routing preferred in API layer), Pitfall 1 (skip_quota), Pitfall 2 (MinIO no-op on missing keys)
  </read_first>
  <behavior>
    - test_delete_cloud_document_propagates: Create a Document with storage_backend="google_drive". Mock get_storage_backend_for_document to return a mock backend. DELETE /api/documents/{id}. Assert the mock backend's delete_object was called once with the document's object_key. Assert quota UPDATE was NOT executed (cloud docs never charged quota).
    - test_delete_cloud_document_failure: Create a Document with storage_backend="google_drive". Mock get_storage_backend_for_document to return a mock backend whose delete_object raises Exception("provider error"). DELETE /api/documents/{id}. Assert HTTP 200. Assert response body has cloud_delete_failed=True and success=False. Assert the DB row is NOT deleted (doc still exists after the call).
    - test_delete_cloud_remove_only: Create a Document with storage_backend="google_drive". DELETE /api/documents/{id}?remove_only=true WITHOUT mocking the cloud backend. Assert HTTP 200. Assert DB row is deleted. Assert quota UPDATE was NOT executed.
  </behavior>
  <action>
Make two file changes:

CHANGE 1 — backend/services/storage.py: add skip_quota parameter to delete_document():
Change the function signature to `async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool = False) -> bool:`.
Wrap the existing quota decrement block in `if not skip_quota:` so it only runs when skip_quota is False (i.e., for MinIO documents). The MinIO `_backend().delete_object(doc.object_key)` call stays where it is — it is only reached for MinIO docs once the API layer routing is correct (the API layer will handle cloud routing before calling this function).

CHANGE 2 — backend/api/documents.py: add remove_only param + cloud routing to the delete endpoint:
Add `remove_only: bool = Query(default=False)` to the existing delete_document endpoint handler signature.

In the handler body, BEFORE the call to `storage.delete_document()`, add cloud routing logic:

If `doc.storage_backend != "minio"` and `not remove_only`:
  - Try: call `cloud_backend = await get_storage_backend_for_document(doc, current_user, session)`; then `await cloud_backend.delete_object(doc.object_key)`
  - Except Exception: return JSONResponse(status_code=200, content={"success": False, "cloud_delete_failed": True, "detail": "Cloud provider delete failed. You can remove from app only."})
  - (If cloud delete succeeds, fall through to DB delete with skip_quota=True)

If `doc.storage_backend != "minio"` (regardless of remove_only): call `storage.delete_document(session, str(doc.id), skip_quota=True)`
If `doc.storage_backend == "minio"`: call `storage.delete_document(session, str(doc.id), skip_quota=False)` (existing behavior)

The import for `get_storage_backend_for_document` must be added at the top of documents.py (or lazily inside the handler body following the lazy-import pattern already in the file). Also import `JSONResponse` from `fastapi.responses` if not already imported. Add `from fastapi import Query` if not already imported.

CRITICAL: The cloud delete exception handler must NOT include the exception message `str(exc)` in the response body. The generic detail string is sufficient. Log the exception to stderr internally if desired: `print(f"[cloud-delete] provider error: {exc}", file=sys.stderr)`.

Then in backend/tests/test_documents.py: promote the three xfail stubs to real tests using unittest.mock.patch and the async_client fixture pattern established in the existing file. Mock `api.documents.get_storage_backend_for_document` (the import path used in documents.py) or `storage.get_storage_backend_for_document` depending on how the import appears in the delete handler. Use `AsyncMock` for the mock backend's delete_object method.
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_documents.py::test_delete_cloud_document_propagates tests/test_documents.py::test_delete_cloud_document_failure tests/test_documents.py::test_delete_cloud_remove_only -x -v 2>&1 | tail -20</automated>
  </verify>
  <done>
    - All three promoted tests pass
    - `grep "skip_quota" backend/services/storage.py` returns a match
    - `grep "remove_only" backend/api/documents.py` returns a match
    - `grep "cloud_delete_failed" backend/api/documents.py` returns a match
    - `grep "get_storage_backend_for_document" backend/api/documents.py` returns a match
    - `pytest tests/test_documents.py -x -q` exits 0
  </done>
</task>

<task type="auto">
  <name>Task 2: Frontend — CloudDeleteWarningModal + remove_only path in DocumentView</name>
  <files>frontend/src/views/DocumentView.vue, frontend/src/api/client.js</files>
  <read_first>
    - frontend/src/views/DocumentView.vue — read the full file to find: existing confirmDelete (or equivalent delete handler) function, existing router.push('/') navigation, how the document object is loaded, and the template structure where the modal should be inserted
    - frontend/src/api/client.js — search for "deleteDocument" or the function that calls DELETE /api/documents/{id} to understand the current implementation; also read lines 399-428 (fetchDocumentContent) to understand the raw fetch pattern used for authenticated non-JSON responses
    - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md — C-3 component contract for the CloudDeleteWarningModal, Copywriting Contract for exact copy, State Inventory for states required
  </read_first>
  <action>
Make two file changes:

CHANGE 1 — frontend/src/api/client.js:
Find the existing deleteDocument function. Modify it to accept an optional `removeOnly = false` parameter. The delete endpoint call should append `?remove_only=true` to the URL when removeOnly is true: `/api/documents/${docId}?remove_only=true`. The response is always JSON (HTTP 200 for both success and cloud_delete_failed), so keep `res.json()` via the existing `request()` wrapper. The function should return the parsed JSON body (not throw on success) so callers can inspect `cloud_delete_failed`.

If the existing deleteDocument function uses `request()` and it throws on non-2xx, wrap accordingly: HTTP 200 with cloud_delete_failed body is a valid 2xx response so `request()` will return it normally.

Add a second function `deleteDocumentRemoveOnly(docId)` that calls `deleteDocument(docId, true)` — a convenience wrapper for the remove_only path called from the modal CTA.

CHANGE 2 — frontend/src/views/DocumentView.vue:
Add two new reactive refs to the script section:
- `showCloudDeleteWarning` (boolean, default false)
- `cloudProviderName` (string, default 'your cloud storage')

Modify the existing delete handler (confirmDelete or equivalent). After the delete API call, instead of always navigating to '/', check the response:
- If `response.cloud_delete_failed === true`: set `cloudProviderName.value` from document's storage_backend (map "google_drive" → "Google Drive", "onedrive" → "OneDrive", "nextcloud" → "Nextcloud", "webdav" → "WebDAV", fallback to "your cloud storage"); set `showCloudDeleteWarning.value = true`; do NOT navigate
- Otherwise (success): navigate to `/` as before

Add a `confirmRemoveOnly()` async function:
- Call `await api.deleteDocumentRemoveOnly(props.docId)` (or however the document ID is referenced in DocumentView)
- On success: `showCloudDeleteWarning.value = false`; navigate to `/`
- On error: show an inline error message within the modal (reuse existing error display pattern)

Add `cancelCloudDeleteWarning()` function: set `showCloudDeleteWarning.value = false`; abort — document is NOT deleted.

In the template, add the cloud delete warning modal as an inline conditional block (`v-if="showCloudDeleteWarning"`) following the C-3 contract from UI-SPEC:
- Fixed overlay with `@click.self="cancelCloudDeleteWarning"`
- Panel with `role="dialog"` `aria-modal="true"` `aria-labelledby="cloud-delete-modal-title"`
- Heading id="cloud-delete-modal-title": "Cloud delete failed"
- ExclamationTriangleIcon inline SVG (w-5 h-5 text-amber-500): use the Heroicons stroke SVG path for the triangle: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />`
- Body text using cloudProviderName: `The file could not be deleted from {{ cloudProviderName }}. Remove it from DocuVault anyway? The file will remain on {{ cloudProviderName }}.`
- Buttons: "Remove from app" (@click="confirmRemoveOnly") and "Cancel" (@click="cancelCloudDeleteWarning")
- Exact Tailwind classes from UI-SPEC C-3 (bg-red-600, bg-white rounded-2xl, etc.)
  </action>
  <verify>
    <automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && grep -n "showCloudDeleteWarning\|cloud_delete_failed\|removeOnly\|remove_only" src/views/DocumentView.vue src/api/client.js | head -20</automated>
  </verify>
  <done>
    - `grep "showCloudDeleteWarning" frontend/src/views/DocumentView.vue` returns at least 2 matches (ref + template v-if)
    - `grep "cloud-delete-modal-title" frontend/src/views/DocumentView.vue` returns a match
    - `grep "remove_only" frontend/src/api/client.js` returns a match
    - `grep "Remove from app" frontend/src/views/DocumentView.vue` returns a match
    - No console errors when building: `cd frontend && npm run build 2>&1 | grep -i error | head -10` returns empty (or pre-existing errors only)
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → DELETE /api/documents/{id} | remove_only query param is user-supplied; must not bypass ownership checks |
| api/documents.py → cloud backend | cloud credentials must not appear in the error response returned to the browser |
| api/documents.py → services/storage.py | skip_quota flag must be set correctly to prevent quota underflow |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06.2-03-01 | Tampering | Quota underflow on cloud delete | mitigate | `skip_quota=True` passed to `delete_document()` for all non-minio documents; cloud docs never had quota charged at upload |
| T-06.2-03-02 | Information Disclosure | Cloud credential exposure in error response | mitigate | Exception caught as generic `except Exception`; only a fixed string "Cloud provider delete failed." returned to client — `str(exc)` is logged to stderr only, never serialized to JSON response |
| T-06.2-03-03 | Elevation of Privilege | remove_only param bypasses ownership | accept | Ownership assertion (`doc.user_id != current_user.id → 404`) occurs BEFORE the remove_only branch — authenticated user must own the document regardless of query param value |
| T-06.2-03-04 | Spoofing | Silent MinIO no-op for cloud docs | mitigate | Cloud routing happens before any MinIO call for non-minio documents — `get_storage_backend_for_document()` returns the cloud backend, not the MinIO singleton (Pitfall 2 from RESEARCH.md) |
| T-06.2-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>

<verification>
After both tasks complete:

```
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_documents.py -x -q
```

Expected: exits 0, 3 promoted cloud-delete tests pass, all pre-existing tests still pass.

```
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest -v 2>&1 | tail -20
```

Expected: zero failures.

Frontend build check:
```
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | grep -c "error" || echo "0 errors"
```
</verification>

<success_criteria>
- DELETE /api/documents/{id} for a cloud doc calls cloud backend delete_object — confirmed by test_delete_cloud_document_propagates
- Cloud delete failure returns HTTP 200 with cloud_delete_failed=True — confirmed by test_delete_cloud_document_failure
- remove_only=true skips cloud, removes DB row, skips quota decrement — confirmed by test_delete_cloud_remove_only
- Cloud doc deletes never decrement quota (skip_quota=True path)
- DocumentView.vue shows CloudDeleteWarningModal when cloud_delete_failed is received
- "Remove from app" calls DELETE ?remove_only=true and navigates to /
- All test_documents.py tests pass
</success_criteria>

<output>
Create `.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-03-SUMMARY.md` when done.
</output>
