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¶
- fadp-posture § Right to access — V2 admin-mediated channel; out-of-band request; admin runs the export pipeline; emails result back. SLA 30 days per FADP art. 25.
- fadp-posture § Right to erasure — V2 admin-mediated; two-stage (soft-delete then hard-erase after 30-day grace).
- fadp-posture § Right to data portability — same JSON document as DSAR; tracked in
dsar_logwithkind = portability_request. - fadp-posture § Hidden requirements 1, 7 — admin "Process DSAR" workflow; admin "Finalize expired soft-deletes" affordance.
- fadp-posture § Schema asks —
dsar_logtable;Person.deleted_at,Person.scrubbed_at; admin-triggered finalize-expired-persons endpoint; order PII scrub helper. - Round-3 scope — list view, per-request render-export, execute-erasure with safety prompt, 30-day SLA chip, audit per
dsar_log.
Goal¶
Three commitments:
- 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.
- 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).
- 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¶
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_logrow inpendingstatus, 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.kindenum:access_request|erasure_request|portability_request. - Verification method. Required textarea per A-DSAR-8: "the
dsar_logrow 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_logrow with: person_id = :selectedkind = :selected_kindrequested_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 = :providedmeta = {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¶
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)¶
┌───────────────────────────────────────┐
│ ← 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_sharesandperson_stringer_sharerows; oneshare_auditrow per revocation. - Insert
share_auditrow withevent_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_logrow: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¶
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)¶
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-triangleicon + 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"witharia-describedbylinking to the eligibility-date hint, so screen readers announce why it's disabled. - Stage 4 destructive CTA uses
aria-describedbylinking 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-tokenspalette 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 returnsContent-Disposition: attachmentJSON. 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-redirectheader 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_auditevent 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_logtable schema;share_audit.event_kindextension to includeperson_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)¶
-
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.
-
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.
-
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.
-
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.
-
DSAR / portability — same JSON document. Confirmed via A-PORT-1: the response is identical. Flagged here for visibility; nothing to flip.
-
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.
-
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.
-
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.
-
dsar_logrow for the soft-delete vs. hard-erase steps. Proposed default: onedsar_logrow per request, lifecycle throughpending → soft_deleted (custom status?) → respondedon hard-erase. OR: separatedsar_logrows per stage (soft-delete is one row; hard-erase is another). Iris's schema spec listspending / responded / cancelledas the status enum — implying one row per request. Mira leans one-row; flag for Theo's data-model amendment. -
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.
-
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:
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.)
-
Audit sub-block on Stage 3 — top 5 prior
dsar_logentries 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. -
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.
-
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. -
Logging "Cancel" on Stage 4 (typed-confirm declined / dismissed). Proposed default: do NOT log a
share_auditrow 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. -
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.
-
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.
-
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_prefsfor 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. -
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.