FADP Implementation Asks¶
This page is the formal requirements deliverable for the seven schema / endpoint asks logged in fadp-posture.md § Schema asks for Pax / Theo. The posture page is the rules-layer contract — what the platform owes data subjects; this page promotes each schema ask to a numbered requirement (R-FADP-1 … R-FADP-7) with rationale, acceptance criteria, edge cases, and test scenarios that downstream agents (Theo for data-model, Pax for endpoint + helper) can build against without re-deriving them.
Why a separate page. The posture page is long; the schema asks were a tail-end "Pax/Theo flag" section. Stefan asked for them to be formalised as a coherent deliverable so Theo can amend ADR-0004 /
data-model.mdagainst a stable target and Pax can plan a Phase 3.x slice with a stable acceptance set. This page is that target.
Cross-cuts: FADP posture (parent rules layer); client identity & sharing + ADR-0004 (Person / ClientProfile, share_audit, granter_kind); stringer-lifecycle (the parallel scrub-on-offboarding cascade); order-lifecycle + ADR-0007 (Order.receipt_emit_log is the per-emit snapshot scrubbed by R-FADP-6); data model (the Person, ClientProfile, share_audit rows). Tracked in racket-book#111.
How to read this page¶
Every requirement is structured as:
- Rationale — the FADP article that grants the underlying right + the operational consequence if the ask is not landed.
- Acceptance criteria (A-…) — the testable invariants. These are the contract Pax / Theo must satisfy and that QA / Quill must regression-test.
- Edge cases — the boundary conditions; what the ask does at the corners (Person never claimed, Person re-onboarding after scrub, draft Person created by an offboarded stringer, etc.).
- Test scenarios — narrative end-to-end flows that exercise the requirement as a whole. Pax / Quill use these as integration-test seeds.
Schema-impact summary¶
The seven asks below split cleanly across two ownerships:
| # | Ask | Entity / surface | Migration shape | Owner (data-model) | Owner (impl) |
|---|---|---|---|---|---|
| R-FADP-1 | Person.deleted_at (soft-delete tombstone) |
Person table |
Add nullable timestamptz column (verify-or-add — already mentioned in ADR-0004 prose). |
Theo (ADR-0004 + data-model.md amendment) |
Pax (Phase 3.x soft-delete endpoint) |
| R-FADP-2 | Person.scrubbed_at (set-once hard-erase tombstone) |
Person table |
Add nullable timestamptz column + DB-level guard against UPDATE-back-to-NULL (CHECK or trigger). |
Theo (ADR-0004 amendment) | Pax (Phase 3.x cascade transaction) |
| R-FADP-3 | dsar_log table |
New table | Append-only log; FK to Person retained even after scrub; admin-only RLS-equivalent in V2. |
Theo (data-model.md new entity) | Pax (DSAR endpoint slice) |
| R-FADP-4 | Draft-Person provenance | Person.created_by_kind + Person.created_by_id OR share_audit event |
Either two columns on Person OR a new event_kind = person_created row in share_audit. Theo's call. |
Theo (ADR-0004 amendment) | Pax (Person-creation paths emit the row) |
| R-FADP-5 | POST /admin/persons/finalize-expired endpoint |
New admin endpoint | No schema; runs the cascade transaction. | — (no data-model change) | Pax (admin endpoint + dispatcher) |
| R-FADP-6 | scrub_orders_for_person(person_id) helper |
Application function | No schema; updates Order.comments + per-emit snapshots in place. |
Theo (blesses call sites; the cascade signature) | Pax (writes the helper) |
| R-FADP-7 | share_audit.event_kind enum extension |
share_audit table |
Add three values: person_erasure, dsar_served, consent_change. |
Theo (ADR-0004 amendment + Alembic enum-add) | Pax (audit-write call sites that emit the new kinds) |
Total schema deltas: 2 new columns on Person (R-FADP-1, R-FADP-2), 1 new table (R-FADP-3), either 2 more columns on Person or 1 new event-kind use (R-FADP-4 — Theo's call), 1 enum extension on share_audit (R-FADP-7). One Alembic migration can land them all; ordering within the migration is Theo's call.
No changes to ClientProfile, Stringer, Order, or order_shares schemas. Order.comments content is mutated at runtime by R-FADP-6, but the column shape does not change.
R-FADP-1 — Person.deleted_at (soft-delete tombstone)¶
Statement. Every Person row carries a nullable deleted_at timestamptz column that, when non-NULL, marks the Person as soft-deleted (the data-subject has requested erasure but the 30-day grace window has not yet expired and hard-erase has not yet run).
Rationale¶
- FADP basis. Art. 32 par. 1 grants the data subject the right to request erasure. Two-stage erasure (soft then hard) is the mistake-recovery pattern documented in fadp-posture.md § Two-step erasure and matches the stringer-side 90-day grace pattern in stringer-lifecycle.md § Lifecycle states (with a shorter Person-side window per OQ-F-2).
- Consequence if missing. The platform has no way to express "user requested deletion but is in the grace window" — the only options become "delete immediately on request" (no mistake recovery, regret-prone) or "ignore the request until the cron fires" (FADP non-compliant). Neither is acceptable.
- Note on current state. ADR-0004 § FADP positioning and stringer-lifecycle.md § FADP positioning reference
Person.deleted_atin prose, but thedata-model.md § Personfield table does NOT list the column. Theo's amendment lands the field formally.
Acceptance criteria¶
- A-FADP-1.1. A
Person.deleted_at timestamptz NULLcolumn exists in the schema, indexed for the cleanup-job scan in R-FADP-5. - A-FADP-1.2. Setting
deleted_atis the only way to soft-delete a Person; there is nois_deletedboolean parallel to the timestamp. - A-FADP-1.3. Clearing
deleted_at(back to NULL) is allowed only whenscrubbed_at IS NULL— i.e. a soft-deleted-then-not-yet-scrubbed Person can be reversed during the grace window. Oncescrubbed_atis set,deleted_atstays set forever (its value is the "request received" timestamp the audit relies on). - A-FADP-1.4. Existing visibility queries (chokepoint, listings) MUST exclude soft-deleted Persons by default. Inclusion is opt-in (admin DSAR-export view; cleanup-job query).
- A-FADP-1.5. Setting
deleted_atwrites a row inshare_auditwithevent_kind = person_erasure(per R-FADP-7) andmeta = {"phase": "soft_delete"}. - A-FADP-1.6. Reversing
deleted_at(clearing back to NULL) writes a row inshare_auditwithevent_kind = person_erasureandmeta = {"phase": "soft_delete_reversed"}.
Edge cases¶
- Person never claimed (no
email_verified_at). A draft Person created by a stringer can still be soft-deleted — e.g. if the stringer realises they created the wrong record. The reversal path is admin-mediated (V2) until the V3 portal lands; the soft-delete UX is the same. - Person who is also a Stringer (dual identity). Per data-model.md § Stringer note, a human can hold both a
Stringerrow and aPersonrow. Soft-deleting thePersonrow does NOT touch theStringerrow — they are separate identity roots; the offboarding cascade for the stringer is a different flow (stringer-lifecycle.md § Offboarding). - Soft-delete during a pending DSAR. Per A-ERA-6 — refused. The pending request must be served or cancelled first. Implementation: the soft-delete endpoint queries
dsar_logforstatus = 'pending'rows; if any exist, returns 409 with a message naming the pending request_id. - Person referenced by orders that are still in flight (Draft / Ordered). Soft-delete is allowed; new orders against this Person are refused (the chokepoint excludes soft-deleted Persons from the new-order Person picker). In-flight orders are NOT auto-cancelled — the stringer's contract performance survives. The stringer is notified that their client requested erasure and may choose to complete the in-flight job (reasonable: they already have the rackets) or cancel it; either way the receipt PII gets scrubbed at hard-erase time.
Test scenarios¶
- Soft-delete + reverse within grace. Stringer creates a Person for client Anna. Anna emails Stefan asking to be deleted. Stefan triggers the admin soft-delete endpoint.
Person.deleted_atis set; Anna disappears from the stringer's client list;share_auditcarries aperson_erasurerow. Two days later Anna emails back saying she changed her mind. Stefan triggers the reversal.Person.deleted_atis cleared; Anna re-appears in the list; a secondperson_erasurerow records the reversal phase. - Soft-delete with pending DSAR refused. Anna requests a DSAR;
dsar_logrow created inpending. While pending, Stefan attempts to soft-delete Anna. The endpoint returns 409. Stefan completes the DSAR (status flips toresponded), then re-attempts the soft-delete. Now allowed. - Soft-deleted Person excluded from new-order picker. Stringer-A's listing UI does not show Anna as a candidate for a new Stringjob while
deleted_atis set. Existing orders for Anna remain visible (with a "client soft-deleted" badge — Mira's UX).
R-FADP-2 — Person.scrubbed_at (set-once hard-erase tombstone)¶
Statement. Every Person row carries a nullable scrubbed_at timestamptz column that, when non-NULL, marks the Person as hard-erased: PII fields have been scrubbed to placeholders and the row is now a tombstone retained only for FK integrity. The column is set-once — once non-NULL, no UPDATE may clear it.
Rationale¶
- FADP basis. Art. 32 (right to erasure) + Art. 6 par. 4 (data-minimisation: no longer keep what is no longer needed). The scrub is the moment of compliance.
- Consequence if missing. Without a tombstone marker the platform cannot distinguish "live Person with no email yet" from "scrubbed Person whose email was cleared" — and it cannot enforce the "scrubbed once is permanently scrubbed" invariant (A-ERA-7). FK-integrity orphan-prevention requires the row to survive; the timestamp is what tells everything else "this row's PII is gone".
- Set-once is load-bearing. Without the set-once guarantee, an admin (or a buggy migration) could re-fill PII into a tombstone row, defeating the erasure that was already served. The DB-level guard makes the invariant unbypassable.
Acceptance criteria¶
- A-FADP-2.1. A
Person.scrubbed_at timestamptz NULLcolumn exists. - A-FADP-2.2. A DB-level guard (CHECK constraint or BEFORE-UPDATE trigger; Theo's call which) refuses any UPDATE that transitions
scrubbed_atfrom non-NULL to NULL or to a different non-NULL value. The error returned is descriptive (e.g.Person.scrubbed_at is set-once). - A-FADP-2.3. Setting
scrubbed_atis the only legal effect of the hard-erase cascade; the cascade transaction setsscrubbed_at, mutates the PII fields per fadp-posture.md § Cascade rules, and writes the audit row in one atomic step. - A-FADP-2.4. Setting
scrubbed_atwrites a row inshare_auditwithevent_kind = person_erasure(per R-FADP-7) andmeta = {"phase": "hard_erase", "cascade_summary": {...}}(the summary names the count of orders / shares / audit rows touched). - A-FADP-2.5. A scrubbed Person row has
email IS NULL,email_verified_at IS NULL,display_first_name = '[redacted]',display_last_name = '[redacted]',notification_prefs IS NULL(or'{}'::jsonb),claim_token IS NULL.default_locale,gotrue_user_id,merged_into, and timestamps are retained (no PII). - A-FADP-2.6. A scrubbed Person row's
gotrue_user_idis retained on the row but the corresponding gotrue user is separately deleted by the cascade — this is per A-ERA-3 (single-transaction cascade) and per keystone ADR-0005 / project_gotrue_hs256.md coordination boundary. If the gotrue delete fails, the cascade transaction is rolled back;scrubbed_atis NOT set and the Person remains soft-deleted, eligible for retry. Pax flag for the failure-mode design.
Edge cases¶
- Re-onboard at the same email after scrub. Per OQ-F-5 default: allowed. Because
emailis cleared (NULL) on scrub, a future Person with the same email can be created without a unique-constraint conflict. The scrubbed tombstone row stays as it is — a separate row for the new Person. Audit reads correctly resolve "old Anna" vs. "new Anna" via PK. - Person who was a stringer-side dual identity. If the human had both a
Stringerrow and aPersonrow, scrubbing the Person scrubs the Person's PII and breaks the link to gotrue (R-FADP-2.6). TheStringerrow is unaffected unless that human also separately offboards as a stringer — that is a different cascade (stringer-lifecycle.md § Cascade rules). - Scrub attempted on a Person with
deleted_at IS NULL. Refused — hard-erase requires the soft-delete + grace state (A-ERA-2). The cleanup-job endpoint's predicate (R-FADP-5) enforces this; a direct admin-call route should also enforce it. - Scrub attempted twice on the same Person. The DB-level set-once guard refuses the second UPDATE. The cleanup-job loop must filter rows where
scrubbed_at IS NOT NULLto avoid hitting the guard for already-scrubbed rows. - Person never claimed (draft Person, never ratified). Scrub still works — a draft Person who is soft-deleted, then 30 days pass, becomes scrubbable. The
display_first_name/display_last_nameset by the stringer at draft-creation are scrubbed to[redacted]; the stringer'sClientProfilesurvives (per the cascade rules in fadp-posture.md). This is the most common case in V2 (every Person starts as a draft). - Scrub of a Person referenced by an Order whose receipt already emitted. The already-emitted receipt PDF in the customer's inbox is NOT recallable (per fadp-posture.md cascade rules — same caveat as share-revocation). The retained
Order.receipt_emit_logsnapshot has its name fields scrubbed via R-FADP-6; any future receipt re-render shows[redacted].
Test scenarios¶
- Cascade transaction success path. Anna soft-deleted on day 0; on day 31, admin runs
POST /admin/persons/finalize-expired. Cascade runs:Person.scrubbed_atset, PII fields scrubbed,Order.commentsfor Anna's orders →[redacted by request],Order.receipt_emit_logsnapshot name fields →[redacted],order_sharesandperson_stringer_sharerows where Anna is the subject →revoked_atset,share_auditcarries oneperson_erasurerow withphase=hard_erase. Single transaction; on failure the whole thing rolls back. - Set-once enforcement. Direct DB UPDATE attempt to clear
scrubbed_at(e.g. an accidental migration script or a buggy admin tool) is refused by the CHECK / trigger; error message names the invariant. - gotrue-side delete failure rollback. Cascade started but the gotrue delete call returns 500. Transaction rolls back;
Person.scrubbed_atis still NULL; admin can retry. The audit row reflects the failure (one row withphase=hard_erase_failed— see also Quill: this needs a test). - Re-onboard after scrub. Anna scrubbed; six months later a new Anna with the same email tries to register via V3 portal. The registration succeeds (creates a new Person row); the old tombstone is unaffected. DSAR for "new Anna" only includes new Anna's data.
R-FADP-3 — dsar_log table¶
Statement. A new append-only table dsar_log records every DSAR / erasure / portability request and its corresponding response. Rows are retained for 5 years from responded_at per fadp-posture.md § Retention policy.
Rationale¶
- FADP basis. Art. 25 (right of access — 30-day SLA), Art. 31 par. 2 lett. c (legitimate interest in keeping records of compliance). The platform must be able to answer "how long did the response take?" and "did we serve every request?" — that requires a log.
- Consequence if missing. Without
dsar_logthe platform cannot prove FADP-rights compliance to a regulator and cannot show its own SLA performance. Worse: if a Person disputes whether their request was served, there is no source of truth.
Schema (Theo's call on details; this is the requirement)¶
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
person_id |
UUID FK → Person.id |
Retained even after the referenced Person is scrubbed (the FK survives the cascade). |
kind |
enum | access_request | erasure_request | portability_request. |
requested_at |
timestamptz NOT NULL | When the request was received. |
responded_at |
timestamptz NULL | NULL while pending; set when status flips to responded or cancelled. |
status |
enum NOT NULL DEFAULT 'pending' |
pending | responded | cancelled. |
requested_by_kind |
enum NOT NULL | person | admin. (V2 = always admin since the channel is out-of-band; V3 portal = person.) |
requested_by_id |
UUID NULL | FK to Person.id if person; FK to Stringer.id (admin) if admin. Polymorphic — Theo's call on the model shape. |
response_ref |
text NULL | URL to the response artifact (a stored JSON document) OR a sha256 hash + a bytes-stored payload. Theo to bless the storage shape. |
verification_method |
text NULL | Free text: V2 admin notes ("verified by reply to verified email on file 2026-05-12"); V3 magic-link auto-fills (magic-link claim @ <ts>). |
meta |
JSONB | Free-form; carries e.g. cancellation reason, partial-completion notes. |
created_at, updated_at |
timestamptz | Standard audit columns. |
Acceptance criteria¶
- A-FADP-3.1. Every state-change of a DSAR / erasure / portability request writes a row OR updates the existing row in
dsar_log. There is one row per request (mutated as it progresses through the state machine), not one per state-change. - A-FADP-3.2. The state machine is
pending → respondedorpending → cancelled. Once a row is inrespondedorcancelled, it is immutable. - A-FADP-3.3.
responded_atis non-NULL exactly whenstatus IN ('responded', 'cancelled'). CHECK constraint enforces. - A-FADP-3.4. Visibility (V2): admin-only read. (V3): the requesting Person sees their own rows; admin sees all. The chokepoint enforces.
- A-FADP-3.5. Soft-deleting or hard-erasing a Person does NOT delete or modify their
dsar_logrows — they are retained 5 years pastresponded_atper the retention policy. Theperson_idFK survives because the scrubbedPersonrow survives. - A-FADP-3.6. A
dsar_logrow inrespondedforkind = erasure_requestis the audit trail of the erasure cascade itself; it carriesmeta.cascade_summarymatching theshare_audit.metasummary (R-FADP-2.4). - A-FADP-3.7. Setting
kind = dsar_servedon the correspondingshare_auditrow (per R-FADP-7) happens in the same transaction as thedsar_logrow's transition toresponded.
Edge cases¶
- Request received for a Person that does not exist. The admin endpoint (V2) refuses with 404 — no
dsar_logrow is written for non-Persons (the request never landed in the system). For audit, the admin's email back to the requester is the trail; no schema cost. - Request received for a soft-deleted Person. Allowed — the Person can still exercise rights during the grace window. Per A-DSAR-4.
- Request received for a hard-erased Person. Per A-DSAR-5: response is a minimal stub
{"status": "erased", ...}; thedsar_logrow is still written (audit invariant). - Erasure-request from a Person who is also a stringer. Allowed; the erasure scope is the Person row only (R-FADP-1 edge case). The
dsar_log.requested_by_kindisperson(V3) oradmin(V2 admin-mediated); the request_kind iserasure_request. The Stringer's offboarding flow is a separatedsar_log-irrelevant audit trail in stringer-lifecycle.md. - Cancellation by the requester before response. The
dsar_logrow flips tocancelledwithresponded_atset to the cancellation timestamp;meta.cancel_reasoncarries the reason. No further request from this Person is needed; if they want a new DSAR they file a new request (new row). - Long-running export pipeline crashes mid-way. The
dsar_logrow stays inpending; admin can retry. The retry does NOT create a new row; it re-attempts the export and updates the existing row on completion. Pax: design the retry path to be idempotent (probably a job-runner with a job-id key onmeta).
Test scenarios¶
- End-to-end DSAR. Anna emails Stefan asking for a data export. Stefan creates a
dsar_logrow (pending,kind=access_request,requested_by_kind=admin,requested_by_id=<Stefan>). Stefan triggers the export pipeline; the pipeline runs, writes a JSON document to storage, setsresponse_refand flips the row toresponded. Stefan emails the response link to Anna. SLA check:responded_at - requested_at <= 30 days. - Erasure request → soft-delete → grace → cleanup-job → response. Anna requests erasure; row created (
pending,kind=erasure_request). Stefan triggers soft-delete (R-FADP-1); 30 days pass; cleanup-job runs (R-FADP-5); cascade completes; thedsar_logrow flips torespondedwithmeta.cascade_summary. - Portability request — same shape as DSAR but distinct row. Anna asks to port her data to another system. New
dsar_logrow,kind=portability_request. The export pipeline runs (identical to DSAR). The twokindvalues let the platform report on the rights-mix exercised.
R-FADP-4 — Draft-Person provenance¶
Statement. Every Person creation event records who created the row: the stringer ID for stringer-created draft Persons, or system / NULL for self-creation via the V3 portal magic-link flow. The mechanism is either (a) a Person.created_by_kind + Person.created_by_id column pair, or (b) a share_audit row with event_kind = person_created. Theo decides which.
Rationale¶
- FADP basis. Art. 19–20 (transparency about how data was collected). For a draft Person created by a stringer on behalf of a not-yet-onboarded client, the legal basis at creation is "consent-by-proxy under legitimate interest" (per fadp-posture.md § Stringer-created draft Persons). The provenance — which stringer, when — is the audit trail backing that legal basis. Without it, a Person who later asks "how did you get my data?" gets a non-answer.
- Consequence if missing. Cannot reconstruct the consent-by-proxy chain; cannot prove FADP transparency duty was met; the V3 magic-link "we already had a record for you, created by Stringer X on
" ratification screen (A-CONS-2) cannot be rendered.
Acceptance criteria¶
- A-FADP-4.1. For every
Personrow, the system can answer the question "who created this row, when, and via what flow?" via one query — either a column-read or ashare_auditrow lookup. - A-FADP-4.2. For stringer-created draft Persons:
created_by_kind = stringer;created_by_id = <stringer.id>. - A-FADP-4.3. For self-created Persons (V3 portal magic-link claim that did NOT land on an existing draft):
created_by_kind = self;created_by_id = <person.id>(the Person's own ID, which the system knows post-creation). - A-FADP-4.4. For migration-created Persons (Vera's M15 ETL):
created_by_kind = migration;created_by_id IS NULL. - A-FADP-4.5. Provenance is immutable post-creation. There is no flow that mutates a Person's
created_by_*fields (or rewrites the audit row). If a Person merge happens (Person.merged_into), the surviving Person's provenance is unchanged; the merged-in Person's provenance is unchanged; the audit trail of the merge itself is a separateshare_auditrow (existing model). - A-FADP-4.6. Person scrub does NOT clear provenance — it is not PII; it is metadata about the platform's relationship with the Person.
Edge cases¶
- Self-Profile creation (
is_self_for_stringer = TRUE). When the lazy-create path fires (per stringer-lifecycle.md § Self-Profile slot), a new Person is created bound to the stringer'sgotrue_user_id. Provenance:created_by_kind = self,created_by_id = <person.id>(consistent with V3-portal self-creation; the human is creating their own Person, just via a different surface). - Stringer who created the draft Person was later offboarded. Per stringer-lifecycle.md § Cascade the
Stringerrow is retained as a tombstone post-finalize. The FKcreated_by_idsurvives — the stringer's display name resolves to[scrubbed]on read but the audit chain is intact. - Draft Person that is later ratified. No provenance rewrite — the row was created by the stringer (consent-by-proxy); the ratification is a separate event that sets
email_verified_atand writes its ownshare_auditrow (event_kind = consent_change, per R-FADP-7). The originalcreated_by_*is correct: it records who created the row, not whether the Person consents (that's the ratification). - DSAR response includes provenance. When a Person exercises DSAR, the response includes the human-readable "your record was created by Stringer X on
, ratified on " — derived from created_by_*+email_verified_at.
Test scenarios¶
- Stringer creates draft → DSAR shows provenance. Stringer-A creates a draft Person Anna on day 0; Anna ratifies on day 7; Anna files a DSAR on day 30. The DSAR response surfaces the provenance ("created by Stringer-A on day 0, ratified by you on day 7").
- Migration-created Person → no stringer ID. Vera's M15 ETL creates 750-ish Persons from the V1 XLSX. Each row has
created_by_kind = migration. DSAR for these Persons surfaces "your record was migrated from the V1 spreadsheet on". - Provenance survives stringer offboarding. Stringer-A offboards; the Person Anna created by Stringer-A retains
created_by_id = <Stringer-A>; the resolved display name shows[scrubbed]post-finalize, but the audit chain is queryable.
R-FADP-5 — POST /admin/persons/finalize-expired endpoint¶
Statement. A new admin endpoint scans Person rows whose 30-day soft-delete grace has expired and runs the hard-erase cascade for each. Admin-triggered (not cron); idempotent.
Rationale¶
- FADP basis. Art. 32 par. 1 — the right to erasure must be honoured within reasonable time. The 30-day grace (fadp-posture.md OQ-F-2) plus admin-trigger cadence (e.g. weekly) keeps the platform inside the FADP 30-day SLA window.
- Why admin-triggered, not cron. The same human-in-the-loop principle as B8 in stringer-lifecycle.md § Offboarding acceptance criteria — destructive operations don't auto-fire. Stefan reviews the candidate list (Mira's UX surface — fadp-posture.md hidden-7) and confirms before the cascade runs.
Acceptance criteria¶
- A-FADP-5.1. Endpoint signature:
POST /admin/persons/finalize-expired. Auth: admin role only (per ADR-0006 once HS256 amendment lands). - A-FADP-5.2. Predicate scanned:
Person.deleted_at IS NOT NULL AND Person.deleted_at < NOW() - INTERVAL '30 days' AND Person.scrubbed_at IS NULL. The30 daysliteral lives in a configuration constant (per A-RET-1). - A-FADP-5.3. For each matched Person, the endpoint runs the cascade transaction (per R-FADP-2). One Person per transaction (an isolated failure of one Person's cascade does not block the others).
- A-FADP-5.4. Response: JSON
{"finalized": <count>, "failed": <count>, "errors": [{"person_id": "...", "reason": "..."}]}. The endpoint is idempotent — calling it twice in quick succession should finalize 0 the second time (because the first run scrubbed everything matching the predicate). - A-FADP-5.5. The endpoint supports a
dry_run=truequery parameter that returns the candidate list without running the cascade. Default isdry_run=falseBUT the admin UX (Mira) callsdry_run=truefirst to surface the typed-confirm pattern. - A-FADP-5.6. Each completed cascade flips the corresponding
dsar_log.kind = erasure_requestrow frompendingtoresponded, in the same transaction (per A-FADP-3.6). - A-FADP-5.7. The endpoint records its own invocation in
share_auditwithevent_kind = person_erasure(one row per cascade run, withmeta.batch_idlinking the per-Person audit rows).
Edge cases¶
- Empty candidate list. Returns
{"finalized": 0, "failed": 0, "errors": []}. No audit rows written (no work happened). - Concurrent invocations. Two admins (or the same admin double-clicking) hit the endpoint simultaneously. Each transaction takes a row-level lock on the
Personrow; whichever wins runs the cascade, the other sees the row'sscrubbed_atalready set and skips it. Net result is correct but Pax: please add an HTTP-level idempotency guard to avoid wasted work. - A Person was created by a stringer who was later offboarded. No special handling — the cascade only touches the Person; it does not need to reach the (already-tombstoned) stringer row.
- A Person who is the subject of an active in-flight order (Draft / Ordered). Cascade still runs — by the time we are 30 days post-soft-delete, the stringer has had 30 days to decide whether to complete the in-flight job. Order rows are retained per the cascade rules; PII surfaces on the order are scrubbed (R-FADP-6). Stringer is notified per Stringer-side notification.
- A Person with no orders, no shares, no audit history. Cascade is trivially correct: scrubs the Person row + clears email + sets
scrubbed_at+ writes the audit row. No cascading work to do.
Test scenarios¶
- Happy path — 3 candidates, all succeed. Anna soft-deleted day 0; Bob soft-deleted day 2; Carla soft-deleted day 32. Admin calls endpoint on day 35. Anna and Bob match (both > 30 days old); Carla does not. Cascade runs for Anna and Bob; response:
{"finalized": 2, "failed": 0, "errors": []}.dsar_logrows for Anna's + Bob's erasure-requests flip toresponded. - Dry-run. Admin calls with
dry_run=true. Response:{"would_finalize": [...], "would_skip": [...]}. No cascade runs; no audit rows written (a separate "dry-run viewed" audit row may be useful — Pax flag). - One failure does not block others. Anna's cascade fails (e.g. gotrue delete returns 500); Anna's transaction rolls back. Bob's cascade succeeds independently. Response:
{"finalized": 1, "failed": 1, "errors": [{"person_id": "<Anna>", "reason": "gotrue_delete_failed"}]}. Anna remains soft-deleted; admin retries. - Idempotency. Admin calls twice in a row. Second call returns
{"finalized": 0, "failed": 0, "errors": []}.
R-FADP-6 — scrub_orders_for_person(person_id) helper¶
Statement. An application-side helper function scrub_orders_for_person(person_id) is invoked inside the hard-erase cascade transaction (R-FADP-2). It mutates the PII surfaces on every Order whose subject is the given Person: Order.comments → [redacted by request]; per-emit snapshots in Order.receipt_emit_log.content_snapshot have their name fields scrubbed via the same [redacted] placeholder while structural / financial / spec fields are retained. Returns a count of orders touched + a count of snapshots scrubbed.
Rationale¶
- FADP basis. Art. 32 + Art. 6 par. 4 (data-minimisation). The
Orderrow itself is retained 10 years (Swiss CO art. 957–958f); the PII content within it is not — that is the boundary the helper enforces. - Why a helper, not inline SQL. The cascade transaction is already complex; spreading the PII-scrub logic across it creates a tested-once, never-tested-again surface. A named helper with its own unit tests is the audit-defensible pattern. It also makes the Mira "what did the cascade touch" UX trivially queryable (the helper's return value is the summary).
- Why per-emit snapshots get scrubbed. Per order-lifecycle.md and ADR-0007's amendment,
Order.receipt_emit_log.content_snapshotretains the receipt-affecting fields at emit time so old receipt versions survive future edits. The snapshot includes the client's display name (the receipt PII surface). On erasure, those name surfaces in retained snapshots become[redacted]. Already-emitted PDFs in customer inboxes remain unrecallable (same caveat as share-revocation).
Acceptance criteria¶
- A-FADP-6.1. Function signature:
scrub_orders_for_person(session: Session, person_id: UUID) -> ScrubResultwhereScrubResultis{"orders_scrubbed": int, "snapshots_scrubbed": int}. - A-FADP-6.2. Scope of mutation per Order:
Order.comments→'[redacted by request]'(free text may have client PII; conservative blanket-scrub).Order.receipt_emit_log[].content_snapshot.client_display_name_first→'[redacted]'.Order.receipt_emit_log[].content_snapshot.client_display_name_last→'[redacted]'.Order.receipt_emit_log[].content_snapshot.client_email→null(drop).- All other fields (racket spec, string spec, tensions, prices, dates, receipt number) are RETAINED.
- A-FADP-6.3. Helper is idempotent — running it twice on the same Person yields the same end state (the second call mutates already-redacted fields to the same redacted values; orders_scrubbed count on second call = 0 if the helper short-circuits on already-redacted, OR = the same count if it doesn't, Pax's call which but first call's count is the canonical "what changed").
- A-FADP-6.4. Helper raises if called outside a transaction (the caller MUST be inside the cascade transaction so a failure rolls back atomically).
- A-FADP-6.5. Helper does NOT touch
Order.receipt_emit_logrows for orders whose subject is a different Person — the join predicate isOrder.client_profile_id IN (SELECT id FROM client_profiles WHERE person_id = :person_id). Cross-stringer ClientProfiles for the same Person all get scrubbed (the Person's PII reaches across stringers because the data subject is the Person, not the ClientProfile).
Edge cases¶
- Person has no orders. Helper returns
{"orders_scrubbed": 0, "snapshots_scrubbed": 0}. No mutation; no error. - Person has orders with NULL
comments. No-op for those orders' comments field;snapshots_scrubbedstill increments per snapshot. - Person has orders with already-
[redacted by request]comments. Idempotency edge — depends on Pax's implementation. If short-circuit-on-already-redacted: no mutation, no count. If unconditional set: mutation is a no-op-effective (same value); count increments. Either is correct; document which one in the helper docstring. - Order with multiple emit-snapshots. All snapshots for that order get their name fields scrubbed (each is its own retained version of the receipt; each gets the redaction).
- Person who was the subject AND a Rule-1 grantee on someone else's order. The helper does NOT scrub the grantee-side data — that is someone else's order, with someone else's Person as the subject. Only orders where THIS Person is the subject are touched.
- Person erased then a new Person created at the same email. The helper run during erasure is bound to the OLD person_id. The new Person's orders are independent.
Test scenarios¶
- Single-order case. Person Anna has 1 order with
comments = "Anna prefers softer mains", 2 receipt-emit-snapshots. Helper called → comments becomes[redacted by request]; both snapshots' name fields become[redacted]; structural fields untouched. Return:{"orders_scrubbed": 1, "snapshots_scrubbed": 2}. - Cross-stringer case. Person Anna has a ClientProfile under Stringer-A (1 order, Stringer-A's view) AND a ClientProfile under Stringer-B (3 orders, Stringer-B's view, via separate onboarding before identity-merge). Helper called for Anna's
person_id→ all 4 orders' comments scrubbed; both stringers' views show redaction. Return:{"orders_scrubbed": 4, "snapshots_scrubbed": <total>}. - Idempotency test. Helper called twice in a row → first call does the work; second call either returns same counts (if unconditional) or zero counts (if short-circuit). Quill picks the variant in implementation; both are correct, but the same one across all helpers in the cascade.
R-FADP-7 — share_audit.event_kind enum extension¶
Statement. Extend the share_audit.event_kind enum with three new values: person_erasure, dsar_served, consent_change. These are the audit-event kinds emitted by the cascade (R-FADP-2), the DSAR pipeline (R-FADP-3), and the V3 magic-link ratification + notification-prefs change flows (A-CONS-4).
Rationale¶
- FADP basis. Art. 31 par. 2 lett. c — legitimate interest in maintaining audit logs of who-did-what-when for compliance defence. The existing
share_audittable is the FADP-defensible audit log per ADR-0004; adding the three event kinds extends its coverage to FADP-rights events without inventing a parallel log. - Consequence if missing. Erasure events, DSAR responses, and consent changes have no audit trail in the canonical audit table — they live only in
dsar_log(which covers DSAR/erasure) and nowhere (consent changes). Splitting audit across two tables makes correlation hard; a regulator query for "what audit events are tied to Anna?" needs to JOIN both. Adding toshare_auditkeeps the canonical log canonical. - Why three kinds, not one combined. Each maps to a distinct event family with different downstream consumers:
person_erasureis the cascade audit (consumed by Mira's "I'm a stringer, my client erased" notification UX);dsar_servedis the SLA / response trail (consumed by admin reporting);consent_changeis the ratification + notification-prefs trail (consumed by the V3 portal "preferences history" view).
Acceptance criteria¶
- A-FADP-7.1. Alembic migration adds three values to the
share_audit.event_kindenum:person_erasure,dsar_served,consent_change. (Postgres enum-add is a one-statement DDL; documented in Theo's amendment.) - A-FADP-7.2. Existing application code that reads
event_kindand switches on the value MUST handle the new variants gracefully — at minimum a "unknown kind: ignore" fallback. Pax to grep call sites and add cases. - A-FADP-7.3. New event-kind semantics:
| Value | Emitted when | meta shape |
|---|---|---|
person_erasure |
Person.deleted_at is set or cleared (R-FADP-1.5, R-FADP-1.6); Person.scrubbed_at is set (R-FADP-2.4). |
{"phase": "soft_delete" \| "soft_delete_reversed" \| "hard_erase", ...}; for hard_erase, cascade_summary is included. |
dsar_served |
A dsar_log row transitions from pending to responded (any kind) (A-FADP-3.7). |
{"dsar_log_id": "<uuid>", "kind": "access_request" \| "erasure_request" \| "portability_request", "sla_ms": <int>} — the sla_ms lets reporting compute SLA-percentile without joining dsar_log. |
consent_change |
V3 magic-link claim ratifies a draft Person (A-CONS-2); Person.notification_prefs is updated (A-CONS-4); a Rule-2 / Rule-3 share is granted (already audited by existing event kinds — no double-write). |
{"kind": "ratification" \| "notification_prefs_update", "before": {...}, "after": {...}}. |
- A-FADP-7.4. Audit reads MUST continue to work after the enum extension — no code path that filters on an exhaustive
event_kindset may silently drop the new kinds.
Edge cases¶
event_kind = consent_changefor Rule-2 / Rule-3 grants. Per A-FADP-7.3 third row: the existingevent_kind = order_share_granted(or whatever ADR-0004 calls it) already covers the grant;consent_changedoes NOT double-write. The consent record for Rule-2 / Rule-3 is the share row itself + the existing audit row;consent_changeis for non-grant consent events (ratification + prefs).- Enum-add migration on production data. Postgres enum-add is non-destructive; existing rows are unaffected. The migration is forward-only; no data backfill is needed.
- Pre-existing audit rows that should retroactively be
person_erasure. None — there are no Person erasures in production yet. The migration is forward-only; historical audit rows keep their existingevent_kindvalues. - Consent change driven by an admin (V2 admin-mediated path).
actor_kind = admin;actor_id = <stringer.id of admin>;targetreferences thePersonwhose consent state changed. Same shape; just different actor.
Test scenarios¶
- Cascade emits one
person_erasurerow per phase. Anna soft-deleted: 1 row (phase=soft_delete). Anna reverses: 1 row (phase=soft_delete_reversed). Anna soft-deleted again, 30 days pass, hard-erase: 1 row (phase=hard_erase). Total: 3 rows tied to Anna'sperson_id. - DSAR served writes
dsar_served. Anna's DSAR completes;share_auditcarries 1dsar_servedrow withmeta.dsar_log_idmatching the correspondingdsar_logrow. - Notification-prefs update writes
consent_change. Anna toggles email opt-in OFF;share_auditcarries 1consent_changerow withmeta = {"kind": "notification_prefs_update", "before": {"email": true}, "after": {"email": false}}. - Existing audit reads keep working. Pax's chokepoint reads + Mira's audit-view rendering are exercised against a DB containing rows with the new event_kind values; nothing breaks.
Open questions¶
None. The seven asks above are direct promotions of the schema-asks list in fadp-posture.md § Schema asks; the open questions on the parent page (OQ-F-1 … OQ-F-5, all closed or defaulted) cover the rules-layer choices that flow into these requirements. If implementation reveals new questions, they get logged on this page.
Cross-references¶
- Parent rules layer: FADP posture — defines the rights surfaces this page implements.
- Architecture / authoritative ADR: ADR-0004 — Person + ClientProfile +
share_audit. Theo's amendment per R-FADP-1, R-FADP-2, R-FADP-4, R-FADP-7 lands here. - Architecture / data model: data-model.md § Person — Theo's amendment lands the new columns.
- Order-side cascade: order-lifecycle.md + ADR-0007 —
Order.receipt_emit_logis the per-emit snapshot scrubbed by R-FADP-6. - Stringer-side parallel: stringer-lifecycle.md § Cascade rules — the parallel offboarding cascade pattern the Person-side cascade mirrors.
- JWT auth (admin endpoint gate): ADR-0006 — the admin-role gate for R-FADP-5 rides the existing JWT middleware (post HS256-amendment per #108).
- Coordination: Theo (data-model amendment for R-FADP-1 / 2 / 4 / 7); Pax (R-FADP-3 endpoint, R-FADP-5 endpoint, R-FADP-6 helper); Mira (admin DSAR queue, finalize-expired typed-confirm UX — already flagged in fadp-posture.md hidden-requirements); Quill (regression tests per the test scenarios on each requirement).
- Issue tracking: racket-book#111 (this page); racket-book#98 (parent FADP posture page).