Skip to content

Admin — DSAR Queue

The admin's surface for the V2 admin-mediated FADP rights queue: data-subject access requests (DSAR), erasure requests, and portability requests. Owned by Mira. Cross-cuts: fadp-posture (Iris's source spec — DSAR / erasure / portability), data-model (the dsar_log table is a schema ask for Theo per fadp-posture § Schema asks), ADR-0004 § FADP positioning, admin-person-merge (sibling admin tooling), stringer-dashboard, design-tokens.

Source requirement

Goal

Three commitments:

  1. The admin's job is the SLA, not the queue depth. V2 admin-mediated DSAR / erasure is rare in absolute terms (a handful per year, probably). The UI optimises for getting one request resolved within 30 days, not for processing dozens at scale.
  2. Erasure is the destructive case — friction matches. Per fadp-posture A-ERA-2: hard-erase is only callable after the 30-day grace expires; admin-triggered, not auto-cron. The UI gates hard-erase behind a typed-confirm pattern (same convention as admin-person-merge and stringer-finalize per stringer-lifecycle § hidden requirement 7).
  3. The admin's dashboard surface is a count badge with SLA-urgency colouring. A request 25 days old gets a red chip; a request 5 days old gets an amber chip. The dashboard makes Stefan see "I'm two days from missing the SLA on Lukas Müller's request" before he opens his email.

Information architecture

Admin-only route at /admin/dsar/queue. Three tabs reflect the three FADP rights, but they share the same shell because the JSON-document export pipeline is identical.

Admin
├── Dashboard (existing)              ←  badge "2 DSARs, 1 due in 5 days" → ↓
└── DSAR queue (new)
    ├── Pending (default)             ←  open DSAR / erasure / portability requests
    │   ├── Tab filter (All / DSAR / Erasure / Portability)
    │   └── Row tap                   ←  → detail
    ├── Detail view (per request)
    │   ├── Person summary
    │   ├── Request metadata (kind, requested_at, SLA chip)
    │   ├── Identity-verification panel (V2 admin notes)
    │   ├── Action: Generate response (DSAR / portability)
    │   ├── Action: Execute erasure   ←  destructive; typed-confirm
    │   └── Mark responded
    └── Done / Archive                ←  responded + cancelled requests

The "Finalize expired soft-deletes" action (fadp-posture § hidden requirement 7) is a sibling sub-route, also admin-only:

└── Finalize expired soft-deletes     ←  Persons whose 30-day grace has passed
    ├── List of soft-deleted Persons (deleted_at + grace-expired)
    └── Per-Person finalize action    ←  destructive; typed-confirm

Stage 1 — Dashboard badge entry point

Like the catalogue moderation entry, the admin dashboard surfaces a chip for pending DSAR-queue items. Distinct from the catalogue chip; SLA-urgency colored.

┌───────────────────────────────────────┐
│  Inbox                          ⓘ     │
│ ┌───────────────────────────────────┐ │
│ │ 🛡 2 data requests pending        │ │  Admin DSAR chip
│ │    1 due in 5 days                │ │
│ │                       Review →    │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 🔔 3 catalogue submissions        │ │  Existing
│ ⋯                                     │
└───────────────────────────────────────┘

Behaviour

  • Chip background colour reflects the most-urgent SLA chip among pending requests:
  • Any request ≤ 7 days from SLA breach: chip bg-red-50 border-red-200 text-red-800.
  • Any request 8–14 days remaining: chip bg-amber-50 border-amber-200 text-amber-800.
  • All requests > 14 days remaining: chip bg-slate-50 border-slate-200 text-slate-700.
  • Chip text includes the urgency callout: "1 due in 5 days" / "1 due tomorrow" / "1 overdue by 2 days" (the overdue case is shown in text-red-700 font-semibold).
  • Tapping the chip → /admin/dsar/queue.
  • Non-admins do not see the chip.

Stage 2 — Pending list view

/admin/dsar/queue

sm 375 px

┌───────────────────────────────────────┐
│ ←  Data requests                      │  Header
├───────────────────────────────────────┤
│                                       │
│  [ Pending 2 ] [ Done ]               │  Tabs (Pending vs Done/Archive)
│                                       │
│  [ All ] [ Access ] [ Erasure ] [ Port. ] │  Kind filter chips
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  ⏰ 5 days left                   │ │  SLA chip — text-tiny red-700
│ │                                   │ │
│ │  Erasure request                  │ │  Kind tag — text-small uppercase
│ │  Lukas Müller                     │ │  Person display name — text-h3
│ │  ✓ lukas.mueller@example.com      │ │  Verified-email line
│ │  Requested 2026-04-12             │ │  text-small slate-600
│ │  Verified by reply email          │ │  text-tiny slate-500
│ │                                   │ │
│ │                       Open →      │ │
│ └───────────────────────────────────┘ │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  📅 22 days left                  │ │  SLA chip — slate
│ │                                   │ │
│ │  Access request (DSAR)            │ │
│ │  Anna Bauer                       │ │
│ │  ✓ anna.bauer@example.com         │ │
│ │  Requested 2026-04-30             │ │
│ │  Verified — pending               │ │  text-tiny amber-700
│ │                                   │ │
│ │                       Open →      │ │
│ └───────────────────────────────────┘ │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  + Log new request              │  │  CTA — log out-of-band request
│  └─────────────────────────────────┘  │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • Default tab: Pending.
  • Default kind filter: All.
  • Sort order: SLA-chip-urgency descending (least time remaining first). Overdue requests appear at the top with a red SLA chip.
  • SLA chip thresholds:
  • Overdue (≤ 0 days remaining): bg-red-700 text-white "Overdue by N days" — lucide:alert-triangle.
  • ≤ 7 days remaining: bg-red-50 border-red-200 text-red-700 "{N} days left" — lucide:alarm-clock.
  • 8–14 days: bg-amber-50 border-amber-200 text-amber-800 "{N} days left" — lucide:clock.
  • 15–30 days: bg-slate-50 border-slate-200 text-slate-700 "{N} days left" — lucide:calendar.
  • Verification status:
  • "Verified by reply email" — admin has confirmed identity per A-DSAR-8 (V2-admin-mediated requires identity verification before serving).
  • "Verified — pending" — request logged but identity verification not yet recorded. text-amber-700.
  • "Log new request" CTA. When a Person sends an out-of-band request (email Stefan), Stefan logs it via this CTA — the request becomes a dsar_log row in pending status, ready for processing. Form covered in Stage 2.5 — Log new request.

Component breakdown

Component Notes
Header "Data requests" text-h1. Back arrow → admin dashboard.
Tabs Pending / Done — text-body; selected tab border-b-2 border-indigo-700 text-indigo-700.
Kind filter chips Pill row, text-small. Selected bg-indigo-700 text-white; unselected bg-slate-100.
Request card bg-white border border-slate-200 rounded-lg p-4. SLA chip pinned top-right.
SLA chip inline-flex items-center gap-1 text-tiny font-medium px-2 py-0.5 rounded. Color per thresholds.
Kind tag "Erasure request" / "Access request (DSAR)" / "Portability request" — text-small uppercase tracking-wide slate-500.
Display name text-h3.
Verified-email line Same component as admin-person-merge: lucide:check-circle text-green-700 + email.
Verification-status line "Verified by reply email" / "Verified — pending" — text-tiny.
"Open →" affordance Right-aligned link inside the card; whole card is also tappable.
Log-new-request CTA bg-white border border-indigo-200 text-indigo-700 rounded-lg; full-width on sm.

Empty state

┌───────────────────────────────────────┐
│ ←  Data requests                      │
├───────────────────────────────────────┤
│  [ Pending 0 ] [ Done ]               │
│                                       │
│  Nothing pending                      │  H2
│                                       │
│  All data requests have been          │  text-body slate-600
│  resolved.                            │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  + Log new request              │  │
│  └─────────────────────────────────┘  │
│                                       │
└───────────────────────────────────────┘

Stage 2.5 — Log new request

When a Person sends an out-of-band request (the V2 channel per fadp-posture A-DSAR-1), the admin manually logs it.

sm 375 px

┌───────────────────────────────────────┐
│ ←  Log data request                   │  Header
├───────────────────────────────────────┤
│                                       │
│  Subject (the Person making the       │  H3
│  request)                             │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search Persons                 │ │
│ └───────────────────────────────────┘ │
│  Or — pick from email                 │  Group label
│ ┌───────────────────────────────────┐ │
│ │ • Lukas Müller (verified)         │ │  Search-result row
│ │   lukas.mueller@example.com       │ │
│ │                       Select →    │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
│  Selected: Lukas Müller               │  Once selected
│                                       │
│  Kind                                 │  Required field
│  ◉ Access (DSAR)                      │  Radio group
│  ○ Erasure                            │
│  ○ Portability                        │
│                                       │
│  Verification method                  │  Required field
│ ┌───────────────────────────────────┐ │
│ │                                   │ │  Textarea
│ │ e.g. "Replied via email to the    │ │
│ │ verified address; confirmed by    │ │
│ │ Person."                          │ │
│ └───────────────────────────────────┘ │
│  How you verified the requester is    │  text-tiny slate-500
│  who they say they are.               │
│                                       │
│  Channel notes                        │  Optional
│ ┌───────────────────────────────────┐ │
│ │                                   │ │
│ │ e.g. "Email received 2026-04-12;  │ │
│ │ replied 2026-04-13."              │ │
│ └───────────────────────────────────┘ │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │           Cancel                │ │
│   └─────────────────────────────────┘ │
│   ┌─────────────────────────────────┐ │
│   │  Log request                    │ │  Primary CTA
│   └─────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • Subject picker is the same fuzzy-search component as admin-person-merge Stage 1, scoped to active Persons (not soft-deleted, not scrubbed).
  • Kind radio group maps to dsar_log.kind enum: access_request | erasure_request | portability_request.
  • Verification method. Required textarea per A-DSAR-8: "the dsar_log row notes the verification method". Stefan writes free text describing how he confirmed the requester's identity. Min 1 char; ≤ 500 chars.
  • Channel notes. Optional context: when the request came in, when Stefan responded out-of-band, etc. Stored in dsar_log.meta.channel_notes.
  • On submit: server creates a dsar_log row with:
  • person_id = :selected
  • kind = :selected_kind
  • requested_at = :provided (or NOW() if not specified — V2 simpler default)
  • requested_by_kind = 'admin' (V2: admin always logs; the Person did not log it themselves)
  • status = 'pending'
  • verification_method = :provided
  • meta = {channel_notes: …}
  • Success state: redirect to the request's detail view (Stage 3) so Stefan can immediately start processing.

Validation

Rule Message
Subject not picked "Pick a Person."
Kind not selected "Pick the request kind."
Verification method empty "Describe how you verified the requester's identity."
Verification method > 500 chars "Verification method is too long (max 500)."
Channel notes > 500 chars "Channel notes are too long (max 500)."

Stage 3 — Detail view

/admin/dsar/queue/:dsar_log_id

The full processing surface for one request. Different actions render based on kind.

sm 375 px — DSAR / Portability variant

┌───────────────────────────────────────┐
│ ←  Request #87                        │  Header
├───────────────────────────────────────┤
│                                       │
│  Access request (DSAR)                │  Kind tag
│  ⏰ 5 days left                       │  SLA chip
│                                       │
│  Subject                              │  H3
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller                      │ │
│ │ ✓ lukas.mueller@example.com       │ │
│ │ Locale: de                        │ │
│ │ 17 orders · 2 ClientProfiles      │ │
│ │                       View →      │ │  Link to Person admin detail (future)
│ └───────────────────────────────────┘ │
│                                       │
│  ─── Request ───                      │
│  Requested  2026-04-12 by admin       │
│  Status     pending                   │
│  Channel    Email (verified reply)    │
│                                       │
│  Verification method                  │
│ ┌───────────────────────────────────┐ │
│ │ Replied to lukas.mueller@example. │ │
│ │ com; received signed-confirmation │ │
│ │ reply 2026-04-13.                 │ │
│ └───────────────────────────────────┘ │
│                                       │
│  ─── Response ───                     │
│                                       │
│  Per FADP art. 25, this request must  │  text-small slate-700
│  be answered within 30 days of        │
│  2026-04-12 (i.e. by 2026-05-12).     │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │  Generate JSON response         │ │  Primary CTA
│   └─────────────────────────────────┘ │
│                                       │
│  After download, attach to your       │  text-tiny slate-500
│  reply email, then mark responded.    │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │  Mark responded                 │ │  Secondary CTA
│   └─────────────────────────────────┘ │
│                                       │
│  ─── Audit ───                        │
│  No prior dsar_log entries for        │  text-small slate-500
│  this Person.                         │
│                                       │
└───────────────────────────────────────┘

sm 375 px — Erasure variant

┌───────────────────────────────────────┐
│ ←  Request #88                        │
├───────────────────────────────────────┤
│                                       │
│  Erasure request                      │
│  ⏰ 5 days left                       │
│                                       │
│  Subject                              │
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller                      │ │
│ │ …summary as DSAR…                 │ │
│ └───────────────────────────────────┘ │
│                                       │
│  ─── Request ───                      │
│  …same metadata block as DSAR…        │
│                                       │
│  ─── Two-step erasure ───             │
│                                       │
│  Erasure is two-stage:                │  text-small slate-700
│                                       │
│  1. Soft-delete now (today)           │
│     → Person.deleted_at set;          │
│     → 30-day grace begins;            │
│     → Person can no longer log into   │
│       V3 (when V3 lands);             │
│     → Existing data is retained,      │
│       PII is preserved.               │
│                                       │
│  2. Hard-erase eligible after         │
│     2026-06-12 (30 days from soft-    │
│     delete) → cascade scrub runs.     │
│                                       │
│  Stage 1                              │  H3
│   ┌─────────────────────────────────┐ │
│   │  Soft-delete this Person        │ │  Primary CTA — first stage
│   └─────────────────────────────────┘ │
│                                       │
│  Stage 2 (greyed — not yet eligible)  │
│  ┌────────────────────────────────────┐│
│  │ Eligible 2026-06-12                ││  Greyed CTA, disabled
│  │  Hard-erase (after grace)          ││
│  └────────────────────────────────────┘│
│                                       │
│   ┌─────────────────────────────────┐ │
│   │  Mark responded                 │ │  Secondary
│   └─────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

After Stage 1 (soft-delete) is run, the page re-renders showing Stage 1 as completed and Stage 2 with a countdown:

  ─── Two-step erasure ───
  Stage 1 — soft-deleted on 2026-05-12
  ✓ Person.deleted_at set
  ✓ 30-day grace started

  Stage 2 — hard-erase eligible 2026-06-12
  Countdown: 24 days
   ┌─────────────────────────────────┐
   │  Hard-erase (after grace)       │  Disabled until grace ends
   └─────────────────────────────────┘

Once the 30-day grace expires, the Stage 2 CTA enables, and tapping it opens the typed-confirm modal (Stage 4 below).

Behaviour

  • Subject summary card. Compact summary of the Person; "View →" links to a Person admin detail page (queued for future; in V2 this might link to a list of orders instead — see Open question #2).
  • Generate JSON response (DSAR / portability). Tapping triggers a server-side render of the per-Person JSON document per fadp-posture § What's in the DSAR response. The browser downloads the file (person-{id}-dsar-{timestamp}.json). The same endpoint serves portability requests (identical document per A-PORT-1).
  • Mark responded. Tapping prompts a small modal:
   ╔═══════════════════════════════╗
   ║  Mark this request responded? ║
   ║                               ║
   ║  This sets dsar_log.responded_║
   ║  at = now() and moves the     ║
   ║  request to the Done tab.     ║
   ║                               ║
   ║  Response reference (optional)║
   ║ ┌─────────────────────────┐   ║
   ║ │                         │   ║  e.g. "Sent JSON via email"
   ║ └─────────────────────────┘   ║
   ║                               ║
   ║  ┌───────────────────────┐    ║
   ║  │  Cancel               │    ║
   ║  └───────────────────────┘    ║
   ║  ┌───────────────────────┐    ║
   ║  │  Mark responded       │    ║
   ║  └───────────────────────┘    ║
   ╚═══════════════════════════════╝

On submit: UPDATE dsar_log SET responded_at = NOW(), status = 'responded', response_ref = :response_ref (per the schema in fadp-posture § Schema asks 3).

  • Stage 1 soft-delete (erasure variant). Tap opens the soft-delete confirmation modal (smaller than the hard-erase modal because soft-delete is reversible during grace).
   ╔═══════════════════════════════╗
   ║  Soft-delete this Person?     ║
   ║                               ║
   ║  Person.deleted_at = now().   ║
   ║  Reversible within 30 days.   ║
   ║                               ║
   ║  After 2026-06-12, hard-erase ║
   ║  becomes available.           ║
   ║                               ║
   ║  ☐ Notify stringers holding   ║  Per A-ERA-4
   ║    a ClientProfile for this   ║  fadp-posture stringer-side notif
   ║    Person                     ║
   ║                               ║
   ║  [Cancel]  [Soft-delete]      ║
   ╚═══════════════════════════════╝

Per A-ERA-4 / fadp-posture § Stringer-side notification: stringers are notified when a Person erases. Default the checkbox to checked; the admin can uncheck only if there's a reason (e.g. soft-delete is being walked back immediately).

  • Stage 2 hard-erase. Tap opens the typed-confirm modal (Stage 4).

Component breakdown

Component Notes
Header "Request #{id}" text-h2.
Kind tag text-small uppercase tracking-wide.
SLA chip Same component as Stage 2 list.
Subject card bg-white border border-slate-200 rounded-lg p-3.
Metadata block <dl> definition list, text-small.
Verification-method card bg-slate-50 border border-slate-200 rounded-lg p-3 text-small italic.
FADP SLA reminder text-small slate-700; concrete deadline date.
Generate JSON CTA bg-indigo-700 text-white.
Mark responded CTA bg-white border border-indigo-200 text-indigo-700.
Two-step erasure block <ol> with explanatory copy.
Soft-delete CTA bg-amber-700 text-white — destructive but reversible (amber, not red).
Hard-erase CTA (greyed) bg-slate-100 text-slate-400 cursor-not-allowed; tooltip / aria-disabled "Eligible {date}".
Hard-erase CTA (enabled) bg-red-700 text-white — destructive, irreversible.
Audit sub-block text-small slate-500 listing prior dsar_log rows for the same Person.

Stage 4 — Hard-erase typed-confirm

The destructive-action gate. Same convention as admin-person-merge Stage 3.

sm 375 px (separate route, not a modal — same reasoning as merge)

/admin/dsar/queue/:id/erase
┌───────────────────────────────────────┐
│ ←  Confirm hard-erase                 │
├───────────────────────────────────────┤
│                                       │
│  ⚠ Permanent data erasure             │  text-h1 + lucide:alert-triangle
│                                       │  red-700
│                                       │
│  This will permanently scrub the      │  text-body slate-700
│  Person's PII from the platform.      │
│                                       │
│  Subject                              │  H3
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller                      │ │
│ │ ✓ lukas.mueller@example.com       │ │
│ │ Soft-deleted 2026-05-12            │ │
│ │ Grace ended 2026-06-12              │ │
│ └───────────────────────────────────┘ │
│                                       │
│  ─── What happens ───                 │
│                                       │
│  ✓ Person.scrubbed_at set; PII fields │  Per fadp-posture cascade-rules
│    scrubbed: display_first_name,      │
│    display_last_name → "[redacted]";  │
│    email cleared.                     │
│  ✓ 2 ClientProfiles linked to this    │
│    Person stay (stringer notes are    │
│    retained — not the Person's data). │
│  ✓ 17 orders stay (10-year financial- │
│    record retention per Swiss CO).    │
│  ✓ Order.comments scrubbed on those   │
│    17 orders.                         │
│  ✓ Receipt-emit-log per-emit          │
│    snapshots: structural fields kept; │
│    PII fields scrubbed.               │
│  ✓ 0 share grants migrate (already    │
│    revoked at soft-delete time).      │
│  ✓ 14 share_audit rows preserved      │
│    verbatim (FADP-defensible audit).  │
│  ✓ One dsar_log entry written.        │
│  ✓ Stringers holding ClientProfiles   │
│    are notified (in-app).             │
│                                       │
│  ─── Confirm ───                      │
│  To confirm, type the Person's        │  text-small slate-700
│  display name:                        │
│  "Lukas Müller"                       │  text-body bold slate-900
│                                       │
│ ┌───────────────────────────────────┐ │
│ │                                   │ │  Confirmation input
│ └───────────────────────────────────┘ │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │           Cancel                │ │
│   └─────────────────────────────────┘ │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │  Hard-erase Lukas Müller        │ │  Primary CTA — bg-red-700
│   └─────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • Page (not modal) — same reasoning as the admin-person-merge Stage 3: destructive action deserves real estate.
  • Eligibility guard. Server validates that Person.deleted_at IS NOT NULL AND deleted_at + INTERVAL '30 days' <= NOW() (per A-ERA-2). If not eligible, page redirects back to the detail with a toast: "Hard-erase is not yet eligible for this Person."
  • Dry-run preview ("What happens"). Server-rendered counts of every entity affected by the cascade per fadp-posture § Cascade rules — what happens to data on erasure. The list mirrors the spec line-by-line so the admin sees exactly what the FADP cascade does.
  • Typed-confirm. Same convention as admin-person-merge Stage 3 — exact match including special characters. Disabled CTA until typed-confirm matches.
  • Hard-erase CTA is bg-red-700 text-white. Label includes the Person's display name.
  • On submit: server runs the erasure cascade transaction per fadp-posture § cascade rules:
  • Set Person.scrubbed_at = NOW(); scrub PII fields.
  • Run scrub_orders_for_person(person_id) per Schema asks 6.
  • Revoke remaining order_shares and person_stringer_share rows; one share_audit row per revocation.
  • Insert share_audit row with event_kind = person_erasure, actor_kind = stringer, actor_id = :admin_id, target_kind = person, target_id = :person_id, meta = {dsar_log_id: …, reason: 'admin_finalized_dsar'}.
  • Update the dsar_log row: responded_at = NOW(), status = 'responded'.
  • Notify stringers per stringer-side notification.
  • Success state: redirect to the DSAR queue Done tab with toast: "Hard-erase complete. {N} records scrubbed."
  • Failure handling. If the transaction fails, toast: "Erasure couldn't complete — try again." Form preserved.

Stage 5 — Done / Archive tab

/admin/dsar/queue?tab=done

A read-only list of completed requests. Same card shape as Pending; SLA chip replaced with status chip:

  • "✓ Responded on 2026-05-08" bg-green-50 text-green-800
  • "✕ Cancelled on 2026-05-04" bg-slate-50 text-slate-600

Tapping a row opens the detail view in read-only mode (same shell, no Action CTAs).

Pagination: 25 rows per page (offset).

Stage 6 — Finalize expired soft-deletes (sub-route)

/admin/dsar/queue/finalize-expired

Per fadp-posture § hidden requirement 7: list of soft-deleted Persons whose 30-day grace has passed. Bulk-finalize action with typed-confirm pattern.

sm 375 px

┌───────────────────────────────────────┐
│ ←  Finalize expired soft-deletes      │
├───────────────────────────────────────┤
│                                       │
│  Persons soft-deleted ≥ 30 days ago.  │  text-body slate-700
│  Hard-erase is now eligible.          │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller                      │ │
│ │ Soft-deleted 2026-04-01           │ │
│ │ Grace ended 2026-05-01 (1 day ago)│ │
│ │                       Finalize →  │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • Per-row "Finalize →" opens the same Stage 4 typed-confirm page, scoped to that Person.
  • No bulk-finalize across multiple Persons in one transaction in V2 (per A-ERA-3: "cascade rules execute in a single transaction" per Person; bulk = N transactions, fine but not modelled as bulk in V2). The per-row pattern matches the admin's "review one at a time" workflow.
  • Empty state: "No expired soft-deletes." Means everything that aged out has already been finalized.

This sub-surface is a sibling to the main DSAR queue; it does not strictly require a separate dsar_log row (the original soft-delete already wrote one), but the finalize action writes a new share_audit row per the cascade.

Audit-log surfacing

Every dsar_log row + every share_audit row written by the cascade is the audit trail. The detail view's "Audit" sub-block lists prior dsar_log entries for the same Person — a Person who has cycled through "soft-delete → reverse → soft-delete → hard-erase" should see all four entries here.

The cross-event admin audit-log surface (across all share_audit event kinds — share grants, person merges, person erasures, etc.) is queued for a future round; this surface only writes its own events.

Interaction states

State What renders
Stage 2 — empty Pending "Nothing pending" empty state with Log-new-request CTA.
Stage 2 — sorted SLA-desc Most-urgent requests at top.
Stage 2 — kind filter HTMX swap of the list.
Stage 2 — log-new-request opened Stage 2.5 form.
Stage 3 — DSAR / portability Generate-JSON + Mark-responded CTAs.
Stage 3 — erasure, not yet soft-deleted Soft-delete CTA primary; Hard-erase greyed.
Stage 3 — erasure, soft-deleted, in grace Soft-delete completed marker; Hard-erase greyed with countdown.
Stage 3 — erasure, grace expired Hard-erase enabled (links to Stage 4).
Stage 3 — erasure, hard-erased Read-only view; "Hard-erased on {date}" status chip; no CTAs.
Stage 3 — already responded Read-only view; "Responded on {date}" status chip.
Stage 4 — typed-confirm not matching CTA disabled.
Stage 4 — typed-confirm matches CTA enabled bg-red-700.
Stage 4 — submit pending "Erasing…" with spinner.
Stage 4 — success Redirect to Done tab; toast confirms.
Stage 4 — failure Toast "Erasure couldn't complete — try again." Form preserved.
Stage 5 — Done tab empty "No completed requests yet."
Stage 5 — Done tab populated Same card shape as Pending; status chip.
Stage 6 — Finalize-expired empty "No expired soft-deletes."
Stage 6 — populated Per-row Finalize → with typed-confirm flow.

Validation rules (UI surface; canonical server-side)

Rule Inline message
Stage 2.5 — subject not picked "Pick a Person."
Stage 2.5 — kind not selected "Pick the request kind."
Stage 2.5 — verification method empty "Describe how you verified the requester's identity."
Stage 3 — Generate JSON when status != pending (Disabled; defensive on POST.)
Stage 3 — Soft-delete when Person.deleted_at already set "This Person is already soft-deleted."
Stage 4 — typed-confirm doesn't match "Match the display name exactly, including spelling and special characters."
Stage 4 — eligibility check fails (deleted_at unset, or grace not yet elapsed) "Hard-erase is not yet eligible for this Person."
Stage 4 — Person already scrubbed "This Person was already hard-erased on {date}." (Defensive.)

Accessibility

  • SLA chip uses both color AND text — every signal is redundant. Overdue chip uses lucide:alert-triangle icon + text "Overdue by N days" — color-blind users get the message.
  • Tabs (Pending / Done) use <nav role="tablist"> with <button role="tab" aria-selected="…">.
  • Kind filter chips use the same <fieldset role="radiogroup"> pattern as the catalogue queue.
  • Greyed hard-erase CTA uses aria-disabled="true" with aria-describedby linking to the eligibility-date hint, so screen readers announce why it's disabled.
  • Stage 4 destructive CTA uses aria-describedby linking to the "what happens" list.
  • Subject card view-link uses descriptive label "View Lukas Müller's full record" (not just "View →") for screen readers.
  • Hit targets ≥ 44 × 44 px throughout.
  • Color contrast: every chip-color combination on design-tokens palette meets AA. Red-700 on red-50 = 5.46:1; amber-800 on amber-50 = 5.41:1; green-800 on green-50 = 6.21:1.
  • Focus management: advancing through Stage 2 → 3 → 4 moves focus to the H1 / H2 of the new stage. Returning via the back arrow restores focus to the previously-actioned element.
  • Without JS: the SLA chip is server-rendered HTML; the typed-confirm validation is server-side at submit; the dynamic search in Stage 2.5 falls back to a regular GET form. All flows degrade cleanly.

HTMX / progressive-enhancement seams

  • Stage 2 tab change: hx-get="/admin/dsar/queue?tab=done" hx-target="body" hx-push-url="true". Without JS: regular <a> navigation.
  • Stage 2 kind filter: hx-get="/admin/dsar/queue?kind=erasure" hx-target="#queue-list". Without JS: regular GET.
  • Stage 2.5 subject search: hx-get="/admin/dsar/queue/_search_persons?q=…" hx-trigger="keyup changed delay:200ms". Without JS: regular GET.
  • Stage 3 Generate JSON: regular <a href="/admin/dsar/queue/:id/json" download>. Server returns Content-Disposition: attachment JSON. No HTMX needed — file download is a native browser action.
  • Stage 3 Mark responded modal: hx-get="/admin/dsar/queue/:id/_mark_modal" hx-target="#modal-region". Without JS: regular form submit to a confirm-mark-responded route.
  • Stage 3 Soft-delete modal: same pattern.
  • Stage 4 typed-confirm validation: server-side canonical; HTMX optionally enables/disables the CTA via hx-get="/admin/dsar/queue/:id/_check_typed" hx-trigger="keyup" hx-target="#erase-cta". Without JS: user types, clicks Erase, sees the validation error if mismatched.
  • Stage 4 submit: regular form POST. The cascade transaction runs server-side; success returns hx-redirect header or 303.

The whole flow's fundamental contract is "regular form POSTs". HTMX swaps are surgical optimisations.

i18n affordance

String Type Catalogue key
"Data requests" (page title) {% trans %} admin.dsar.title
"Pending {N}" / "Done" {% trans %} (Babel) admin.dsar.tab.{pending,done}
"All" / "Access" / "Erasure" / "Portability" {% trans %} admin.dsar.filter.{all,access,erasure,portability}
"Access request (DSAR)" / "Erasure request" / "Portability request" {% trans %} admin.dsar.kind.{access,erasure,portability}
SLA chip text — "Overdue by {N} days" / "{N} days left" / "Due tomorrow" {% trans %} (Babel) admin.dsar.sla.{overdue,days_left,tomorrow}
"Verified by reply email" / "Verified — pending" {% trans %} admin.dsar.verified.{by_reply,pending}
"Open →" {% trans %} admin.dsar.list.open
"+ Log new request" {% trans %} admin.dsar.list.cta.log_new
"Nothing pending" / "All data requests have been resolved." {% trans %} admin.dsar.empty.{title,body}
"Log data request" (Stage 2.5) {% trans %} admin.dsar.log.title
"Subject (the Person making the request)" {% trans %} admin.dsar.log.subject.label
"Search Persons" {% trans %} admin.dsar.log.subject.search
"Selected: {name}" {% trans %} (Babel) admin.dsar.log.subject.selected
"Kind" / "Access (DSAR)" / "Erasure" / "Portability" (radio labels) {% trans %} admin.dsar.log.kind.{label,access,erasure,portability}
"Verification method" / "How you verified the requester is who they say they are." {% trans %} admin.dsar.log.verification.{label,hint}
"Channel notes" {% trans %} admin.dsar.log.channel.label
"Cancel" / "Log request" {% trans %} common.cancel / admin.dsar.log.cta.submit
"Request #{id}" (Stage 3 header) {% trans %} (Babel) admin.dsar.detail.title
"Subject" / "Request" / "Verification method" / "Response" / "Audit" {% trans %} admin.dsar.detail.section.{subject,request,verification,response,audit}
"Locale: {locale}" / "{N} orders · {N} ClientProfiles" / "View →" {% trans %} (Babel) admin.dsar.detail.subject.{locale,counts,view}
"Requested {date} by admin" / "Status {status}" / "Channel {channel}" {% trans %} (Babel) admin.dsar.detail.request.{requested,status,channel}
"Per FADP art. 25, this request must be answered within 30 days of {request_date} (i.e. by {sla_date})." {% trans %} (Babel) admin.dsar.detail.response.fadp_reminder
"Generate JSON response" {% trans %} admin.dsar.detail.response.cta.generate
"After download, attach to your reply email, then mark responded." {% trans %} admin.dsar.detail.response.hint
"Mark responded" {% trans %} admin.dsar.detail.response.cta.mark
"Mark this request responded?" / "This sets dsar_log.responded_at = now() and moves the request to the Done tab." {% trans %} admin.dsar.detail.mark_modal.{title,body}
"Response reference (optional)" {% trans %} admin.dsar.detail.mark_modal.ref.label
"Two-step erasure" / Stage 1 / Stage 2 explanatory copy {% trans %} admin.dsar.detail.erasure.{title,stage1,stage2}
"Soft-delete this Person" / "Soft-delete this Person?" / "Person.deleted_at = now()." {% trans %} admin.dsar.detail.erasure.soft.{cta,modal_title,modal_body}
"After {date}, hard-erase becomes available." {% trans %} (Babel) admin.dsar.detail.erasure.soft.modal_grace
"Notify stringers holding a ClientProfile for this Person" {% trans %} admin.dsar.detail.erasure.soft.notify_stringers
"Hard-erase (after grace)" / "Eligible {date}" {% trans %} (Babel) admin.dsar.detail.erasure.hard.{cta,eligible}
"Confirm hard-erase" (Stage 4 header) {% trans %} admin.dsar.erase.title
"Permanent data erasure" / "This will permanently scrub the Person's PII from the platform." {% trans %} admin.dsar.erase.{warning_title,warning_body}
"Soft-deleted {date}" / "Grace ended {date}" {% trans %} (Babel) admin.dsar.erase.subject.{soft_deleted,grace_ended}
"What happens" + the cascade-list bullets {% trans %} (Babel for counts) admin.dsar.erase.what_happens.{title,bullet_n} for each
"To confirm, type the Person's display name:" {% trans %} admin.dsar.erase.typed.prompt
"Hard-erase {name}" {% trans %} (Babel) admin.dsar.erase.cta.execute
Toast — "Hard-erase complete. {N} records scrubbed." {% trans %} (Babel) admin.dsar.toast.erase_success
Toast — "Erasure couldn't complete — try again." {% trans %} admin.dsar.toast.erase_failure
Toast — "Marked responded." {% trans %} admin.dsar.toast.marked
"Finalize expired soft-deletes" (Stage 6 header) {% trans %} admin.dsar.finalize.title
"Persons soft-deleted ≥ 30 days ago. Hard-erase is now eligible." {% trans %} admin.dsar.finalize.intro
"Soft-deleted {date}" / "Grace ended {date} ({N} days ago)" {% trans %} (Babel) admin.dsar.finalize.row.{soft_deleted,grace_ended}
"Finalize →" {% trans %} admin.dsar.finalize.row.cta
"No expired soft-deletes." {% trans %} admin.dsar.finalize.empty
Validation messages {% trans %} admin.dsar.validation.{subject_empty,kind_empty,verification_empty,verification_long,already_soft_deleted,already_scrubbed,not_eligible,typed_mismatch}
Person display names, emails, dates Data n/a

Iris's DE pass follows after merge.

DE width budget (designer note)

  • "Data requests" → "Datenanfragen" (similar).
  • "Erasure request" → "Löschungsanfrage" (~1.4×).
  • "Access request (DSAR)" → "Zugriffsanfrage (DSAR)" (~1.3×).
  • "Portability request" → "Übertragbarkeitsanfrage" (~1.7×) — fits if kind tag wraps.
  • "Generate JSON response" → "JSON-Antwort generieren" (~1.0×) — fits CTA.
  • "Soft-delete this Person" → "Diese Person sanft löschen" (~1.5×) — fits modal CTA.
  • "Hard-erase (after grace)" → "Endgültig löschen (nach Schonfrist)" (~1.4×) — fits.
  • "Per FADP art. 25, this request must be answered..." — long sentence. DE: "Gemäss FADP Art. 25 muss diese Anfrage innerhalb von 30 Tagen ab {request_date} (d.h. bis {sla_date}) beantwortet werden." (~1.3×) — wraps to 2-3 lines on sm; reserve room.
  • The cascade bullets (long sentences) all need DE pass with care; none are tight on width because they wrap freely.

Cross-references

  • Source requirements: fadp-posture (Iris) — DSAR / erasure / portability rights, retention policy, processor disclosure, hidden requirements.
  • Architecture: data-model, ADR-0004 § FADP positioning.
  • Linked design surfaces: stringer-dashboard § Inbox (entry-point chip), admin-person-merge (sibling admin tooling, shared Person picker + typed-confirm convention), design-tokens.
  • Future surfaces:
  • V3 client portal "Download my data" + "Delete my account" — per fadp-posture § hidden requirements 2 + 3; the self-service mirror of this admin queue. Not in V2 / Round 3.
  • V3 portal magic-link claim consent-ratification screen — per fadp-posture § hidden requirement 4; a separate surface from this queue but related FADP context.
  • Generic admin audit-log search — across all share_audit event kinds. Queued; not in Round 3.
  • Privacy-notice page — per fadp-posture § A-NOTICE-1; legal-review deliverable, not Iris's; outside Round 3 scope.
  • Coordination: Iris (this surface implements her FADP spec — cross-link her MR / commit); Theo (dsar_log table schema; share_audit.event_kind extension to include person_erasure, dsar_served, consent_change); Pax-B (server endpoints — DSAR JSON pipeline, soft-delete + hard-erase cascade, finalize-expired endpoint); Juno (frontend implementation).
  • i18n strategy: i18n architecture.
  • Issue tracking: racket-book#109.

Open questions for Stefan (with proposed defaults)

  1. SLA chip thresholds — 7 / 14 / 30 days, or different? Proposed default: red ≤ 7 days, amber 8–14, slate 15–30. Matches industry convention for FADP-style 30-day SLAs. Alternative: red ≤ 5, amber 6–10, slate 11–30 (tighter alarm); or red ≤ 10 (more aggressive escalation). Stefan to confirm; the threshold is a config constant.

  2. Subject card "View →" link target. Proposed default: link to a Person admin detail page (queued for future; out of Round 3). For V2, the link can either be hidden entirely (simplest) OR link to a basic order-list filtered to this Person. Stefan to confirm. Mira leans hidden for V2 — the detail view's metadata is sufficient for the DSAR case; the deeper drill-down is a future round.

  3. Mark-responded modal — required response-reference vs. optional. Proposed default: optional. The reference is a free-text field for Stefan's records ("Sent JSON via email at 14:30") — the audit value is high but the friction of requiring it on every mark-responded is too high. Alternative: required (forces audit hygiene). Stefan to confirm.

  4. Generate JSON response — does the server immediately download, or first show a preview? Proposed default: immediate download. The JSON is the canonical artifact; rendering a preview would essentially duplicate the spec. Alternative: a preview page with the JSON pretty-printed + "Download" button (more forensic; more friction). Mira leans direct download.

  5. DSAR / portability — same JSON document. Confirmed via A-PORT-1: the response is identical. Flagged here for visibility; nothing to flip.

  6. Erasure soft-delete — auto-checked "Notify stringers" box, or unchecked default? Proposed default: checked by default. Per A-ERA-4 / fadp-posture § Stringer-side notification the notification is the rule. Alternative: unchecked default (more friction; safer against accidental notify-storms when admin is testing). Mira leans checked-default + admin-can-uncheck-with-reason.

  7. Hard-erase eligibility countdown — show in days, hours, or live timer? Proposed default: days-resolution countdown. "Eligible in 24 days" / "Eligible in 1 day" / "Eligible today" / "Eligible since N days ago". Alternative: live ticking timer (overkill for a 30-day window). Stefan to confirm.

  8. Stage 6 finalize-expired surface — separate sub-route, or merged into the main DSAR queue tabs? Proposed default: separate sub-route. The finalize-expired list is a different cohort (Persons whose soft-delete grace expired but admin hasn't yet finalized) from the dsar_log queue. Mixing them confuses the mental model. Alternative: a third tab in the queue ("Pending / Done / Expired finalizes"). Stefan to confirm; the separate-route is cleaner.

  9. dsar_log row for the soft-delete vs. hard-erase steps. Proposed default: one dsar_log row per request, lifecycle through pending → soft_deleted (custom status?) → responded on hard-erase. OR: separate dsar_log rows per stage (soft-delete is one row; hard-erase is another). Iris's schema spec lists pending / responded / cancelled as the status enum — implying one row per request. Mira leans one-row; flag for Theo's data-model amendment.

  10. Stage 6 — how does admin learn about expired soft-deletes? Proposed default: dashboard chip — "{N} expired soft-deletes — finalize?". Adds another admin-only chip alongside the catalogue + DSAR chips. Alternative: a daily email / notification (V2 reality has no notification dispatcher in V2; fan-out infra is V3). Mira leans the dashboard chip; Stefan to confirm.

  11. Soft-delete reversal during grace — admin-only or self-service via re-magic-link? Per A-ERA-1: "Soft-delete is reversible by Stefan (V2) or by the Person themselves via re-magic-link (V3)." V2 reality: admin-only reversal. The reversal is a small button on the detail view of a soft-deleted-but-not-yet-erased Person:

   ┌─────────────────────────────────┐
   │  Reverse soft-delete            │
   └─────────────────────────────────┘

Tap → modal "Reverse the soft-delete? Person becomes active again." → on confirm, clear Person.deleted_at and write a share_audit row. Stefan to confirm the reversal CTA shape; current design only sketches it in the detail view as a future edit. (Round-3 deliverable surfaces only the soft-delete + hard-erase paths; the reversal path is logically the inverse and queued for inclusion.)

  1. Audit sub-block on Stage 3 — top 5 prior dsar_log entries vs. all. Proposed default: all (V2 reality: maximum a handful). At V2 scale, "all" is small. Alternative: top 5 + link. Mira leans all-because-rare.

  2. Identity-verification panel — separate from the request creation, or always shown? Proposed default: always shown on detail. The verification method is the FADP-defensible artifact; surfacing it on the detail view ensures admin can see what they wrote at log time. Alternative: collapsible. Mira leans always-shown.

  3. DSAR JSON file naming. Proposed default: person-{id}-dsar-{YYYYMMDD}-{request_id}.json. Includes the dsar_log request ID for back-correlation. Alternative: dsar-{request_id}.json (simpler; hides the Person ID). Stefan to confirm; the verbose name is the audit-hygiene pick.

  4. Logging "Cancel" on Stage 4 (typed-confirm declined / dismissed). Proposed default: do NOT log a share_audit row on cancel. The admin opened the page, looked at the dry-run, and chose not to proceed — that's not a domain event. Alternative: log it for transparency. Adds noise; rare value. Mira leans no-log.

  5. Stage 5 (Done tab) — open detail in read-only mode, or hide entirely? Proposed default: read-only detail. Stefan needs to look back at "what was the response reference for last month's DSAR?" — the read-only detail is the audit lookup. Alternative: hide; the only access is via a dedicated audit-log surface (which is a future round). Mira leans accessible read-only.

  6. Multi-admin (V2.x) — DSAR queue assignment per admin? Proposed default: shared queue, no per-admin assignment in V2. V2 reality: only Stefan. The shared queue is the default; assignment is V2.x. Stefan to confirm; defer.

  7. DSAR notification to other stringers (per stringer-side notification). Proposed default: in-app notification fires automatically on hard-erase commit; email opt-in per Person.notification_prefs for the affected stringers. This is in scope for the cascade transaction but its UX surface is the stringer's own notifications inbox (a queued surface; out of Round 3 spec). Flagged here for cross-round-coordination.

  8. Soft-delete date display — relative + absolute (Mira's convention from catalogue queue)? Proposed default: yes — both, like other surfaces. Consistency with admin-catalogue-moderation Open question 2.