Files
curo1305 4adc77d8cc docs(06.2): create 4-plan phase covering SHARE-03, SHARE-05, cloud-delete, ADMIN-06
Wave 0: 11 xfail stubs across test_shares/test_documents/test_audit
Wave 1 (parallel): SHARE-05 badge + SHARE-03 permission control; cloud-delete propagation
Wave 2: audit handle enrichment, user_handle filter, CSV fetch+Blob, daily-export UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:36:33 +02:00

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06.2 04 execute 2
06.2-02
06.2-03
backend/api/audit.py
frontend/src/components/admin/AuditLogTab.vue
frontend/src/api/client.js
backend/tests/test_audit.py
true
ADMIN-06
truths artifacts key_links
Audit log JSON viewer returns user_handle and actor_handle alongside user_id and actor_id
GET /api/admin/audit-log?user_handle=X filters to entries for that user
GET /api/admin/audit-log?user_handle=nonexistent returns empty items list, not 422
CSV export button in AuditLogTab downloads a file via fetch+Blob (not window.location.href)
GET /api/admin/audit-log/daily-exports returns sorted list of available export dates
GET /api/admin/audit-log/daily-exports/{date} streams the CSV for that date
Daily exports section in AuditLogTab shows date dropdown + Download button
Date path parameter validated against YYYY-MM-DD regex before MinIO key construction
path provides contains
backend/api/audit.py handle-enriched query; user_handle filter; two daily-export endpoints _audit_to_dict_with_handles
path provides contains
frontend/src/api/client.js adminExportAuditLogCsv(), adminListDailyExports(), adminDownloadDailyExport() adminExportAuditLogCsv
path provides contains
frontend/src/components/admin/AuditLogTab.vue fixed exportCsv(), daily exports section, user_handle filter label Daily exports
from to via pattern
backend/api/audit.py list_audit_log User table (aliased twice) outerjoin on user_id and actor_id FKs outerjoin.*UserSubject|outerjoin.*UserActor
from to via pattern
backend/api/audit.py list_daily_exports MinIO audit-logs bucket asyncio.to_thread(_list) asyncio.to_thread
from to via pattern
frontend/src/components/admin/AuditLogTab.vue:exportCsv adminExportAuditLogCsv() in client.js fetch() + Blob URL — no window.location.href adminExportAuditLogCsv
Close the ADMIN-06 gaps in a single vertical slice: user handles in audit log responses, handle-based filter, fixed CSV export download, and a new daily-export listing + download UI.

Purpose: Admins can now see who performed actions by name (not UUID), filter by handle without 422 errors, download exports that actually arrive (not a 401 from window.location.href), and access the Celery-generated daily export files from the admin panel.

Output: Modified audit.py (handle JOIN, user_handle filter, two new endpoints), modified AuditLogTab.vue (filter label, fetch+Blob exportCsv, daily-export section), new client.js functions, five promoted test stubs.

<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>

@/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

From backend/api/audit.py (current state): def audit_to_dict(entry: AuditLog) -> dict: # Returns: id, event_type, user_id, actor_id, resource_id, ip_address, metadata, created_at # Does NOT return user_handle or actor_handle

def _build_filtered_query(start, end, user_id: Optional[uuid.UUID], event_type): # Accepts user_id as UUID type — FastAPI validates this via Query(Optional[uuid.UUID]) # This type annotation causes FastAPI to 422 on non-UUID strings

@router.get("/audit-log") async def list_audit_log( user_id: Optional[uuid.UUID] = Query(default=None), # BUG: must change to Optional[str] ... )

@router.get("/audit-log/export") async def export_audit_log( user_id: Optional[uuid.UUID] = Query(default=None), # BUG: same fix needed ... )

Both endpoints must be updated to accept user_handle: Optional[str]

From backend/db/models.py (User model — key fields): User.id: UUID User.handle: str (unique, indexed)

From backend/tasks/audit_tasks.py line 79: key = f"audit-logs/{yesterday.isoformat()}.csv"

MinIO bucket: "audit-logs"

Key pattern: "audit-logs/YYYY-MM-DD.csv"

From backend/storage/__init__.py: def get_storage_backend() -> StorageBackend: # Returns MinIOBackend; has ._client attribute (Minio SDK instance)

From backend/storage/minio_backend.py:

_client: Minio SDK instance

_client.list_objects(bucket, prefix, recursive) → synchronous iterator

_client.get_object(bucket, key) → response with .read() and .release_conn()

From frontend/src/api/client.js (existing patterns):

request() wrapper: always calls res.json() — NOT for CSV responses

fetchDocumentContent() at lines 399-428: raw fetch() pattern with Authorization header

export async function fetchDocumentContent(docId, options = {})

From frontend/src/components/admin/AuditLogTab.vue (current state):

filters reactive object: { start, end, user_id, event_type }

exportCsv() at lines 185-192: uses window.location.href (broken)

fetchLog() sends user_id: filters.user_id to adminListAuditLog()

Table renders: entry.user_handle || entry.user_id || '—' (line 89 — already expects handle)

From .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md: C-4: Daily Exports Section — below pagination block, border-t separator C-5: User filter label change from "User" to "User handle" Copywriting: section label "Daily exports", dropdown label "Select date", button "Download"

Task 1: Backend — handle enrichment, user_handle filter, two daily-export endpoints backend/api/audit.py, backend/tests/test_audit.py - backend/api/audit.py — read the full file; understand _audit_to_dict(), _build_filtered_query(), both existing endpoints and their exact Query parameter signatures; understand how both endpoints share _build_filtered_query - backend/db/models.py — search for "class User" and "class AuditLog" to confirm handle field and user_id/actor_id FK field names - backend/storage/__init__.py — read lines 32-50 (get_storage_backend factory) to understand how to get the MinIOBackend instance for the daily-export endpoints; confirm _client attribute - backend/tasks/audit_tasks.py — read lines 78-86 to confirm the MinIO bucket name ("audit-logs") and key pattern ("audit-logs/YYYY-MM-DD.csv") - backend/tests/test_audit.py — read the full file to understand _seed_audit helper, admin_user fixture, and existing test patterns before promoting stubs - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md — Pattern 3 (aliased double-JOIN), Pattern 4 (handle-to-UUID resolution), Pattern 6 (list_objects), Pattern 7 (daily export streaming), Pitfall 4 (COUNT query breaks after JOIN), Pitfall 6 (date regex), Pitfall 7 (both endpoints must use enriched function) - test_audit_log_includes_user_handle: Seed an audit entry for admin_user. GET /api/admin/audit-log. Assert each item in items has keys "user_handle" and "actor_handle". Assert the first item's user_handle matches admin_user["user"].handle (not None for a seeded entry). - test_audit_log_filter_by_handle: Seed one entry for admin_user. Seed one entry for a second distinct user. GET /api/admin/audit-log?user_handle={admin_user.handle}. Assert items contains only entries matching admin_user (user_handle == admin_user.handle). Seeded second entry must not appear. - test_audit_log_filter_unknown_handle: GET /api/admin/audit-log?user_handle=definitely_does_not_exist. Assert status 200. Assert response body items == []. Assert total == 0. Assert no 422 error. - test_daily_exports_list: Mock MinIOBackend._client.list_objects to return fake objects (or patch get_storage_backend and its _client). GET /api/admin/audit-log/daily-exports. Assert status 200. Assert response has "items" key. Items sorted descending by date. - test_daily_export_download: Mock MinIOBackend._client.get_object to return fake CSV bytes. GET /api/admin/audit-log/daily-exports/2026-05-30. Assert status 200. Assert Content-Type: text/csv. Assert Content-Disposition header contains "2026-05-30". Also test GET /api/admin/audit-log/daily-exports/invalid-date returns 404. Make these changes to backend/api/audit.py:

CHANGE 1 — Add SQLAlchemy aliased imports and User import check: Add from sqlalchemy.orm import aliased to the imports if not already present. Confirm User is already imported from db.models.

CHANGE 2 — New helper _audit_to_dict_with_handles(): Add a new function _audit_to_dict_with_handles(entry: AuditLog, user_handle: Optional[str], actor_handle: Optional[str]) -> dict that returns the same dict as _audit_to_dict(entry) PLUS two additional keys: "user_handle": user_handle or None and "actor_handle": actor_handle or None. Do NOT remove or rename _audit_to_dict — preserve it as a fallback.

CHANGE 3 — New query builder _build_filtered_query_with_handles(): Add function _build_filtered_query_with_handles(start, end, user_uuid, event_type) that builds a multi-column 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())
)

Apply the same start/end/user_uuid/event_type filters as the original _build_filtered_query. Return the statement. This is a standalone function, NOT replacing _build_filtered_query (the old function stays for the count query — see Pitfall 4).

CHANGE 4 — Update list_audit_log endpoint: Change user_id: Optional[uuid.UUID] = Query(default=None) to user_handle: Optional[str] = Query(default=None).

Add handle-to-UUID resolution logic before executing the main query (Pattern 4 from RESEARCH.md):

user_uuid: Optional[uuid.UUID] = None
if user_handle:
    result = await session.execute(select(User.id).where(User.handle == user_handle))
    uid = result.scalar_one_or_none()
    if uid is None:
        return {"items": [], "total": 0, "page": page, "per_page": per_page}
    user_uuid = uid

For the count query, use the ORIGINAL _build_filtered_query(start, end, user_uuid, event_type) to avoid the COUNT subquery problem (Pitfall 4). Count query is unchanged.

For the data query, use _build_filtered_query_with_handles(start, end, user_uuid, event_type). Add .limit(per_page).offset((page - 1) * per_page). Execute. Iterate result.all() as tuples: for row in rows: entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]. Build each item with _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val).

CHANGE 5 — Update export_audit_log endpoint: Apply the same user_handle→user_uuid resolution (identical block as above). Use _build_filtered_query_with_handles for the data query. Iterate rows as tuples. Use _audit_to_dict_with_handles for CSV serialization. Add "user_handle" and "actor_handle" to the fields list for the CSV DictWriter. This satisfies Pitfall 7 (both endpoints must use enriched function).

CHANGE 6 — Add two new endpoints for daily exports: Before the existing endpoints, add necessary imports: import asyncio, import re. The StreamingResponse import should already be present.

Add endpoint @router.get("/audit-log/daily-exports"):

  • Auth: _admin: User = Depends(get_current_admin)
  • No session param needed (MinIO call only)
  • Body: get the MinIO backend via from storage import get_storage_backend; from storage.minio_backend import MinIOBackend; backend = get_storage_backend(). If not MinIOBackend, return {"items": []}.
  • Define inner _list() -> list[dict] function (synchronous) that calls backend._client.list_objects("audit-logs", prefix="audit-logs/", recursive=False), iterates objects, filters .endswith(".csv"), extracts date from obj.object_name.removeprefix("audit-logs/").removesuffix(".csv"), builds {"date": date_str, "key": obj.object_name}, sorts by date descending.
  • Execute: items = await asyncio.to_thread(_list)
  • Return {"items": items}

Add endpoint @router.get("/audit-log/daily-exports/{date}"):

  • Auth: _admin: User = Depends(get_current_admin)
  • Path param: date: str
  • Date validation (Pitfall 6 / D-16): if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): raise HTTPException(404, "Invalid date format")
  • Get backend, construct key = f"audit-logs/{date}.csv"
  • Define inner _get() -> bytes (synchronous): response = backend._client.get_object("audit-logs", key); try: return response.read(); finally: response.close(); response.release_conn()
  • Execute: wrap in try: csv_bytes = await asyncio.to_thread(_get); except Exception: raise HTTPException(404, "Export not found")
  • Return StreamingResponse(iter([csv_bytes]), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="audit-{date}.csv"'})

CRITICAL: The two new endpoints must be placed BEFORE the existing @router.get("/audit-log/export") and @router.get("/audit-log") in the file, because FastAPI routes are matched in registration order. The path /audit-log/daily-exports is more specific than /audit-log and must be registered first. Or, at minimum, place them before the @router.get("/audit-log") GET handler.

Then in backend/tests/test_audit.py: promote all five xfail stubs. Use unittest.mock.patch to mock storage.get_storage_backend for the daily-export endpoint tests, returning a mock MinIOBackend with a _client mock. cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_audit.py::test_audit_log_includes_user_handle tests/test_audit.py::test_audit_log_filter_by_handle tests/test_audit.py::test_audit_log_filter_unknown_handle tests/test_audit.py::test_daily_exports_list tests/test_audit.py::test_daily_export_download -x -v 2>&1 | tail -25 - All five promoted tests pass - grep "_audit_to_dict_with_handles" backend/api/audit.py returns at least 2 matches (definition + both endpoint usages — Pitfall 7) - grep "user_handle" backend/api/audit.py returns at least 4 matches - grep "daily-exports" backend/api/audit.py returns 2 matches (two new endpoints) - grep "fullmatch" backend/api/audit.py returns a match (date regex validation) - pytest tests/test_audit.py -x -q exits 0

Task 2: Frontend — user_handle filter, fetch+Blob export, daily-export section frontend/src/components/admin/AuditLogTab.vue, frontend/src/api/client.js - frontend/src/components/admin/AuditLogTab.vue — read the full file; understand filters reactive object (filters.user_id must become filters.user_handle), fetchLog() which passes params to adminListAuditLog(), exportCsv() (broken window.location.href on lines 185-192), pagination block location (where to add the new daily-export section below it) - frontend/src/api/client.js — read lines 395-435 (fetchDocumentContent — the fetch+Blob reference pattern); search for "adminListAuditLog" to find its current implementation; note that request() wrapper always calls res.json() and must NOT be used for CSV responses - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md — Pattern 5 (fetch+Blob URL for CSV), Pattern 6 (adminListDailyExports signature), Pattern 7 (adminDownloadDailyExport) - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md — C-4 (daily exports section markup), C-5 (user filter label), Copywriting Contract (section copy), State Inventory (loading/empty/populated states) Make two file changes:

CHANGE 1 — frontend/src/api/client.js: add three new functions Follow the exact fetch+Blob pattern from fetchDocumentContent (lines 399-428) — NOT using the request() wrapper.

Add adminExportAuditLogCsv(params = {}):

  • Import useAuthStore lazily (same pattern as fetchDocumentContent)
  • Build URLSearchParams with format=csv; add start, end, event_type if provided; add user_handle if provided (NOT user_id — the backend param is now user_handle)
  • Raw fetch to /api/admin/audit-log/export?${searchParams} with Authorization Bearer header and credentials: 'include'
  • On !res.ok: throw Error(Export failed: ${res.status})
  • const text = await res.text() (NOT res.json())
  • Create Blob([text], { type: 'text/csv' }), URL.createObjectURL, create <a> element, set href + download='audit-export.csv', click, URL.revokeObjectURL

Add adminListDailyExports():

  • Raw fetch to /api/admin/audit-log/daily-exports with Authorization Bearer header
  • On !res.ok: throw Error
  • Return await res.json() — this endpoint returns JSON

Add adminDownloadDailyExport(date):

  • Raw fetch to /api/admin/audit-log/daily-exports/${date} with Authorization Bearer header and credentials: 'include'
  • On !res.ok: throw Error(Download failed: ${res.status})
  • const text = await res.text()
  • Blob + URL.createObjectURL + <a> click with download=audit-${date}.csv + revokeObjectURL

CHANGE 2 — frontend/src/components/admin/AuditLogTab.vue: three UI changes

CHANGE 2a — User filter label and binding (per D-12, C-5): In the filters reactive object, rename user_id: '' to user_handle: ''. In the fetchLog() function, change user_id: filters.user_id || undefined to user_handle: filters.user_handle || undefined. In the template filter bar, change the label text from "User" to "User handle". Change v-model="filters.user_id" to v-model="filters.user_handle". Update adminListAuditLog() call to pass user_handle not user_id (check the existing call signature in fetchLog).

CHANGE 2b — Fix exportCsv() (per D-13): Replace the entire body of function exportCsv() with an async call to api.adminExportAuditLogCsv({...}). Change the function declaration to async function exportCsv(). Pass current filter values: start: filters.start || undefined, end: filters.end || undefined, user_handle: filters.user_handle || undefined, event_type: filters.event_type || undefined. Add a ref exportingCsv (boolean, default false) and set it true/false around the call. On error: show an alert or set an error ref with "Export failed. Please try again."

CHANGE 2c — Add daily exports section (per D-17, C-4 from UI-SPEC): Add new reactive state in script setup:

  • dailyExports ref (Array, default [])
  • loadingExports ref (boolean, default false)
  • selectedExportDate ref (string, default '')
  • downloadingExport ref (boolean, default false)
  • exportsError ref (string, default null)

Add loadDailyExports() async function that calls await api.adminListDailyExports() and populates dailyExports.value from data.items. Set loadingExports accordingly. Call loadDailyExports() inside onMounted() alongside the existing fetchLog() call.

Add downloadDailyExport() async function that calls await api.adminDownloadDailyExport(selectedExportDate.value). Set downloadingExport true/false. On error: set exportsError.value = "Download failed. Please try again.".

In the template, add the daily-export section below the pagination block, following C-4 markup from UI-SPEC:

  • Section separator: <div class="border-t border-gray-100 mt-6 pt-6">
  • Section label: <h3 class="text-sm font-semibold text-gray-700 mb-3">Daily exports</h3>
  • Loading state: <p v-if="loadingExports" class="text-sm text-gray-400">Loading exports…</p>
  • Empty state: <p v-else-if="dailyExports.length === 0" class="text-sm text-gray-400 italic">No daily exports available.</p>
  • Controls row (v-else): <div class="flex items-end gap-3">
    • <select v-model="selectedExportDate" class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white">
      • <option value="" disabled>Choose a date</option>
      • <option v-for="exp in dailyExports" :key="exp.date" :value="exp.date">{{ exp.date }}</option>
    • <button @click="downloadDailyExport" :disabled="!selectedExportDate || downloadingExport" class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors">
      • Loading spinner inline when downloadingExport (same animate-spin pattern as ShareModal)
      • "Download" text otherwise
  • Error display: <p v-if="exportsError" class="text-xs text-red-600 mt-2">{{ exportsError }}</p> cd /Users/nik/Documents/Progamming/document_scanner/frontend && grep -n "adminExportAuditLogCsv|adminListDailyExports|adminDownloadDailyExport" src/api/client.js | head -10
    • grep "adminExportAuditLogCsv" frontend/src/api/client.js returns a match
    • grep "adminListDailyExports" frontend/src/api/client.js returns a match
    • grep "adminDownloadDailyExport" frontend/src/api/client.js returns a match
    • grep "window.location.href" frontend/src/components/admin/AuditLogTab.vue returns NO match (broken export removed)
    • grep "Daily exports" frontend/src/components/admin/AuditLogTab.vue returns a match
    • grep "User handle" frontend/src/components/admin/AuditLogTab.vue returns a match
    • grep "user_handle" frontend/src/components/admin/AuditLogTab.vue returns at least 2 matches (filter binding + fetchLog param)
    • No build errors: cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>" | head -10 returns empty

<threat_model>

Trust Boundaries

Boundary Description
browser → GET /api/admin/audit-log/daily-exports/{date} date path param is user-supplied; must not allow MinIO key injection
api/audit.py → MinIO asyncio.to_thread isolates sync SDK from the async event loop
AuditLogTab → /api/admin/audit-log/export fetch() must carry Bearer header; window.location.href cannot

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06.2-04-01 Tampering Date path parameter injection mitigate re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) validates before f"audit-logs/{date}.csv" key construction — rejects any non-date string including path traversal sequences (Pitfall 6 from RESEARCH.md)
T-06.2-04-02 Elevation of Privilege Unauthenticated daily-export access mitigate Both new endpoints use _admin: User = Depends(get_current_admin) — regular users receive 403, unauthenticated receive 401
T-06.2-04-03 Information Disclosure Audit log CSV token bypass via window.location.href mitigate exportCsv() replaced with fetch()+Blob pattern that sends Authorization Bearer header — no unauthenticated CSV download possible
T-06.2-04-04 Information Disclosure user_handle in audit response leaks PII accept handle is already public within the platform (users are identified by handle in sharing UI); admin view of handles is consistent with existing admin privileges
T-06.2-04-05 Denial of Service list_objects blocking event loop mitigate asyncio.to_thread(_list) wraps synchronous Minio iterator — event loop is not blocked
T-06.2-SC Tampering npm/pip/cargo installs accept No new packages installed in this plan
</threat_model>
After both tasks complete:
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_audit.py -x -q

Expected: exits 0, all 9 tests pass (4 pre-existing + 5 promoted).

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

Expected: zero failures.

Phase gate — full suite:

cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest -v 2>&1 | grep -E "passed|failed|error" | tail -5

Frontend:

cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>" | head -10

Security spot-checks:

grep "window.location.href" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue
# Expected: no output (bug removed)

grep "fullmatch" /Users/nik/Documents/Progamming/document_scanner/backend/api/audit.py
# Expected: matches the date regex line

grep "get_current_admin" /Users/nik/Documents/Progamming/document_scanner/backend/api/audit.py
# Expected: 4 matches (2 existing endpoints + 2 new endpoints)

<success_criteria>

  • Audit log JSON response includes user_handle and actor_handle — confirmed by test_audit_log_includes_user_handle
  • user_handle filter returns correct filtered results — confirmed by test_audit_log_filter_by_handle
  • Unknown handle returns empty (not 422) — confirmed by test_audit_log_filter_unknown_handle
  • Daily export list endpoint returns sorted items — confirmed by test_daily_exports_list
  • Daily export download streams CSV with regex-validated date — confirmed by test_daily_export_download
  • AuditLogTab exportCsv() uses fetch+Blob (window.location.href removed)
  • AuditLogTab user filter labeled "User handle"
  • AuditLogTab has Daily exports section with date dropdown and Download button
  • All 9 test_audit.py tests pass
  • Full pytest suite exits 0 </success_criteria>
Create `.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-04-SUMMARY.md` when done.