Skip to content

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=true first to surface the typed-confirm pattern."
  • ADR-0011 § Bypass-scope tablePOST /admin/persons/finalize-expired bypasses tenancy AND consent; writes to both admin_audit_log (action=person.finalize_expired) AND share_audit (event_kind=person_erasure). The composite-case request_id joins 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:

  1. 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=true first so the preview is the canonical view of what will happen.
  2. 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.
  3. 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_log rows (pending requests, including erasure-requests still inside the 30-day grace).
  • Finalize-expired chip counts Person rows where deleted_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 from scrubbed_at set; 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-queue lucide: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

/admin/persons/finalize-expired

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_at overdue duration. Most-overdue first. Within the same overdue bucket, sort by deleted_at ascending (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.comments fields — counts Order rows linked via this Person's ClientProfiles.
  • N receipt-emit-log snapshots — counts ReceiptEmitLog rows for those Orders.
  • N ClientProfile rows retained (not scrubbed; surfaced for transparency — the admin sees that the stringer's notes survive the erasure).
  • N order_shares / person_stringer_share rows 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_log rows with action = 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_summary written 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

/admin/persons/finalize-expired/{person_id}

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_id per 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 bold when the overdue duration > 7 days; text-small slate-700 otherwise. 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-900 between 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=false scoped to the single person_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_audit row with event_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_log rows with request_kind = erasure for this Person → flipped to outcome = served; one share_audit row per dsar with event_kind = dsar_served.
  • Composite-case per ADR-0011: admin_audit_log row with action = person.finalize_expired, target_id = person_id, metadata.request_id = <UUID>, metadata.cascade_summary = {...}. The same request_id lands in share_audit.meta.request_id so 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 state bg-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

/admin/persons/finalize-expired/{person_id}/done

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 per routes_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-700 when 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_log rows were flipped to served, surface the count.
  • Audit references. The cross-table request_id + the two audit row IDs are surfaced as text-tiny font-mono slate-600 so the admin (or a future forensic query) can copy/paste them. Hidden behind a <details> collapsible on sm to keep the page short by default; expanded on md+.
  • 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_log as action = notification.send.failed with metadata.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_audit row 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-triangle icon + bold text + red color. Three redundant signals.
  • Will-scrub vs Will-retain bullets distinguish via icon (lucide:eraser vs lucide: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-describedby linking 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" and disabled attribute; aria-describedby linked 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-describedby linked 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: ✓ Finalized green-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-700 on bg-amber-50 = 4.6:1 (AA for body); text-red-700 on bg-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-h1 on sm.
  • "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

  1. Dedicated page, not modal. Destructive action gets a real URL. Refresh, back-arrow, sharing-a-screen all work.
  2. Subject summary card at top — display name, verification, provenance, counts. The admin disambiguates "yes, this is the right row" before reading the preview.
  3. 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.
  4. 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.
  5. Inline hint — identical across surfaces: "Match the display name exactly, including spelling and special characters."
  6. CTA colorbg-red-700 text-white. Disabled state visually distinct but not deemphasised.
  7. 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.
  8. Cancel CTA — neutral styling (bg-white border border-slate-300), full-width, distinct from the destructive CTA. Always available.
  9. Composite audit emission — successful submit writes to both share_audit (data-subject log) and admin_audit_log (platform log), sharing a request_id per ADR-0011 § Composite case.
  10. 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

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.

  1. share_audit event-kind name inconsistency. ~~ADR-0011 § Bypass-scope table names the event person_erased, but the canonical name in app/db/models/enums.py::EventKind and fadp-implementation-asks R-FADP-7 and the implemented routes_admin_persons.py::_finalize_one is person_erasure. This spec uses person_erasure throughout. Theo flag — ADR-0011 should be amended to match the canonical enum name.~~ Resolved by #158 — ADR-0011 now reads person_erasure.

  2. admin_audit_log table schema is forward-referenced. ADR-0011 commits to the routing rule ("write to admin_audit_log with 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.

  3. 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 in admin_audit_log.metadata.pre_scrub_display_name (audit row only; not in share_audit.meta which 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).

  4. 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).

  5. admin_audit_log.action = person.finalize_expired.failed is not in ADR-0011. ~~Stage 2 F-2 failure path proposes a failure-audit row. ADR-0011's bypass-scope table lists person.finalize_expired as the action; the failed variant is a logical extension but not in the table. Mira's proposal: extend the table to include person.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.

  6. Bulk-vs-singleton ambiguity in R-FADP-5. Pax's endpoint at POST /admin/persons/finalize-expired finalizes the entire cohort in one POST (no per-person_id body 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:

  7. (a) extending the endpoint to accept a person_id query/body parameter that scopes the cascade to one row, OR
  8. (b) a new endpoint POST /admin/persons/{person_id}/finalize-expired for 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).

  9. Concurrent finalize race + Pax's batch commit semantics. routes_admin_persons.py::finalize_expired does a single session.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 sees scrubbed_at IS NOT NULL and 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)

  1. OQ-FE-1. Bulk-mode vs singleton-mode finalize endpoint. Proposed default: extend Pax's endpoint to accept a person_id query 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-line if person_id branch in the candidate-scan). Alternative: new endpoint POST /admin/persons/{id}/finalize-expired. Either works; query-param is the smaller diff. Stefan to confirm.

  2. OQ-FE-2. Failure-row audit emission. Proposed default: write admin_audit_log row with action = person.finalize_expired.failed on 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.

  3. OQ-FE-3. Pre-scrub display name retention in admin_audit_log.metadata. Proposed default: yes — store pre_scrub_display_name on the admin_audit_log row's metadata. Surfaces on Stage 3 for closure; available to forensic queries; NOT in share_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.

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

  5. 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_at being set; the 7-day mark is operational hygiene, not legal). Alternative: 14 days (gentler); 3 days (tighter). Stefan to confirm.

  6. OQ-FE-6. Route canonicalisation. Proposed default: /admin/persons/finalize-expired is canonical; /admin/dsar/queue/finalize-expired 302-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.

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

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

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

  10. OQ-FE-10. Stage 3 audit-refs collapsibility. Proposed default: <details> collapsed by default on sm; expanded on md+. 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.

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

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

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

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

  15. 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.comments and 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.