{
  "slug": "data-export-gdpr",
  "spec_file": "docs/specs/2026-05-31-data-export-gdpr.md",
  "findings": [
    {
      "id": "data-export-gdpr-001",
      "severity": "P0",
      "type": "security",
      "summary": "POST /api/cron/data-retention-purge has no CRON_SECRET guard",
      "spec_ref": "Hard-delete job (Cron Trigger, daily); Plan Task 9 — require CRON_SECRET header",
      "code_ref": "apps/zync-api/src/routes/cron/data-retention-purge.ts:12",
      "evidence": "Handler calls deleteExpiredAuditLogs with no auth check. grep CRON_SECRET in data-retention-purge.ts returns no matches; peer crons (e.g. audit-log-retention.ts:25) compare x-cron-secret.",
      "repro": "curl -X POST https://<api>/api/cron/data-retention-purge — returns 200 without secret header.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-002",
      "severity": "P1",
      "type": "missing",
      "summary": "@zync/export package absent (jobs, rate-limit, CSV/ZIP collectors)",
      "spec_ref": "Architecture — Package @zync/export; Plan Tasks 2–3",
      "code_ref": "NONE",
      "evidence": "glob packages/export/** returns 0 files. Only packages/exporters exists (financial utilities, package.json name @zync/exporters).",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-003",
      "severity": "P1",
      "type": "missing",
      "summary": "POST /api/export/request not implemented",
      "spec_ref": "API Endpoints — POST /api/export/request",
      "code_ref": "NONE",
      "evidence": "grep apps/zync-api for /export/request and export/request returns no matches. Legacy route is POST /api/data/export at apps/zync-api/src/routes/data/export.ts:35.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-004",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/export/jobs history endpoint not implemented",
      "spec_ref": "API Endpoints — GET /api/export/jobs",
      "code_ref": "NONE",
      "evidence": "grep apps/zync-api for export/jobs returns no route. No listExportJobs in packages/db/src/queries/data-export.ts.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-005",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/export/jobs/:id/download (signed R2 redirect / 410) not implemented",
      "spec_ref": "API Endpoints — GET /api/export/jobs/:id/download",
      "code_ref": "apps/zync-api/src/routes/data/export.ts:83",
      "evidence": "Only GET /api/data/export/:jobId/download exists; returns inline JSON Response, not 302 to signed R2 URL or 410 when expired.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-006",
      "severity": "P1",
      "type": "missing",
      "summary": "DELETE /api/tenants/me workspace soft-delete not implemented",
      "spec_ref": "Account Deletion — Tenant Account Deletion; API DELETE /api/tenants/me",
      "code_ref": "NONE",
      "evidence": "grep apps/zync-api for tenants/me and DELETE.*tenants returns no handler.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-007",
      "severity": "P1",
      "type": "missing",
      "summary": "DELETE /api/users/me user self-deletion not implemented",
      "spec_ref": "User Self-Deletion; API DELETE /api/users/me",
      "code_ref": "NONE",
      "evidence": "grep apps/zync-api for users/me returns no DELETE handler.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-008",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/settings/data/deletion-progress not implemented",
      "spec_ref": "Deletion Progress Tracking — Progress polling endpoint",
      "code_ref": "NONE",
      "evidence": "grep deletion-progress across apps/zync-api returns no matches.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-009",
      "severity": "P1",
      "type": "missing",
      "summary": "export.generate queue consumer for GDPR ZIP pipeline not implemented",
      "spec_ref": "Architecture Decisions — Async ZIP generation via Queue; Plan Task 7",
      "code_ref": "apps/zync-api/src/index.ts:137",
      "evidence": "export.generate consumer only handles type uniform-format and report.schedule; no branch for { kind:'export', jobId, exportType }. No apps/zync-api/src/workers/export-generate.ts.",
      "repro": "Enqueue GDPR export message — no consumer builds ZIP or uploads to exports/{tenant_id}/{job_id}.zip.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-010",
      "severity": "P1",
      "type": "missing",
      "summary": "tenant.delete queue worker (deleteTenant) not implemented",
      "spec_ref": "Deletion Progress Tracking — Worker pseudocode deleteTenant; Plan Task 8",
      "code_ref": "NONE",
      "evidence": "No apps/zync-api/src/workers/tenant-deletion.ts. grep deleteTenant in apps/zync-api (excluding admin tenant-roles) returns no GDPR deletion worker. wrangler.toml has no tenant.delete consumer binding.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-011",
      "severity": "P1",
      "type": "missing",
      "summary": "tenants/users soft-delete columns absent from schema",
      "spec_ref": "Data Model — ALTER tenants deleted_at/deletion_requested_by/deletion_job_id; users deleted_at",
      "code_ref": "packages/db/src/schema/tenants.ts:1",
      "evidence": "grep deleted_at in packages/db/src/schema/tenants.ts and users.ts returns no matches. Migration 0010_wave10_consolidated.sql creates export_jobs only; no GDPR soft-delete ALTERs.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-012",
      "severity": "P1",
      "type": "missing",
      "summary": "Permissions data:export and tenant:delete not seeded",
      "spec_ref": "Permissions matrix; Plan Task 13",
      "code_ref": "packages/db/src/seed/permission-keys.ts:6",
      "evidence": "PERMISSION_KEYS lists settings:read/write but no data:export or tenant:delete. grep data:export and tenant:delete in packages/ returns no permission definitions.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-013",
      "severity": "P1",
      "type": "missing",
      "summary": "User personal data export (user_personal) has no API or collector",
      "spec_ref": "User Personal Data Export — Contents ZIP; POST export_type user_personal",
      "code_ref": "NONE",
      "evidence": "grep user_personal and collectUserExport across repo (excluding docs) returns no implementation.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-014",
      "severity": "P1",
      "type": "missing",
      "summary": "/settings/data page (Export + Danger Zone tabs) not implemented",
      "spec_ref": "Features & Screens — /settings/data",
      "code_ref": "apps/zync-app/src/modules/settings.tsx:244",
      "evidence": "Route is path data-privacy not data. No DataExportPage, ExportHistoryTable, DeleteWorkspaceDialog, or DeletionProgress components under apps/zync-app.",
      "repro": "Navigate to /settings/data — no route; notification href data_export_ready points to /settings/data (resolveEntityHref.ts:16).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-015",
      "severity": "P1",
      "type": "missing",
      "summary": "/profile Privacy tab (personal export + delete account) not implemented",
      "spec_ref": "Features & Screens — /profile → Privacy tab",
      "code_ref": "apps/zync-app/src/shell/UserMenu.tsx:79",
      "evidence": "Shell links to /profile; grep path=/profile in apps/zync-app routes returns no match. Only /staff/profile exists. No PrivacyTab or DeleteAccountDialog.",
      "repro": "User menu → Profile → no GDPR privacy UI.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-016",
      "severity": "P1",
      "type": "missing",
      "summary": "GDPR email templates not implemented",
      "spec_ref": "Plan Task 10 — data_export_ready, tenant_deletion_requested, user_deletion_confirmed",
      "code_ref": "NONE",
      "evidence": "grep data_export_ready and tenant_deletion_requested in packages/notifications returns no template files. Only i18n notification strings in packages/ui and notifications.render.ts.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-017",
      "severity": "P1",
      "type": "missing",
      "summary": "Hard-delete cron does not enqueue tenant.delete or anonymize users",
      "spec_ref": "Hard-delete job (Cron Trigger, daily); User retention 30-day anonymization",
      "code_ref": "apps/zync-api/src/routes/cron/data-retention-purge.ts:15",
      "evidence": "Handler only calls deleteExpiredAuditLogs(db, 2555). No SELECT tenants WHERE deleted_at < now()-30d; no user name/email overwrite.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-018",
      "severity": "P1",
      "type": "missing",
      "summary": "Login block for soft-deleted tenants/users not implemented",
      "spec_ref": "Tenant deletion — block new logins; User self-deletion — login blocked",
      "code_ref": "apps/zync-api/src/middleware/auth.ts:1",
      "evidence": "grep deleted_at in apps/zync-api/src/middleware returns no matches. tenants/users tables lack deleted_at column.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "export_jobs schema diverges from spec (columns, types, FKs, indexes)",
      "spec_ref": "Data Model — CREATE TABLE export_jobs",
      "code_ref": "packages/db/src/schema/data-export.ts:31",
      "evidence": "Has id, tenant_id NOT NULL CASCADE, created_by, status, download_url, error_message, created_at, completed_at only. Missing: export_type, requested_by, r2_key, download_expires_at, row_count, file_size_bytes, deletion_stage, deletion_stage_pct, deletion_error, requested_at. Status CHECK uses completed not ready; no expired. Index idx_export_jobs_tenant_created not idx_export_jobs_tenant on requested_at; no idx_export_jobs_user.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Tenant export uses settings:write not OWNER-only data:export",
      "spec_ref": "Permissions — Request tenant full export OWNER only",
      "code_ref": "apps/zync-api/src/routes/data/export.ts:35",
      "evidence": "POST /data/export uses requirePermission('settings:write'). ADMIN role gets settings:write per packages/db/src/seed/permission-keys.ts:70.",
      "repro": "Authenticated ADMIN can POST /api/data/export and receive full tenant JSON.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "Export download/history gated by settings:read (VIEWER+) not OWNER-only",
      "spec_ref": "Permissions — View own export history OWNER only",
      "code_ref": "apps/zync-api/src/routes/data/export.ts:66",
      "evidence": "GET /data/export/:jobId and /download use requirePermission('settings:read'). VIEWER role has all :read keys including settings:read (permission-keys.ts:72).",
      "repro": "VIEWER with job UUID can GET /api/data/export/:jobId/download and receive full tenant JSON.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "Export is synchronous JSON in-request, not async queued ZIP to R2",
      "spec_ref": "Tenant Full Export flow; Architecture — Async ZIP via Queue",
      "code_ref": "apps/zync-api/src/routes/data/export.ts:46",
      "evidence": "Comment 'In production this would be queued; here we do it eagerly.' Runs setExportJobProcessing, exportTenantData, completeExportJob inline in POST handler. No QUEUE.send, no R2 upload, no sendEmail.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-023",
      "severity": "P2",
      "type": "divergent",
      "summary": "Export payload is JSON not spec ZIP with CSV files and R2 assets",
      "spec_ref": "Export Contents (ZIP file) — customers.csv, invoices/*.pdf, etc.",
      "code_ref": "packages/db/src/queries/data-export.ts:68",
      "evidence": "exportTenantData returns TenantExportData object; download serializes JSON.stringify(data). No README.txt, contacts.csv, tasks.csv, audit-log.csv, receipts/, recurring-invoice-templates.csv, or PDF fetches.",
      "repro": "Complete export — download filename ends in .json not .zip.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-024",
      "severity": "P2",
      "type": "divergent",
      "summary": "No 24h/7d export rate limits",
      "spec_ref": "Rate limit max 1 export per 24h per tenant; 7d per user",
      "code_ref": "apps/zync-api/src/routes/data/export.ts:35",
      "evidence": "POST handler calls createExportJob with no canRequestTenantExport/canRequestUserExport checks. grep canRequestTenantExport in repo (excluding docs) returns no matches.",
      "repro": "Repeated POST /api/data/export succeeds without 429.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-025",
      "severity": "P2",
      "type": "divergent",
      "summary": "No 48h download expiry or expired status",
      "spec_ref": "Export expiry — download_expires_at; status expired; 410 Gone",
      "code_ref": "packages/db/src/schema/data-export.ts:42",
      "evidence": "export_jobs has no download_expires_at column. Status enum has no expired. Download endpoint checks status===completed only, no expiry (export.ts:97).",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-026",
      "severity": "P2",
      "type": "divergent",
      "summary": "API response shape diverges from spec",
      "spec_ref": "POST /api/export/request response { job_id, status, message }",
      "code_ref": "apps/zync-api/src/routes/data/export.ts:61",
      "evidence": "Returns { jobId } with 202 only; no status:'pending' or message string.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-027",
      "severity": "P2",
      "type": "divergent",
      "summary": "UI at /settings/data-privacy diverges from spec /settings/data",
      "spec_ref": "Tenant Full Export — Located at /settings/data → Export tab",
      "code_ref": "apps/zync-app/src/routes/settings/data-privacy.tsx:151",
      "evidence": "Single page with 'Export All Workspace Data' JSON button; no Export/Danger Zone tabs, no export history table, no delete workspace flow, no 24h countdown.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-028",
      "severity": "P2",
      "type": "divergent",
      "summary": "Customer anonymize is not spec user/tenant erasure flow",
      "spec_ref": "User Self-Deletion; Tenant Account Deletion",
      "code_ref": "apps/zync-api/src/routes/data/export.ts:120",
      "evidence": "Only POST /customers/:id/anonymize with customers:delete + OWNER role gate. UI documents erasure via customer detail (data-privacy.tsx:302), not account deletion endpoints.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-029",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "data_export_ready notification links to non-existent /settings/data",
      "spec_ref": "Features & Screens — /settings/data",
      "code_ref": "apps/zync-app/src/pages/notifications/resolveEntityHref.ts:16",
      "evidence": "data_export_ready maps to '/settings/data' but app route is settings/data-privacy (settings.tsx:244).",
      "repro": "Tap data_export_ready notification — 404 or wrong page.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "data-export-gdpr-030",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Job status label completed vs spec ready",
      "spec_ref": "export_jobs.status ready | expired",
      "code_ref": "packages/db/src/schema/data-export.ts:51",
      "evidence": "CHECK constraint IN ('pending','processing','completed','failed'); UI data-privacy.tsx:24 uses completed not ready.",
      "repro": "",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 1,
    "P1": 17,
    "P2": 10,
    "P3": 2,
    "total": 30
  }
}
