# stapel-gdpr 0.4.2

GDPR compliance: staged async data export (Art. 15/20) with status polling and download, account deletion with a cancellable grace period (Art. 17), inactivity closure, retention cleanup, LegalHold (blocks closure/deletion) and ReRegistrationHash, orchestrated through an in-process GDPRProvider registry (stapel_core.gdpr.gdpr_registry) plus comm confirmation (gdpr.section.erased) for remote services.

Contract: axes 1 · surface 14 · extension points 3 · error codes 51.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- REMOTE_DELETION_SERVICES [list, default []] — Remote services that must confirm account erasure
  Service names that must confirm erasure via a gdpr.section.erased comm action before a closure is marked deleted; one AccountDeletionPart is created per entry in execute_deletion (conf.py, MODULE.md Extension points table).

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- check_inactive_accounts — stapel_gdpr.tasks.check_inactive_accounts
  Daily Celery task: sends 60-day and 14-day inactivity warning emails, then initiates closure at the 365-day inactivity mark. Wire it via get_gdpr_beat_schedule — this is the entire mechanism behind the module's inactivity-closure axis, there is no other trigger.
- expire_export — stapel_gdpr.tasks.expire_export
  Expire one export request: delete its archive from disk and drop its token. Shared by the download view (a token spent after the window) and purge_expired_exports, so 'expired' means the ZIP is gone on both paths. Call it directly only to retire one specific request out of band.
- notify_llm_providers_of_deletion — stapel_gdpr.tasks.notify_llm_providers_of_deletion
  Call this from a custom GDPRProvider's delete()/anonymize() whenever that provider's data was ever sent to a third-party LLM provider, to leave an auditable log line of the DPA deletion obligation you still must action manually (this module does not call any LLM provider's API itself). Nothing in stapel-gdpr wires this automatically — it exists only to be called from your own provider, and an unwired one is silent by construction.
- process_expired_grace_periods — stapel_gdpr.tasks.process_expired_grace_periods
  The Celery task that actually calls GDPROrchestrator.execute_deletion() for every account whose 30-day grace period elapsed, skipping users under an active LegalHold. This is the ONLY path that turns a scheduled closure into a real erasure — a deployment that forgot to register get_gdpr_beat_schedule() has working closure/cancel endpoints and never deletes a single account, with nothing anywhere saying so.
- purge_expired_exports — stapel_gdpr.tasks.purge_expired_exports
  Hourly Celery task that deletes export ZIPs whose download window closed or whose single-use token was already spent. Wire it via get_gdpr_beat_schedule: without it every export archive — a complete personal-data dump per user — stays on the filesystem forever, which is exactly the retention gap the audit found.
- purge_unverified_hashes — stapel_gdpr.reregistration.purge_unverified_hashes
  Delete ReRegistrationHash rows that did not come through store_hashes (unknown, probably unsalted digest format) — the remediation half of the gdpr.E004 check, also exposed as `manage.py gdpr_purge_unverified_hashes`. Reach for it after pointing a stray writer at store_hashes; the rows it deletes are PII retained for a purpose they cannot serve, since a lookup never matches them.
- run_data_export — stapel_gdpr.tasks.run_data_export
  The Celery task that actually runs a single export request's in-process providers and assembles the archive. Normally triggered internally via `.delay()` from DataExportRequestView — call it directly only to retry/backfill one specific request_id.
- run_retention_cleanup — stapel_gdpr.tasks.run_retention_cleanup
  Daily Celery task that purges ReRegistrationHash rows past their 24-month retention, skipping users under an active LegalHold. Wire it via get_gdpr_beat_schedule; without it expired hashes accumulate forever instead of aging out on schedule.
- sweep_deletion_deadlines — stapel_gdpr.tasks.sweep_deletion_deadlines
  Hourly Celery task that flips deletion parts whose data owner never confirmed to TIMEOUT, keeping the closure out of DELETED and putting the silent owner's name in the log. Wire it via get_gdpr_beat_schedule; unwired, an owner's silence is indistinguishable from an owner still working.
- sweep_pending_exports — stapel_gdpr.tasks.sweep_pending_exports
  Hourly Celery task that force-assembles a partial archive for any export request whose 24h deadline passed (a service down mid-export, or one that never responded). Wire it via get_gdpr_beat_schedule; call directly only for an out-of-band admin sweep.
### predicate
- is_reregistration — stapel_gdpr.reregistration.is_reregistration
  instead of: stapel_gdpr.models.ReRegistrationHash.objects.filter(...) queried directly from a signup view
  The signup-time check: true if this email/phone belonged to a previously erased account within the 24-month retention window (part of stapel_gdpr.__all__). Call this from ANY registration/invite-acceptance flow instead of querying ReRegistrationHash directly — the salt and normalization here must match store_hashes' write side exactly, and a hand-rolled lookup silently never matches (wrong case-fold, wrong phone digit-stripping), making banned/deleted users re-registering invisible.
### factory
- compute_hash — stapel_gdpr.reregistration.compute_hash
  The low-level purpose-bound keyed HMAC underneath store_hashes/is_reregistration — normalizes then hashes one email-or-phone value. This is the ONE re-registration hash format in the fleet: the store lives in this library, so its format is defined here and every writer must go through store_hashes rather than hashing into the table itself (a plain SHA-256 of a normalized email is dictionary-recoverable). NOT exported in stapel_gdpr.__all__: call store_hashes or is_reregistration unless you are writing a one-off data migration that needs the exact same digest for an existing row.
- get_gdpr_beat_schedule — stapel_gdpr.tasks.get_gdpr_beat_schedule
  instead of: hand-authored CELERY_BEAT_SCHEDULE crontab entries for GDPR sweeps
  Spread `**get_gdpr_beat_schedule()` into CELERY_BEAT_SCHEDULE to wire all four scheduled GDPR workers (export-deadline sweep, grace-period deletion, inactivity checker, retention cleanup) at once. Hand-authoring these four crontab entries yourself is how one gets silently dropped — most importantly process_expired_grace_periods, without which grace-period deletions never execute even though the API says a closure is scheduled.
- store_hashes — stapel_gdpr.reregistration.store_hashes
  instead of: hand-rolled hashing of email/phone for post-deletion re-registration memory
  Persist re-registration hashes for a user about to be erased, in the one keyed-HMAC scheme this library defines (part of stapel_gdpr.__all__). GDPROrchestrator.execute_deletion() already calls it before erasure runs; call it directly from any OTHER writer that wants a row in ReRegistrationHash — rows written around it are recorded as scheme='unverified', never match a lookup, and are reported by the gdpr.E004 system check.

## Extension points — what a product replaces, fork-free
- GDPR_PROVIDERS [dotted_path_list]
  Flat Django setting: list of GDPRProvider class paths loaded via import_string and registered into stapel_core.gdpr.gdpr_registry in apps.py ready() — no compile-time dependency on any service package (MODULE.md 'Deletion parts', apps.py:18-style registration).
- deletion_parts [protocol]
  Three fork-free ways a module/app participates in account deletion: an in-process GDPRProvider (export/delete/anonymize), a comm subscriber that confirms via gdpr.section.erased (opt in via REMOTE_DELETION_SERVICES), or a remote GDPRServiceConsumerCommand in microservices mode (MODULE.md 'Deletion parts — how a module/app participates').
- serializer_seams [class_override]
  Every view subclasses GDPRAPIView (request_serializer_class/response_serializer_class + get_* getters); subclass and remount to reshape a response envelope (views.py:45-56, MODULE.md 'Serializer seams').

## Fits with — fleet dependencies
- stapel-core (required) — GDPRProvider registry (stapel_core.gdpr.gdpr_registry), comm bus (user.deletion_initiated/user.deleted emits, gdpr.section.erased consume), notifications request helper (pyproject.toml dependency; MODULE.md comm surface)

## Error codes (51) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.gdpr.account_closed [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.gdpr.export_not_found [404] retry
- error.404.gdpr.no_active_closure [404] fix_input
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.409.gdpr.closure_already_pending [409] fix_input
- error.409.gdpr.export_cooldown [409] fix_input
- error.409.gdpr.legal_hold [409] fix_input
- error.410.gdpr.download_consumed [410] retry
- error.410.gdpr.download_expired [410] retry
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.425.gdpr.export_not_ready [425] retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.gdpr.closure_unavailable [503] retry
