Admin — Finalize expired soft-deletes¶
The admin's destructive-cleanup surface: when a Person's 30-day soft-delete grace expires, the admin runs hard-erase to scrub the row's PII per FADP. This spec wraps Pax's existing POST /admin/persons/finalize-expired endpoint behind a typed-confirm + dry-run preview UX. Owned by Mira. Cross-cuts: fadp-posture § Two-step erasure, fadp-implementation-asks § R-FADP-5, ADR-0011 § Bypass-scope declaration, admin-dsar-queue § Stage 6 (the sister surface inside the DSAR queue), admin-person-merge § Stage 3 (the canonical typed-confirm pattern this surface inherits), design-tokens.
Source requirement¶
- fadp-posture § A-ERA-2 — "Hard-erase is only callable after the 30-day grace expires (parallels the stringer-side admin-finalize-after-grace rule). Admin-triggered, not auto-cron."
- fadp-posture § hidden requirement 7 — "Admin 'Finalize expired soft-deletes' affordance — list of soft-deleted Persons whose 30-day grace has expired; bulk-finalize action with typed-confirm pattern (per the stringer-lifecycle finalize convention)."
- fadp-implementation-asks R-FADP-5 — endpoint signature, per-Person transaction, dry-run query parameter, idempotency. This UX is the front-end Pax's endpoint was shaped for: A-FADP-5.5 explicitly says "the admin UX (Mira) calls
dry_run=truefirst to surface the typed-confirm pattern." - ADR-0011 § Bypass-scope table —
POST /admin/persons/finalize-expiredbypasses tenancy AND consent; writes to bothadmin_audit_log(action=person.finalize_expired) ANDshare_audit(event_kind=person_erasure). The composite-caserequest_idjoins the two rows. - fadp-posture § Cascade rules — what the hard-erase scrubs (Person PII, Order.comments, receipt-emit-log snapshot PII) and what it retains (Order rows, ClientProfile rows, share_audit history).
- Round 10 scope (#156) — standalone admin-surface spec; sibling to admin-dsar-queue Stage 6 which sketched the list-of-eligible-Persons sub-route. This spec fleshes out the typed-confirm wrapper for one-Person-at-a-time finalize.
Goal¶
Three commitments:
- Hard-erase is the most destructive admin action in the app. It is irreversible PII scrub under a legal obligation (FADP). Friction is welcome; the UX optimises for decision quality, not throughput. Per fadp-implementation-asks A-FADP-5.5 the admin UX calls
dry_run=truefirst so the preview is the canonical view of what will happen. - The dry-run preview is the contract. Pax's endpoint already returns the candidate count + IDs without mutation. The UX surfaces that list as a per-Person summary — counts of rows-to-scrub per table — so the admin sees the exact blast radius before typing the confirm string.
- Reuse the established typed-confirm pattern; do NOT invent a new one. admin-person-merge Stage 3 and admin-dsar-queue Stage 4 both gate destructive actions behind typing the subject's display name, exact-match, special characters intact. This surface follows the same convention. Consistency across destructive-action surfaces is the safety property.
Information architecture¶
Admin-only route family at /admin/persons/finalize-expired. Three stages, each a distinct route, so back-arrow navigation and tab-refresh preserve state via URL.
Admin
├── Dashboard (existing) ← chip "{N} expired soft-deletes" (when N > 0)
└── Finalize expired soft-deletes (new)
├── Stage 1 — Eligible list GET /admin/persons/finalize-expired
│ ├── Per-Person summary card
│ │ ├── Display name (pre-scrub)
│ │ ├── deleted_at + grace-expiry timestamps
│ │ ├── Rows-to-scrub counts per table
│ │ └── "Review & finalize →"
│ ├── Empty state ("Nothing to finalize")
│ └── Audit footer (link to past finalizes)
├── Stage 2 — Per-Person dry-run GET /admin/persons/finalize-expired/{person_id}
│ ├── Person summary card (pre-scrub)
│ ├── Deactivation timeline (deleted_at → grace_ends_at → now)
│ ├── Cascade preview (full counts + cascade rules)
│ ├── Typed-confirm input
│ └── [Cancel] [Finalize {display_name}] (destructive bg-red-700)
└── Stage 3 — Post-finalize summary GET /admin/persons/finalize-expired/{person_id}/done
├── "Finalized" status chip
├── Cascade summary (actuals from the audit row)
├── audit_log + share_audit cross-link block
└── [Back to eligible list]
This sub-surface is a sibling, not a child, of the DSAR queue. admin-dsar-queue Stage 6 sketches the same eligible-list view at /admin/dsar/queue/finalize-expired and per-row "Finalize →" affordance, but defers the typed-confirm page to "the same Stage 4 typed-confirm page, scoped to that Person." This spec is the canonical home of that typed-confirm page. The DSAR-queue Stage 6 list and this Stage 1 list are the same view rendered under two route prefixes — one entry-point lives inside DSAR queue (for admins who are already there processing erasure requests), one entry-point lives in the dashboard chip (for admins arriving fresh).
Route-alias note (Juno-flag)¶
The two URLs above (/admin/dsar/queue/finalize-expired and /admin/persons/finalize-expired) MUST resolve to the same template + handler. Juno picks one canonical route and 302-redirects the other. Mira's preferred canonical: /admin/persons/finalize-expired (matches Pax's existing POST endpoint family; the DSAR-queue sub-route is the entry-point alias). This is a routing decision, not a UX one — Stefan to confirm during implementation.
Stage 0 — Dashboard entry point¶
The admin dashboard surfaces a chip for pending expired soft-deletes, alongside the existing DSAR-queue chip and catalogue chip. Distinct from the DSAR-queue chip because the two cohorts are different:
- DSAR-queue chip counts open
dsar_logrows (pending requests, including erasure-requests still inside the 30-day grace). - Finalize-expired chip counts
Personrows wheredeleted_at < NOW() - INTERVAL '30 days' AND scrubbed_at IS NULL— Persons whose grace has actually expired.
A Person can appear in both cohorts (the dsar_log row is "responded — soft-delete written"; the Person is now expired-eligible). Surfacing them separately is correct: the DSAR-queue lifecycle is "process the request" and the finalize-expired lifecycle is "execute the scrub now that grace ended."
┌───────────────────────────────────────┐
│ Inbox ⓘ │
│ ┌───────────────────────────────────┐ │
│ │ 🧹 2 expired soft-deletes │ │ Finalize-expired chip
│ │ Hard-erase eligible │ │
│ │ Review → │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 🛡 1 data request pending │ │ Existing DSAR chip
│ ⋯ │
└───────────────────────────────────────┘
Behaviour¶
- Chip background colour:
- 1+ rows where
grace_ends_at < NOW() - INTERVAL '7 days'(the admin has been sitting on a 7+ day overdue scrub):bg-amber-50 border-amber-200 text-amber-800. The cleanup is overdue but not legally urgent (FADP gives no further deadline once grace passes — the Person has already requested erasure and the platform is in compliance fromscrubbed_atset; sitting on a few days of extra grace is the operational reality of one human admin). - All rows ≤ 7 days past grace-expiry:
bg-slate-50 border-slate-200 text-slate-700. - Chip text: "{N} expired soft-delete{s}" + sub-line "Hard-erase eligible".
- Icon
lucide:eraser(the broom/cleanup connotation) — distinguishes visually from the DSAR-queuelucide:shield. - Tap →
/admin/persons/finalize-expired(Stage 1). - Non-admins do not see the chip.
- When N = 0, the chip is hidden entirely (zero-state-on-dashboard is noise).
Stage 1 — Eligible list¶
The list of Persons whose soft-delete grace has expired. Backed by GET /admin/persons/finalize-expired?dry_run=true (Pax's existing endpoint), which returns the candidate IDs. The page fans out one-detail-query-per-ID server-side to render the per-Person summary cards; the fan-out is bounded (V2 reality: dozens-per-quarter max).
sm 375 px¶
┌───────────────────────────────────────┐
│ ← Finalize expired soft-deletes │ Header
├───────────────────────────────────────┤
│ │
│ Persons whose 30-day soft-delete │ text-body slate-700
│ grace has expired. Hard-erase is │
│ admin-triggered — these will not │
│ finalize until you do. │
│ │
│ ⓘ Each finalize is irreversible. │ text-small slate-600
│ Review the preview before │ + lucide:info
│ confirming. │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ⚠ Grace ended 12 days ago │ │ Overdue chip — amber-700
│ │ │ │
│ │ Lukas Müller │ │ Display name (pre-scrub) — text-h3
│ │ Soft-deleted 2026-04-01 │ │ text-small slate-700
│ │ Grace ended 2026-05-01 │ │ text-small slate-700
│ │ │ │
│ │ Will scrub: │ │ text-small slate-700
│ │ • 1 Person row (PII fields) │ │
│ │ • 17 Order.comments fields │ │
│ │ • 17 receipt-emit-log snapshots │ │
│ │ • 2 ClientProfiles retained │ │ (greyed — not scrubbed)
│ │ │ │
│ │ Review & finalize →│ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 📅 Grace ended 2 days ago │ │ Recent chip — slate-700
│ │ │ │
│ │ Anna Bauer │ │
│ │ Soft-deleted 2026-04-11 │ │
│ │ Grace ended 2026-05-11 │ │
│ │ │ │
│ │ Will scrub: │ │
│ │ • 1 Person row (PII fields) │ │
│ │ • 8 Order.comments fields │ │
│ │ • 8 receipt-emit-log snapshots │ │
│ │ • 1 ClientProfile retained │ │
│ │ │ │
│ │ Review & finalize →│ │
│ └───────────────────────────────────┘ │
│ │
│ ─── Recent finalizes ─── │ H3 — audit trail tail
│ ┌───────────────────────────────────┐ │
│ │ 2026-04-22 — admin Stefan Wagen │ │ text-small slate-600
│ │ Finalized [redacted] (person id │ │
│ │ ending in …7a3) │ │
│ │ 12 records scrubbed │ │
│ └───────────────────────────────────┘ │
│ See full audit → │
│ │
└───────────────────────────────────────┘
md 768 px+¶
Same content, max-width 720 px, centered. Cards stack one-per-row (the per-Person detail is dense enough that side-by-side would force unhelpful narrowing).
Empty state¶
┌───────────────────────────────────────┐
│ ← Finalize expired soft-deletes │
├───────────────────────────────────────┤
│ │
│ Nothing to finalize │ H2
│ │
│ No Persons have an expired │ text-body slate-600
│ soft-delete grace right now. When │
│ someone requests erasure, you'll │
│ process it in the DSAR queue. The │
│ hard-erase becomes available here │
│ 30 days after soft-delete. │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Back to admin dashboard │ │
│ └─────────────────────────────────┘ │
│ │
│ ─── Recent finalizes ─── │
│ ⋯ │
│ │
└───────────────────────────────────────┘
The recent-finalizes block always renders (even on empty), so the admin can confirm "yes, I have run this before; here's what I scrubbed last time."
Behaviour¶
- Sort order: descending by
grace_ends_atoverdue duration. Most-overdue first. Within the same overdue bucket, sort bydeleted_atascending (oldest soft-delete bubbles up). - Overdue chip thresholds:
- Overdue by > 7 days:
bg-amber-50 border-amber-200 text-amber-800"Grace ended {N} days ago" —lucide:alert-triangle. - Overdue by 1–7 days:
bg-slate-50 border-slate-200 text-slate-700"Grace ended {N} days ago" —lucide:calendar. - Overdue by 0 days (just-expired):
bg-slate-50 border-slate-200 text-slate-700"Grace ended today" —lucide:calendar. - Per-Person card. Each card renders enough information to decide "is this the right scrub to run now?" — pre-scrub display name (Person.display_first_name + display_last_name as they currently are; not yet redacted), soft-delete + grace-end timestamps, and a dry-run cascade preview of how many rows the scrub will touch. The preview comes from a per-Person dry-run query the server runs at render time:
- 1 Person row — always, scrubs
display_first_name,display_last_name,email,email_verified_at,notification_prefs,claim_token. - N
Order.commentsfields — countsOrderrows linked via this Person'sClientProfiles. - N receipt-emit-log snapshots — counts
ReceiptEmitLogrows for those Orders. - N
ClientProfilerows retained (not scrubbed; surfaced for transparency — the admin sees that the stringer's notes survive the erasure). - N
order_shares/person_stringer_sharerows revoked (shown only when N > 0; suppressed when 0 to keep the card scannable). - "Review & finalize →" advances to Stage 2 for that Person. Whole card is the tap target.
- "Recent finalizes" block lists the last 5
admin_audit_logrows withaction = person.finalize_expired(across all admins, V2 reality: only Stefan). Each row: - Date + admin display name.
- "Finalized [redacted] (person id ending in …{last 4 chars of UUID})" — the scrubbed Person's name is gone, so the row uses the redacted placeholder + a non-PII id-suffix to disambiguate.
- Cascade summary count ("N records scrubbed") — comes from the
share_audit.meta.cascade_summarywritten by Pax's endpoint. - "See full audit →" links to
/admin/audit?action=person.finalize_expired(the generic admin-audit-log search surface — queued for a future round; the link is the forward reference). - Concurrency. No locking. If two admin tabs are open and both try to finalize the same Person, the second submit hits A-FADP-5.4 idempotency (the predicate excludes already-scrubbed rows) and the server returns "Person already finalized" (Stage 3 with a different banner — see Validation).
Component breakdown¶
| Component | Notes |
|---|---|
| Header | "Finalize expired soft-deletes" text-h1. Back arrow → admin dashboard. |
| Intro paragraph | text-body slate-700, two sentences. |
| Irreversibility callout | text-small slate-600 with lucide:info; pinned above the list. |
| Person card | bg-white border border-slate-200 rounded-lg p-4. Overdue chip pinned top-right. |
| Overdue chip | inline-flex items-center gap-1 text-tiny font-medium px-2 py-0.5 rounded. Color per threshold. |
| Display name | text-h3. Pre-scrub value (no redaction yet). |
| Timestamp lines | text-small slate-700. Absolute date format "YYYY-MM-DD". |
| Will-scrub list | <ul> with bullet markers; text-small slate-700; counts in font-mono tabular-nums. |
| Retained line | text-small slate-500 italic — distinguishes "retained" from "scrubbed" rows visually. |
| Review-CTA | Right-aligned link inside card; whole card is the trigger. |
| Recent-finalizes block | bg-slate-50 border border-slate-200 rounded-lg p-3 text-small. |
| See-full-audit link | text-small text-indigo-700 underline. |
Cascade preview accuracy¶
The per-card preview counts come from server-side queries at render time, not from a stored snapshot. This means the preview shifts if a stringer adds a new Order between the admin opening the list and tapping "Review & finalize →" (rare but possible). Stage 2 re-fetches at that step too, so the numbers on the confirm screen are the canonical preview the admin types against. The Stage-1 numbers are advisory.
Stage 2 — Per-Person dry-run + typed-confirm¶
The destructive-action gate. Renders the full dry-run preview, the deactivation timeline, and the typed-confirm input.
sm 375 px¶
┌───────────────────────────────────────┐
│ ← Finalize {display_name} │ Header
├───────────────────────────────────────┤
│ │
│ ⚠ Permanent data erasure │ text-h1 + lucide:alert-triangle
│ │ red-700
│ │
│ This will scrub the Person's PII │ text-body slate-700
│ from the platform under FADP. The │
│ action is irreversible; orders are │
│ retained but receipt-affecting │
│ names are replaced with "[redacted]" │
│ on any re-render. │
│ │
│ ─── Subject ─── │
│ ┌───────────────────────────────────┐ │
│ │ Lukas Müller │ │ Pre-scrub display name — text-h3
│ │ ✓ lukas.mueller@example.com │ │ Verified email (if set)
│ │ Locale: de │ │ text-small slate-700
│ │ Created 2024-08-12 │ │ text-small slate-700
│ │ by stringer Stefan Wagen │ │ Provenance per A-CONS-1
│ │ 17 Orders · 2 ClientProfiles │ │
│ └───────────────────────────────────┘ │
│ │
│ ─── Timeline ─── │
│ │
│ Soft-deleted 2026-04-01 14:22 UTC │ text-small slate-700
│ Grace ends 2026-05-01 14:22 UTC │ text-small slate-700
│ Grace ended 12 days ago │ text-small amber-700 bold
│ Now 2026-05-13 09:15 UTC │ text-small slate-700
│ │
│ ─── What happens ─── │
│ │
│ Will scrub: │ text-small slate-700
│ ✕ 1 Person row │ + lucide:eraser red-700
│ display_first_name, display_last_ │
│ name → "[redacted]" │
│ email → NULL │
│ email_verified_at → NULL │
│ notification_prefs → {} │
│ claim_token → NULL │
│ ✕ 17 Order.comments fields → │
│ "[redacted by request]" │
│ ✕ 17 receipt-emit-log snapshots — │
│ PII fields scrubbed; structural │
│ fields retained │
│ ✕ 0 share grants → revoked │ (0 case still shown for transparency)
│ │
│ Will retain: │
│ ✓ 17 Order rows (10-year CO │ + lucide:archive slate-500
│ retention) │
│ ✓ 2 ClientProfile rows — stringer │
│ notes are not the Person's data │
│ ✓ N share_audit rows (FADP- │
│ defensible audit trail) │
│ ✓ N dsar_log rows (legitimate │
│ interest) │
│ │
│ Will notify: │
│ → 2 stringers holding a ClientProfile│ text-small slate-700
│ for this Person (in-app per │
│ A-ERA-4) │
│ │
│ ─── Confirm ─── │
│ To confirm, type the Person's │ text-small slate-700
│ display name exactly: │
│ "Lukas Müller" │ text-body bold slate-900
│ │
│ ┌───────────────────────────────────┐ │
│ │ │ │ Confirmation input
│ └───────────────────────────────────┘ │
│ Match the display name exactly, │ text-tiny slate-500
│ including spelling and special │
│ characters. │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Cancel │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Finalize Lukas Müller │ │ Primary CTA — bg-red-700
│ └─────────────────────────────────┘ │ Disabled until typed-confirm matches
│ │
└───────────────────────────────────────┘
md 768 px+¶
Same content, max-width 720 px, centered. The timeline gains a horizontal-rule visual:
─── Timeline ───
deleted_at grace_ends_at now
2026-04-01 ─────── 2026-05-01 ─── 2026-05-13
●─────── 30 days ────────●─ 12 days overdue ──▶
The horizontal timeline is a polish; on sm the text-only list is sufficient.
Behaviour¶
- Page (not modal). Same reasoning as admin-dsar-queue Stage 4 and admin-person-merge Stage 3: destructive action deserves real estate, full URL for state, ability to refresh/back without losing context.
- Eligibility guard (server-side). Before rendering, the server re-checks Pax's predicate (
Person.deleted_at IS NOT NULL AND Person.deleted_at < NOW() - INTERVAL '30 days' AND Person.scrubbed_at IS NULL). If the predicate fails — e.g. the Person was already scrubbed by a concurrent admin tab, or someone reversed the soft-delete during grace — the page 302-redirects to Stage 1 with a toast: "This Person is no longer eligible to finalize." See Validation for the specific cases. - Subject summary card. Pre-scrub display name (the name the admin will type to confirm), email (if any), locale, provenance (
Person.created_by_kind+Person.created_by_idper A-CONS-1), and the order/profile counts. Provenance and counts are surfaced because they help the admin disambiguate when multiple Persons have similar names — same reasoning as the admin-person-merge Stage 2 panes. - Timeline. Three timestamps + the "{N} days ago" relative cue on grace-end. The relative cue is
text-small amber-700 boldwhen the overdue duration > 7 days;text-small slate-700otherwise. UTC stamps shown explicitly so the admin understands the timezone of the clock — V2 has no per-stringer timezone preference yet, so UTC is the unambiguous default. - Dry-run preview ("What happens"). Server-rendered from a per-Person dry-run query. Cascade rules mirror fadp-posture § Cascade rules line-by-line. The split into Will scrub / Will retain / Will notify is the load-bearing UX choice — the admin needs to see that some things survive (Order rows, ClientProfile notes, audit) so they understand the FADP-defensible distinction. Bullets use icons + text + color so the redundancy survives color-blindness.
- Typed-confirm. Same convention as admin-person-merge Stage 3:
- Exact match including special characters (umlauts, hyphens, casing).
- The literal name shown in
text-body bold slate-900between curly quotes for visual separation. - Inline hint below the input: "Match the display name exactly, including spelling and special characters." (Same string as the sibling surfaces.)
- Finalize CTA disabled until typed text matches the canonical
Person.display_first_name + " " + Person.display_last_name. - Finalize CTA is
bg-red-700 text-white. Label includes the Person's display name: "Finalize Lukas Müller". (Same label-with-name convention as admin-person-merge Stage 3 "Merge into Lukas Müller".) - On submit: server runs Pax's existing endpoint with
dry_run=falsescoped to the singleperson_id(the endpoint supports the bulk-list shape; this UX submits one ID per request — see Open question OQ-FE-1 on bulk-vs-singleton). The cascade per routes_admin_persons.py::_finalize_one writes: Person.scrubbed_at = NOW()+ PII columns scrubbed (display names →[redacted], email → NULL, etc.).scrub_orders_for_person(person_id)per R-FADP-6.share_auditrow withevent_kind = person_erasure,actor_id = admin.stringer_id,target_id = person_id,meta = {phase: "hard_erase", cascade_summary: {orders_scrubbed: N, snapshots_scrubbed: N}}.- Any pending
dsar_logrows withrequest_kind = erasurefor this Person → flipped tooutcome = served; oneshare_auditrow per dsar withevent_kind = dsar_served. - Composite-case per ADR-0011:
admin_audit_logrow withaction = person.finalize_expired,target_id = person_id,metadata.request_id = <UUID>,metadata.cascade_summary = {...}. The samerequest_idlands inshare_audit.meta.request_idso the forensic JOIN works (per ADR-0011 §"Composite case"). - Success state: 303-redirect to Stage 3 (
/admin/persons/finalize-expired/{person_id}/done) with a toast: "Finalized {display_name}. {N} records scrubbed." - Failure handling. See Failure / partial-success.
Component breakdown¶
| Component | Notes |
|---|---|
| Header | "Finalize {display_name}" text-h2. Back arrow → Stage 1 with focus restored to the just-actioned card. |
| Warning H1 | text-h1 red-700 with lucide:alert-triangle 24 px inline. Visually heavier than the merge spec's amber warning — hard-erase is destructive AND irreversible, merge is destructive but reversible-via-DB. |
| Subject card | bg-white border border-slate-200 rounded-lg p-3. Verified-email line uses lucide:check-circle text-green-700. |
| Timeline list | <dl> definition list; text-small. Relative grace-end cue in amber-700 (overdue > 7d) or slate-700. |
| What-happens block | Three sub-sections: Will scrub (red-700 icon), Will retain (slate-500 icon), Will notify (slate-700 icon). Each is a <ul>. |
| Will-scrub icon | lucide:eraser text-red-700 16 px. |
| Will-retain icon | lucide:archive text-slate-500 16 px. |
| Will-notify icon | lucide:bell text-slate-700 16 px. |
| Cascade bullets | text-small slate-700; sub-text describing scrubbed fields in text-tiny slate-500 font-mono to highlight the schema-specific nature. |
| Typed-confirm prompt | "To confirm, type the Person's display name exactly:" + the literal name in text-body bold slate-900 between "…". |
| Typed-confirm input | text-body; placeholder empty; min height 48 px; autocomplete="off". aria-describedby linked to the prompt + hint. |
| Inline hint | text-tiny slate-500 below input; identical copy to merge/dsar siblings. |
| Cancel CTA | bg-white border border-slate-300 text-slate-700; full-width. |
| Finalize CTA | bg-red-700 text-white; full-width; disabled until typed-confirm matches. Label includes pre-scrub name. |
Typed-confirm canonical pattern (reused, not reinvented)¶
This surface adopts the canonical typed-confirm pattern established in admin-person-merge Stage 3 and reused by admin-dsar-queue Stage 4. For clarity, the canonical pattern in one place:
- Confirm string format: the literal
Person.display_first_name + " " + Person.display_last_name(single ASCII space between the two name fields). UTF-8 special characters (umlauts, hyphens, apostrophes) preserved as-is from the source row. - Match strictness: exact byte-for-byte after trimming leading/trailing whitespace from the typed input (so a stray trailing space on auto-suggest doesn't fail the check). Case-sensitive. Accent-sensitive. No fuzz.
- Server-side canonical check. The route validates the typed string against the freshly-fetched Person row at submit time. The client-side enable/disable is HTMX comfort, not enforcement: on submit the server re-checks and 422s with the form re-rendered + inline error if mismatched.
- CTA-label pattern. Primary CTA label is "{Verb} {display_name}" — "Finalize Lukas Müller", "Merge into Lukas Müller", "Hard-erase Lukas Müller". The name in the CTA visually mirrors the typed-confirm prompt, reinforcing "this exact name is what you're acting on."
- Color. Red-700 background (
bg-red-700 text-white), white text. Disabled statebg-slate-300 text-slate-500 cursor-not-allowed disabled:opacity-100(no transparency reduction; the disabled state is informational, not deemphasised). - Asymmetric variant for known-cohort actions — see admin-v1-upload Stage 3: typed-confirm for prod DB, one-tap for test DB. NOT applicable here — finalize-expired has no "test" cohort; every finalize is real production data.
This spec deliberately does NOT define a new pattern — the Recommendation summary below is a one-screen-spec restatement so future destructive-action authors can find the contract without re-reading three sibling specs.
Stage 3 — Post-finalize summary¶
The read-only audit-trail page. The Person's row is now scrubbed; the display name on this page is the pre-scrub value carried over in the page state (via the admin_audit_log.metadata.pre_scrub_display_name field — Pax flag, see Open question OQ-FE-3).
sm 375 px¶
┌───────────────────────────────────────┐
│ ← Finalize — done │ Header
├───────────────────────────────────────┤
│ │
│ ✓ Finalized │ Status — green-700 bg
│ │
│ Lukas Müller (now redacted) │ Pre-scrub name + scrubbed marker
│ Finalized at: 2026-05-13 09:16 UTC │ text-small slate-700
│ Duration: 0.8 s │ text-small slate-700
│ │
│ ─── Cascade summary ─── │
│ │
│ Scrubbed: │ text-small slate-700
│ • 1 Person row │
│ • 17 Order.comments fields │
│ • 17 receipt-emit-log snapshots │
│ • 0 share grants revoked │
│ DSAR rows updated: │
│ • 1 erasure-request → served │
│ Audit rows written: │
│ • 1 share_audit (person_erasure) │
│ • 1 share_audit (dsar_served) │
│ • 1 admin_audit_log │
│ (action=person.finalize_expired) │
│ │
│ ─── Notifications ─── │
│ │
│ Notified 2 stringers (in-app): │ text-small slate-700
│ • Stefan Wagen │
│ • Marc Egli │
│ │
│ ─── Audit references ─── │
│ │
│ request_id: 4a7e…b29 │ text-tiny font-mono slate-600
│ share_audit.id: …b8c │
│ admin_audit_log.id: …d4f │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Back to eligible list │ │
│ └─────────────────────────────────┘ │
│ │
└───────────────────────────────────────┘
Behaviour¶
- No further CTAs other than "Back to eligible list." Stage 3 is the audit page; the action is done.
- Pre-scrub display name is surfaced one last time on this page so the admin has a moment of "yes, that was the right person" closure. Sourced from
admin_audit_log.metadata.pre_scrub_display_name(the only place the platform retains the cleartext name post-scrub — flagged as Pax-implementation choice in OQ-FE-3 below). After this page is dismissed, the name is gone from every other admin surface (subsequent visits to /admin/persons/{id} show[redacted]). - Cascade summary mirrors the dry-run preview from Stage 2 with actuals from
share_audit.meta.cascade_summary(Pax already writes this perroutes_admin_persons.py::_finalize_one). If the dry-run preview and the actuals differ — e.g. a stringer added a new Order between Stage 2 render and Stage 2 submit — the difference is shown as a delta line: - Format: "17 Order.comments fields (preview: 16 — 1 added since preview)" —
text-small amber-700when delta > 0. - This is the surface that closes the "preview accuracy" loop. Rare in V2 but the audit is the contract.
- DSAR rows updated. If any pending
dsar_logrows were flipped toserved, surface the count. - Audit references. The cross-table
request_id+ the two audit row IDs are surfaced astext-tiny font-mono slate-600so the admin (or a future forensic query) can copy/paste them. Hidden behind a<details>collapsible onsmto keep the page short by default; expanded onmd+. - Notification list. Lists the stringers who received the in-app erasure notification per A-ERA-4. V2 reality: in-app only; email opt-in deferred.
Failure handling on the way to Stage 3¶
If the POST submit from Stage 2 fails mid-cascade, Stage 3 does NOT render — the user stays on Stage 2 with a banner. See Failure / partial-success.
Component breakdown¶
| Component | Notes |
|---|---|
| Header | "Finalize — done" text-h2. Back arrow → Stage 1. |
| Status badge | text-h2 size, full-width chip; ✓ Finalized green-700; lucide:check-circle. |
| Pre-scrub name line | text-body slate-900 + (now redacted) text-small slate-500 italic. |
| Duration / timestamps | text-small slate-700. |
| Cascade summary list | <ul> with bullet markers; counts in font-mono tabular-nums. |
| Audit refs block | <details> collapsible; text-tiny font-mono slate-600 IDs; copy-on-click hint via title attr. |
| Back-to-list CTA | bg-white border border-indigo-200 text-indigo-700. |
Failure / partial-success states¶
The cascade transaction is single-transaction-per-Person per A-FADP-5.3. In practice, three failure modes are possible:
F-1. Eligibility race (the Person is no longer eligible at submit time)¶
Cause: another admin tab finalized this Person between Stage 2 render and Stage 2 submit; OR Stefan reversed the soft-delete in another tab; OR (vanishingly rare) the Person row was deleted by a DB tool.
UX:
- Stage 2 form re-renders with a red banner at the top: "This Person is no longer eligible to finalize." Sub-text describes the likely cause based on the row's current state:
- scrubbed_at IS NOT NULL → "This Person was already finalized on {date}."
- deleted_at IS NULL → "The soft-delete was reversed. Re-soft-delete in the DSAR queue if erasure is still wanted."
- Person row missing → "This Person no longer exists in the platform."
- The typed-confirm input is wiped; Cancel CTA stays; Finalize CTA is gone (no path forward).
- A toast on Stage 1 redirect reads: "Showing current eligible list."
F-2. Mid-cascade DB error (constraint violation, connection drop, server crash)¶
Cause: very rare at V2 scale. The cascade is single-transaction so the whole thing rolls back; scrubbed_at is NOT set; PII is NOT scrubbed.
UX:
- Stage 2 form re-renders with a red banner: "Finalize couldn't complete — the cascade was rolled back. Try again, or contact the operator if this repeats."
- Form values preserved (typed-confirm input still filled).
- Finalize CTA stays enabled — A-FADP-5.4 idempotency means retry is safe (the predicate still matches; the second run finalizes one Person).
- Server-side: the failure is logged to admin_audit_log with action = person.finalize_expired.failed, metadata.error = <truncated message>. The successful person.finalize_expired audit row is NOT written (the transaction rolled back means no person_erasure audit row either — the cascade is atomic). The failure-audit-row is the only record. Pax flag — see Open question OQ-FE-2.
F-3. Partial-success during a (hypothetical future) batch finalize¶
Not applicable to V2. This spec covers one-Person-at-a-time finalize (Stage 2 submits a single person_id). The endpoint supports bulk-mode (no person_id query — finalizes every eligible Person), and A-FADP-5.3 specifies per-Person transaction isolation so a partial-success batch is structurally possible. V2 UX is per-Person only. If Stefan ever asks for a "Finalize all" bulk button (queued for a polish round), this section gets a partial-success table: "Finalized 12; failed 1. {Person X}: {error}. Retry the failed one individually." See Open question OQ-FE-1 on bulk-vs-singleton.
F-4. Notification dispatch failure (orthogonal)¶
The cascade transaction commits the scrub; the stringer-side notification per A-ERA-4 is fired post-commit (via Pax-B's Notifier — see #40). If the notification dispatch fails:
- The scrub is committed (FADP rights served).
- The Stage 3 success page renders normally with a small warning chip under the Notifications block: "Notification dispatch failed for 1 stringer. The scrub is committed; retry the notification from the admin notifications surface." (The retry surface is queued — V2 reality: in-app notifications are best-effort and the admin sees a chip but no retry button.)
- The failure is logged to
admin_audit_logasaction = notification.send.failedwithmetadata.parent_action = person.finalize_expired.
This separation is important: the FADP obligation is satisfied by the scrub, not by the notification. The notification is a courtesy to stringers per A-ERA-4. Surfacing the notification status on Stage 3 is the audit-truth; not blocking the success on it is the FADP-defensible choice.
Audit emission contract¶
Per ADR-0011 § Audit-row routing rule, every successful finalize writes to both audit tables:
| Table | Row written | Purpose |
|---|---|---|
admin_audit_log (per #45) |
action = person.finalize_expired, target_type = person, target_id = <person_id>, metadata = {request_id: <UUID>, cascade_summary: {orders_scrubbed: N, snapshots_scrubbed: N}, pre_scrub_display_name: <name>} |
Platform-internal forensic log; NOT surfaced in client DSAR. Stage 3 displays the row's id for cross-reference. |
share_audit (per ADR-0004 § Audit) |
event_kind = person_erasure, actor_kind = stringer, actor_id = <admin_stringer_id>, target_kind = client_profile, target_id = <person_id>, meta = {phase: "hard_erase", request_id: <same UUID>, cascade_summary: {...}} |
Data-subject-facing consent log; surfaced in any future DSAR for this Person (well, would-be-surfaced — the Person is now scrubbed and cannot DSAR themselves; the row exists for the audit-defensibility chain). |
Both rows share the same request_id per ADR-0011 §"Composite case." The forensic JOIN: SELECT * FROM admin_audit_log a JOIN share_audit s ON a.metadata->>'request_id' = s.meta->>'request_id' WHERE a.action = 'person.finalize_expired'.
Additional rows per existing handler¶
Pax's routes_admin_persons.py::_finalize_one also writes per-dsar_log rows that got flipped to served: one share_audit row per dsar with event_kind = dsar_served, meta = {dsar_log_id, request_kind, sla_ms}. This is the existing implementation; the Stage 3 cascade summary surfaces those rows in the "DSAR rows updated" sub-block.
Append-only invariant¶
Both admin_audit_log and share_audit are append-only per ADR-0011 §"Append-only" and ADR-0004 §"Audit." This UX does not expose any delete/edit affordance on audit rows. If the admin needs to amend the record (e.g. they want to add a note "this was for the test data we cleaned up"), they would need a separate audit-annotation surface — queued for a future round, not in scope here.
Cross-references for audit consumers¶
- The "See full audit →" link on Stage 1 forward-references the generic admin-audit-log search (queued; out of Round 10 scope).
- The Stage 3 audit-references block exposes the per-row IDs for ad-hoc SQL or future forensic-search-by-id surfaces.
- The
share_auditrow participates in any future Person's DSAR response (per fadp-posture § A-DSAR-5 — the scrubbed Person's DSAR is a minimal stub, but the audit row exists and is reachable by admin queries).
Interaction states¶
| State | What renders |
|---|---|
| Dashboard chip — 0 eligible | Chip hidden. |
| Dashboard chip — 1+ eligible (recent) | bg-slate-50 chip "{N} expired soft-deletes" + "Hard-erase eligible". |
| Dashboard chip — 1+ eligible (overdue > 7 days) | bg-amber-50 chip; same copy. |
| Stage 1 — empty | "Nothing to finalize" empty-state + recent-finalizes block. |
| Stage 1 — populated | Per-Person cards sorted by overdue-desc; recent-finalizes block at bottom. |
| Stage 1 — back from Stage 3 | Focus restored to the next eligible card (if any); if list now empty, focus restored to the back-to-dashboard CTA. |
| Stage 2 — initial | Subject + timeline + dry-run preview rendered; typed-confirm empty; Finalize CTA disabled. |
| Stage 2 — typed-confirm matches exactly | Finalize CTA enabled bg-red-700. |
| Stage 2 — typed-confirm wrong casing/spacing | Finalize CTA stays disabled; inline hint visible. |
| Stage 2 — submit pending | Finalize CTA reads "Finalizing…" with 16 px spinner; form disabled. |
| Stage 2 — success | 303 redirect → Stage 3; toast on Stage 3. |
| Stage 2 — eligibility race (F-1) | Red banner; Finalize CTA hidden; Cancel returns to Stage 1. |
| Stage 2 — DB error (F-2) | Red banner "Finalize couldn't complete — rolled back"; form values preserved; Finalize CTA stays enabled (idempotent retry). |
| Stage 3 — success | Status ✓ Finalized + cascade summary + audit refs; Back-to-list CTA. |
| Stage 3 — preview-vs-actual delta | Delta line in amber-700 under the affected row. |
| Stage 3 — notification failure (F-4) | Warning chip under Notifications block; scrub still marked success. |
Validation rules (UI surface; canonical server-side)¶
| Rule | Inline message / toast |
|---|---|
| Stage 2 — Person not in eligible cohort (predicate false) | Banner: "This Person is no longer eligible to finalize." Sub-text per F-1 cases. |
Stage 2 — Person.scrubbed_at IS NOT NULL (already finalized) |
Banner: "This Person was already finalized on {date}." |
Stage 2 — Person.deleted_at IS NULL (soft-delete reversed) |
Banner: "The soft-delete was reversed. Re-soft-delete in the DSAR queue if erasure is still wanted." |
| Stage 2 — typed-confirm doesn't match | Inline hint: "Match the display name exactly, including spelling and special characters." (Same string as merge + dsar siblings.) |
| Stage 2 — typed-confirm matches but submitted via POST without the typed value (no-JS edge) | Server-side re-renders the form with the inline hint visible. |
| Stage 2 — cascade transaction failed (F-2) | Banner: "Finalize couldn't complete — the cascade was rolled back. Try again, or contact the operator if this repeats." |
| Stage 2 — concurrent finalize race (two tabs) | Same as eligibility race; idempotency catches it. |
| Stage 1 — list query failed (DB unreachable) | Banner: "Couldn't load the eligible list. Refresh to retry." Empty-state shape underneath; recent-finalizes still tries to render independently. |
| Stage 3 — notification dispatch failure (F-4) | Inline warning chip; not blocking. |
Accessibility¶
- Stage 1 cards are
<article>semantic; the H3 inside is the card's accessible name; the "Review & finalize →" link is the focus target. Whole-card tap is implemented as a<a>wrapping the card content (not a click-handler-on-div) for screen-reader navigability. - Overdue chip uses both color AND icon AND text. The chip text "Grace ended N days ago" is the canonical signal; the color is redundant; the icon is redundant. Color-blind users get the message.
- Stage 2 warning H1 uses
lucide:alert-triangleicon + bold text + red color. Three redundant signals. - Will-scrub vs Will-retain bullets distinguish via icon (
lucide:eraservslucide:archive) + color (red-700 vs slate-500) + the leader word ("Will scrub" / "Will retain"). Triple redundancy so the destructive vs preserving distinction is loud. - Typed-confirm input has an explicit
<label>(visually rendered as the prompt: "To confirm, type the Person's display name exactly:"),aria-describedbylinking to the literal-name span and the inline hint.autocomplete="off"so password managers don't auto-fill. - Finalize CTA disabled state uses
aria-disabled="true"anddisabledattribute;aria-describedbylinked to the typed-confirm prompt so screen readers announce "Finalize Lukas Müller, disabled. To confirm, type the Person's display name exactly: Lukas Müller." - Finalize CTA enabled state uses
aria-describedbylinked to the "What happens" list, so a screen-reader user gets the dry-run summary spoken before the focus-on-submit moment. - Stage 3 status badge uses icon + text + color:
✓ Finalizedgreen-700. Color-blind users see the checkmark + word. - Audit refs
<details>has an explicit<summary>text "Audit references" that's screen-reader-discoverable. - Focus management: Stage 1 → 2 moves focus to the H1 of Stage 2; Stage 2 → 3 moves focus to the H1 of Stage 3; back-arrow on Stage 2 restores focus to the Stage-1 card that was tapped (per the admin-person-merge accessibility convention).
- Hit targets: all interactive elements ≥ 44 × 44 px; CTAs 48 px tall.
- Color contrast:
bg-red-700(#b91c1c) on white = 5.94:1 (AA);text-amber-700onbg-amber-50= 4.6:1 (AA for body);text-red-700onbg-red-50= 5.7:1 (AA). - Without JS: the typed-confirm enable/disable is HTMX comfort; the canonical check is server-side at submit. Without JS, the Finalize CTA is always enabled and the server validates on POST, returning the form with the inline hint visible on mismatch. Flow is functional end-to-end without JS.
HTMX / progressive-enhancement seams¶
The flow's fundamental contract is regular form POSTs + redirects. HTMX is minimal because the surface is serial (Stage 1 → 2 → 3) and there is no partial-update use case.
- Stage 1 entry: regular GET. Server fans out per-Person dry-run queries to build the cards. No HTMX.
- Stage 1 → Stage 2: regular
<a href="/admin/persons/finalize-expired/{person_id}">. No HTMX. - Stage 2 typed-confirm enable/disable: HTMX comfort layer.
hx-get="/admin/persons/finalize-expired/{person_id}/_check_typed?q=…" hx-trigger="keyup changed delay:100ms" hx-target="#finalize-cta-region". Without JS: CTA is always enabled; server checks at submit. Either way, the canonical server-side check is the contract. - Stage 2 submit: regular
<form method="post" action="/admin/persons/finalize-expired/{person_id}/execute">. Server runs the cascade, 303-redirects to Stage 3 on success. On failure (F-1 or F-2), returns the Stage-2 page with the banner. - Stage 3: regular GET; no HTMX.
- Stage 3 audit-refs collapsible: native
<details><summary>; no HTMX or JS.
The whole flow degrades cleanly to plain HTML + form POSTs without JS — same contract as the admin-person-merge HTMX seam and the admin-dsar-queue HTMX seam.
i18n affordance¶
EN-only in this spec per the brief. Iris reviews DE separately. All strings catalogued for translation; the DE pass follows the admin-dsar-queue i18n affordance pattern.
| String | Type | Catalogue key |
|---|---|---|
| "Finalize expired soft-deletes" (page title, dashboard chip target, Stage 1 header) | {% trans %} |
admin.finalize_expired.title |
| Dashboard chip "{N} expired soft-delete{s}" / "Hard-erase eligible" / "Review →" | {% trans %} (Babel for plural) |
admin.finalize_expired.chip.{count,sub,cta} |
| Stage 1 intro paragraph | {% trans %} |
admin.finalize_expired.list.intro |
| Stage 1 irreversibility callout "Each finalize is irreversible. Review the preview before confirming." | {% trans %} |
admin.finalize_expired.list.callout |
| Overdue chip "Grace ended {N} days ago" / "Grace ended today" | {% trans %} (Babel for plural) |
admin.finalize_expired.list.overdue.{n_days_ago,today} |
| Per-Person card "Soft-deleted {date}" / "Grace ended {date}" | {% trans %} (Babel) |
admin.finalize_expired.list.card.{soft_deleted,grace_ended} |
| "Will scrub:" / "Will retain:" / "Will notify:" | {% trans %} |
admin.finalize_expired.preview.{scrub,retain,notify} |
| Cascade bullet "{N} Person row (PII fields)" | {% trans %} (Babel) |
admin.finalize_expired.preview.bullet.person |
| Cascade bullet "{N} Order.comments fields" | {% trans %} (Babel) |
admin.finalize_expired.preview.bullet.order_comments |
| Cascade bullet "{N} receipt-emit-log snapshots" | {% trans %} (Babel) |
admin.finalize_expired.preview.bullet.emit_log |
| Cascade bullet "{N} ClientProfile{s} retained — stringer notes are not the Person's data" | {% trans %} (Babel) |
admin.finalize_expired.preview.bullet.client_profile_retained |
| Cascade bullet "{N} share grant{s} revoked" / "0 share grants → revoked" | {% trans %} (Babel) |
admin.finalize_expired.preview.bullet.share_grants |
| "Review & finalize →" | {% trans %} |
admin.finalize_expired.list.card.cta |
| "Recent finalizes" / "See full audit →" | {% trans %} |
admin.finalize_expired.list.recent.{title,link} |
| Recent-finalizes row "{date} — admin {name}" / "Finalized [redacted] (person id ending in …{suffix})" / "{N} records scrubbed" | {% trans %} (Babel) |
admin.finalize_expired.list.recent.row.{date_admin,scrubbed_name,count} |
| Empty state "Nothing to finalize" / body / "Back to admin dashboard" | {% trans %} |
admin.finalize_expired.list.empty.{title,body,cta} |
| Stage 2 header "Finalize {display_name}" | {% trans %} (Babel) |
admin.finalize_expired.confirm.title |
| Stage 2 warning H1 "Permanent data erasure" | {% trans %} |
admin.finalize_expired.confirm.warning_title |
| Stage 2 warning body | {% trans %} |
admin.finalize_expired.confirm.warning_body |
| Stage 2 sections "Subject" / "Timeline" / "What happens" / "Confirm" | {% trans %} |
admin.finalize_expired.confirm.section.{subject,timeline,what_happens,confirm} |
| Timeline labels "Soft-deleted" / "Grace ends" / "Grace ended {N} days ago" / "Now" | {% trans %} (Babel for relative) |
admin.finalize_expired.confirm.timeline.{soft_deleted,grace_ends,grace_ended_relative,now} |
| Provenance line "Created {date} by stringer {name}" | {% trans %} (Babel) |
admin.finalize_expired.confirm.provenance |
| Counts line "{N} Orders · {N} ClientProfiles" | {% trans %} (Babel) |
admin.finalize_expired.confirm.counts |
| Scrub bullet sub-text "display_first_name, display_last_name → '[redacted]'" + the email/notification_prefs/claim_token sub-text | {% trans %} |
admin.finalize_expired.confirm.scrub.person_fields |
| Notify line "Notified {N} stringer{s} holding a ClientProfile for this Person (in-app per A-ERA-4)" | {% trans %} (Babel) |
admin.finalize_expired.confirm.notify_line |
| "To confirm, type the Person's display name exactly:" | {% trans %} |
admin.finalize_expired.confirm.typed.prompt |
| Inline hint "Match the display name exactly, including spelling and special characters." | {% trans %} |
admin.finalize_expired.confirm.typed.hint (or share with admin.dsar.erase.typed.hint — see Open question OQ-FE-4) |
| "Cancel" | {% trans %} |
common.cancel |
| "Finalize {display_name}" | {% trans %} (Babel) |
admin.finalize_expired.confirm.cta.execute |
| "Finalizing…" (in-flight CTA) | {% trans %} |
admin.finalize_expired.confirm.cta.in_flight |
| Toast — "Finalized {display_name}. {N} records scrubbed." | {% trans %} (Babel) |
admin.finalize_expired.toast.success |
| Banner F-1 "This Person is no longer eligible to finalize." + sub-cases | {% trans %} |
admin.finalize_expired.banner.f1.{title,already_finalized,reversed,missing} |
| Banner F-2 "Finalize couldn't complete — the cascade was rolled back. Try again, or contact the operator if this repeats." | {% trans %} |
admin.finalize_expired.banner.f2 |
| Stage 3 header "Finalize — done" | {% trans %} |
admin.finalize_expired.done.title |
| Stage 3 status "Finalized" | {% trans %} |
admin.finalize_expired.done.status |
| "Lukas Müller (now redacted)" — "{name} (now redacted)" | {% trans %} (Babel) |
admin.finalize_expired.done.name_redacted |
| "Finalized at: {ts}" / "Duration: {n}s" | {% trans %} (Babel) |
admin.finalize_expired.done.{at,duration} |
| Stage 3 sections "Cascade summary" / "Notifications" / "Audit references" | {% trans %} |
admin.finalize_expired.done.section.{cascade,notifications,audit} |
| Cascade actuals bullets — same keys as preview bullets (the strings are identical; the counts differ) | n/a | (reuse preview keys) |
| Delta line "(preview: {N} — {Δ} added since preview)" | {% trans %} (Babel) |
admin.finalize_expired.done.cascade.delta |
| Notifications "Notified {N} stringer{s} (in-app):" | {% trans %} (Babel) |
admin.finalize_expired.done.notifications.heading |
| Notification failure chip "Notification dispatch failed for {N} stringer{s}. The scrub is committed; retry the notification from the admin notifications surface." | {% trans %} (Babel) |
admin.finalize_expired.done.notifications.failure |
| "request_id: {id}" / "share_audit.id: {id}" / "admin_audit_log.id: {id}" | {% trans %} (Babel) |
admin.finalize_expired.done.audit_ref.{request,share,admin} |
| "Back to eligible list" | {% trans %} |
admin.finalize_expired.done.cta |
| Validation messages | {% trans %} |
admin.finalize_expired.validation.{not_eligible,already_finalized,reversed,missing,typed_mismatch,cascade_failed,list_load_failed} |
| Person display names, emails, dates, UUIDs | Data | n/a |
Iris's DE pass follows after merge. Mira's draft EN is normative.
DE width budget (designer note for Iris)¶
- "Finalize expired soft-deletes" → "Abgelaufene Soft-Deletes finalisieren" (~1.6×) — fits in
text-h1onsm. - "Permanent data erasure" → "Permanente Datenlöschung" (~1.1×) — fits.
- "Each finalize is irreversible. Review the preview before confirming." → "Jede Finalisierung ist unumkehrbar. Prüfe die Vorschau vor der Bestätigung." (~1.1×) — fits.
- "Will scrub:" / "Will retain:" / "Will notify:" → "Wird gelöscht:" / "Bleibt erhalten:" / "Wird benachrichtigt:" (~1.2×) — fits headings.
- "To confirm, type the Person's display name exactly:" → "Zur Bestätigung den Anzeigenamen genau eintippen:" (~1.1×) — fits.
- "Match the display name exactly, including spelling and special characters." — already established DE in admin-person-merge DE width budget: "Tippe den Anzeigenamen genau ab, einschließlich Schreibweise und Sonderzeichen." (~1.2×) — fits.
- "Finalize {display_name}" → "Finalisieren: {display_name}" (~1.2×) — fits CTA.
- "Grace ended {N} days ago" → "Schonfrist vor {N} Tagen abgelaufen" (~1.5×) — fits chip when overdue chip is the only chip on the card.
- Long cascade bullets and the scrub-field sub-text wrap freely; no width-budget concerns.
Typed-confirm pattern summary (for canonical reference)¶
This section is the one-screen restatement of the typed-confirm convention RBO's destructive-action surfaces share. Future destructive-action authors should reference this section (or any of the three sibling specs that implement it: admin-person-merge Stage 3, admin-dsar-queue Stage 4, this spec Stage 2) rather than inventing a new pattern.
The pattern¶
- Dedicated page, not modal. Destructive action gets a real URL. Refresh, back-arrow, sharing-a-screen all work.
- Subject summary card at top — display name, verification, provenance, counts. The admin disambiguates "yes, this is the right row" before reading the preview.
- Dry-run preview ("What happens") — server-rendered counts of every entity affected, split into scrubbed / retained / notified so the admin sees both the blast radius and the surviving artifacts.
- Typed-confirm input — exact match (case + accent + spacing) against
Person.display_first_name + " " + Person.display_last_name(or equivalent canonical identifier for non-Person targets). HTMX comfort enable/disable; server-side canonical check on submit. - Inline hint — identical across surfaces: "Match the display name exactly, including spelling and special characters."
- CTA color —
bg-red-700 text-white. Disabled state visually distinct but not deemphasised. - CTA label —
"{Verb} {display_name}"("Finalize Lukas Müller", "Merge into Lukas Müller", "Hard-erase Lukas Müller"). The name mirrors the typed-confirm prompt visually. - Cancel CTA — neutral styling (
bg-white border border-slate-300), full-width, distinct from the destructive CTA. Always available. - Composite audit emission — successful submit writes to both
share_audit(data-subject log) andadmin_audit_log(platform log), sharing arequest_idper ADR-0011 § Composite case. - Post-success page — Stage 3 read-only summary with cascade actuals + audit-ref IDs + back-to-list CTA. The audit is the receipt the admin gets.
Asymmetric variants (out of scope here, documented for completeness)¶
admin-v1-upload Stage 3 uses an asymmetric typed-confirm: one-tap for rbo_test, typed-confirm for rbo_prod. The asymmetry is justified because the V1-upload surface has two real cohorts (dress rehearsal vs cutover). Finalize-expired has no analogous cohort — every finalize is real production data — so the asymmetry doesn't apply here.
Cross-references¶
- Source requirements: fadp-posture § Right to erasure, fadp-posture § hidden requirement 7, fadp-implementation-asks R-FADP-5, fadp-implementation-asks R-FADP-6 (
scrub_orders_for_personhelper), fadp-implementation-asks R-FADP-7 (share_auditevent-kind extensions). - ADRs: ADR-0011 § Bypass-scope table (audit destination + bypass scopes for this endpoint), ADR-0011 § Audit-row routing (the two-table contract), ADR-0004 § FADP positioning (the underlying erasure model).
- Backend:
app/api/routes_admin_persons.py(Pax's existing endpoint +_finalize_onehelper this UX wraps),app/db/scrub.py(scrub_orders_for_person),#45(admin_audit_logschema — forward reference). - Sibling design surfaces:
- admin-dsar-queue § Stage 6 — the sister entry-point list inside the DSAR queue; resolves to the same Stage-2 typed-confirm page this spec owns.
- admin-person-merge § Stage 3 — the canonical typed-confirm pattern.
- admin-dsar-queue § Stage 4 — the post-DSAR-erasure-request typed-confirm (different entry-point, same shape).
- admin-v1-upload § Stage 3 — the asymmetric-cohort variant.
- Future surfaces (queued):
- Generic admin-audit-log search — the "See full audit →" target. Queued for the round that builds out cross-event admin forensics.
- Stringer-side erasure notification inbox — the receiving surface for the in-app notifications A-ERA-4 dispatches.
- Audit-row annotation — admin adds free-text notes to existing audit rows. Not in V2.
- Coordination: Pax (existing endpoint already shaped for
dry_run=true+ per-Person submit;admin_audit_logrow writing +pre_scrub_display_namein metadata — see OQ-FE-3); Theo (admin_audit_logschema per #45); Iris (DE pass + verifying the FADP narrative on the cascade preview); Juno (frontend implementation; route-alias decision per the Route-alias note); Quill (regression tests — eligibility race, typed-confirm strictness, composite audit emission, dry-run vs actuals delta). - i18n strategy: i18n architecture.
- Tokens: design-tokens.
- Issue tracking: racket-book#156.
Findings flagged during spec authorship¶
These are conflicts / ambiguities Mira found in upstream docs while writing this spec. No paper-over — flagged for Stefan / Theo / Iris to resolve.
-
share_auditevent-kind name inconsistency. ~~ADR-0011 § Bypass-scope table names the eventperson_erased, but the canonical name inapp/db/models/enums.py::EventKindand fadp-implementation-asks R-FADP-7 and the implementedroutes_admin_persons.py::_finalize_oneisperson_erasure. This spec usesperson_erasurethroughout. Theo flag — ADR-0011 should be amended to match the canonical enum name.~~ Resolved by #158 — ADR-0011 now readsperson_erasure. -
admin_audit_logtable schema is forward-referenced. ADR-0011 commits to the routing rule ("write toadmin_audit_logwith action=person.finalize_expired"), but the canonical schema (column shapes, indexes) lives in #45 and is not yet built per [Round 9 checkpoint]. This spec assumes the schema lands as drafted in #45 (id, occurred_at, actor_id, action, target_type, target_id, reason, metadata JSONB). Pax / Theo flag — if the #45 schema differs, the Stage 3 audit-refs block needs adjustment. -
Pre-scrub display name retention. Stage 3 surfaces the pre-scrub display name one last time after the cascade has scrubbed the Person row. The display-name string is gone from
Person.display_first_name/display_last_name(they're now[redacted]). Mira proposes storing the pre-scrub name inadmin_audit_log.metadata.pre_scrub_display_name(audit row only; not inshare_audit.metawhich surfaces in DSAR — the pre-scrub name surfacing in a Person's own DSAR after they've been scrubbed would be a contradiction). Pax flag — see OQ-FE-3. If Pax pushes back ("don't retain the name even in admin_audit_log"), the Stage 3 surface degrades gracefully: it shows[redacted]instead of the pre-scrub name, with the audit-row IDs the only durable identifier. Documented by #158 in ADR-0011 § "Operational notes" (privacy trade-off, retention semantics). -
Person.created_by_kind/Person.created_by_id(A-CONS-1) may not exist yet. Stage 2's subject card surfaces "Created {date} by stringer {name}" as provenance. Per fadp-posture A-CONS-1 this is a schema ask — Pax flag, not necessarily implemented. If the columns don't exist at implementation time, Juno renders "Created {date}" without the "by stringer" sub-clause; design degrades cleanly. Theo / Pax flag. Documented by #158 in ADR-0011 § "Operational notes" (V3 schema ask; UX degrades cleanly). -
admin_audit_log.action = person.finalize_expired.failedis not in ADR-0011. ~~Stage 2 F-2 failure path proposes a failure-audit row. ADR-0011's bypass-scope table listsperson.finalize_expiredas the action; the failed variant is a logical extension but not in the table. Mira's proposal: extend the table to includeperson.finalize_expired.failed(no tenancy/consent bypass — it's a logged-no-op). Theo flag — ADR-0011 amendment.~~ Resolved by #158 — ADR-0011 § "Operational notes" now documents the failed-variant. -
Bulk-vs-singleton ambiguity in R-FADP-5. Pax's endpoint at
POST /admin/persons/finalize-expiredfinalizes the entire cohort in one POST (no per-person_idbody param visible in the route signature; the iteration is server-internal across the candidate set). This spec's Stage 2 submits one Person at a time. The reconciliation requires either: - (a) extending the endpoint to accept a
person_idquery/body parameter that scopes the cascade to one row, OR -
(b) a new endpoint
POST /admin/persons/{person_id}/finalize-expiredfor the singleton case. Mira leans (a) — see OQ-FE-1. Pax flag. This is not a design conflict (the design is consistent); it is an endpoint-shape ambiguity for the implementation phase. Documented by #158 in ADR-0011 § "Operational notes" (endpoint-shape note). -
Concurrent finalize race + Pax's batch commit semantics.
routes_admin_persons.py::finalize_expireddoes a singlesession.commit()at the end of the batch loop (comments call this "the conservative choice over per-Person commits"). If the singleton-mode UX (above) ships as-is, a Stage-2 submit running while another tab/admin runs the bulk-mode endpoint risks a race where the bulk-mode is mid-loop and the singleton hits the predicate-OK-but-row-locked case. Mira proposes: the singleton-mode endpoint runs its own transaction and the predicate re-check inside the cascade handles the race (the second cascade attempt seesscrubbed_at IS NOT NULLand exits with the "already finalized" branch). Pax flag — the race semantics need test coverage per the ADR-0011 § Required tests shape. Documented by #158 in ADR-0011 § "Operational notes" (singleton-vs-batch + cascade-vs-DSAR-export races; test-coverage expectation).
Open questions for Stefan (with proposed defaults)¶
-
OQ-FE-1. Bulk-mode vs singleton-mode finalize endpoint. Proposed default: extend Pax's endpoint to accept a
person_idquery parameter; default to bulk-mode (no param) for backward compat. The UX in this spec is singleton-only (Stage 2 is one Person). Bulk-mode-via-UX could be a future "Finalize all" button on Stage 1 (queued; not Round 10). The endpoint extension is small (one optional query param + a one-lineif person_idbranch in the candidate-scan). Alternative: new endpointPOST /admin/persons/{id}/finalize-expired. Either works; query-param is the smaller diff. Stefan to confirm. -
OQ-FE-2. Failure-row audit emission. Proposed default: write
admin_audit_logrow withaction = person.finalize_expired.failedon F-2 cascade rollback. The audit log captures the failed attempt (with the truncated error) so a forensic query can answer "did Stefan ever try to finalize Person X and have it fail?" Alternative: don't audit failures — they're noise. Mira leans audit-on-failure for forensic completeness; one row per F-2 event is cheap. Stefan to confirm. -
OQ-FE-3. Pre-scrub display name retention in
admin_audit_log.metadata. Proposed default: yes — storepre_scrub_display_nameon the admin_audit_log row's metadata. Surfaces on Stage 3 for closure; available to forensic queries; NOT inshare_audit.meta(which surfaces in DSAR). Alternative: don't retain post-scrub; Stage 3 renders[redacted]. Trade-off: the admin's "yes, that was the right person" moment requires a label; absent the cleartext name, the audit-ref IDs are the only identifier. Privacy-leaning would say "don't retain"; admin-UX-leaning says "retain for the audit closure beat." Mira leans retain — the admin_audit_log is the platform-internal forensic log (per ADR-0011), not the data-subject-facing log. Stefan to confirm. -
OQ-FE-4. i18n key sharing for the typed-confirm hint. Proposed default: each surface defines its own key (
admin.finalize_expired.confirm.typed.hint, etc.) for routing flexibility; the EN/DE strings are identical across surfaces. Alternative: share a single key (common.typed_confirm.hint) across all destructive-action surfaces. Sharing reduces drift risk (Iris won't accidentally translate the same string two ways). Per-surface gives flexibility (one surface might want a more specific hint). Mira leans share — these three surfaces should not drift. Iris flag — Iris owns the i18n catalogue shape; this is her call. -
OQ-FE-5. Dashboard chip 7-day overdue threshold. Proposed default: amber when oldest overdue > 7 days; slate otherwise. Matches the admin-dsar-queue SLA-urgency convention in spirit (different threshold because finalize-expired has no legal-SLA pressure once grace ends — the FADP obligation is already met by
deleted_atbeing set; the 7-day mark is operational hygiene, not legal). Alternative: 14 days (gentler); 3 days (tighter). Stefan to confirm. -
OQ-FE-6. Route canonicalisation. Proposed default:
/admin/persons/finalize-expiredis canonical;/admin/dsar/queue/finalize-expired302-redirects to it. Matches Pax's existing POST endpoint family. Alternative: keep both URLs as first-class with identical handlers. Aliases-with-redirect is the cleaner mental model (one canonical URL per page) — Mira leans the redirect. Juno-flag during implementation. -
OQ-FE-7. Bulk "Finalize all" CTA on Stage 1. Proposed default: not in Round 10. The per-Person review-before-finalize is the safer pattern at V2 scale (dozens of finalizes per quarter at most). Bulk-finalize would need a typed-confirm with the literal count (e.g. "Finalize all 8 expired soft-deletes" + type
8) — a different pattern than the per-Person typed-confirm. Worth a future round if the volume justifies it. Stefan to confirm. -
OQ-FE-8. Timeline absolute vs relative timestamps. Proposed default: absolute (UTC) + relative cue on grace-end. Absolute is the unambiguous record; relative ("12 days ago") is the urgency cue. Both is the admin-dsar-queue convention. Alternative: relative-only. Mira leans both. Stefan to confirm.
-
OQ-FE-9. Cascade preview accuracy strictness. Proposed default: re-query on Stage-2 render; surface delta on Stage 3 if dry-run preview vs actuals differ. Alternative (stricter): lock the candidate set at Stage-2 render time so the preview equals the actuals — would require a soft-lock or a saved snapshot ("here are the row IDs to scrub; the cascade scrubs exactly these IDs even if more arrive"). Adds infra complexity for a rare drift. Mira leans the re-query + delta. Stefan to confirm.
-
OQ-FE-10. Stage 3 audit-refs collapsibility. Proposed default:
<details>collapsed by default onsm; expanded onmd+. The audit-ref IDs are forensic forward-references that not every admin will copy; collapsing them keeps the page short for the common "yes that worked, back to list" flow. Alternative: always expanded. Mira leans collapse. Stefan to confirm. -
OQ-FE-11. Recent-finalizes block scope — across all admins or current-admin-only. Proposed default: across all admins. V2 reality: only Stefan. Multi-admin V2.x would benefit from seeing "what did the other admin scrub last Tuesday?" — across-all is the forward-compat default. Alternative: current-admin-only with a "see all admins →" link. Mira leans across-all-by-default. Stefan to confirm.
-
OQ-FE-12. Stringer-notification list on Stage 3 — show stringer names or IDs. Proposed default: display names. Stefan recognises "Marc Egli" faster than a UUID. The display names are stringer-business-identity (not the erased Person's PII), so surfacing them is fine. Alternative: IDs (more forensic, less readable). Mira leans display names. Stefan to confirm.
-
OQ-FE-13. Per-Person card cascade preview — show "0 share grants revoked" or suppress? Proposed default: suppress the bullet when count is 0 for share grants (the common case at V2 scale — most Persons have no grants). The bullet adds noise on every card. Alternative: always show (transparency). Mira leans suppress-when-zero for noise hygiene; the Stage-2 detail page always shows the bullet including the 0 case. Stefan to confirm.
-
OQ-FE-14. "See full audit" link target on Stage 1. Proposed default:
/admin/audit?action=person.finalize_expired— the generic admin-audit-log search filtered to this action. The generic search is a queued surface; the link is the forward reference. Alternative: a per-action archive page now (just for finalize-expired) — extra work for low marginal value when the generic search lands. Mira leans queue the link and ship the generic search later. Stefan to confirm. -
OQ-FE-15. Receipt-re-emit on retained Order rows. Out of scope for this spec but flagged for clarity: the FADP cascade scrubs
Order.commentsand the receipt-emit-log per-emit snapshot PII fields, but the production receipt PDFs (already emitted to client inboxes / printed copies) are NOT recallable. Per fadp-posture § Cascade rules. Stage 2's "What happens" preview surfaces the retained Order rows so the admin understands the receipt PDFs ALREADY out there are not affected; the future re-render (if Stefan re-prints an old receipt) shows[redacted]. Iris flag — the privacy notice should note this asymmetry. Iris owns the privacy-notice copy; not this spec.