Skip to content

Data Retention & Anonymization

Kamerplanter stores data only as long as technically or legally required. This document describes the retention matrix, the automated enforcement mechanism (Celery task), TimescaleDB aggregation for sensor data, and configuration via environment variables.

Basis: GDPR Art. 5(1)(e).


Retention Matrix — Personal Data

Ref Data Category Retention Period Action after Period Legal Basis
R-01 Soft-deleted user accounts 90 days after soft-delete Hard-delete (incl. edges, auth providers, sessions) GDPR Art. 17
R-02 Unconfirmed accounts 7 days after creation Hard-delete (app.tasks.auth_tasks.cleanup_unverified_accounts, daily) Art. 5(1)(e), purpose lapse
R-03 IP addresses in sessions 7 days after storage Anonymization (IPv4: last octet → 0) Art. 5(1)© data minimization
R-04 Consent records 3 years after revocation Hard-delete (retention.purge_expired_consent_records, daily at 04:35 UTC); the IP address is anonymized after 7 days (R-04a, retention.anonymize_consent_ips, 04:40 UTC) Art. 7(1) accountability
R-05 Export files (GDPR Art. 15/20) 72 hours after completion Delete file first, then set status to expired Purpose lapse
R-06 Erasure audit (completed requests) 3 years after completion Hard-delete (retention.purge_expired_erasure_records, daily at 04:30 UTC) Art. 5(2) accountability
R-07 Email change requests 24 hours after creation Set status to expired and hard-delete the same record in the same run Purpose lapse
R-07a Revert window of a confirmed email change 7 days after confirmation Clear previous_email, the revert token's hash, and its expiry Purpose lapse — the revert link has expired
R-11 Expired refresh tokens Immediately on expiry Hard-delete (TTL index) Purpose lapse
R-12 Expired invitations 30 days after expiry Set status to expired, hard-delete after 30 days (app.tasks.tenant_tasks.cleanup_expired_invitations, daily at 02:00 UTC) Purpose lapse
R-38 Security audit (membership, role and scope changes; deactivation and email verification of an account by platform admins; suspension, reactivation and deletion request of a tenant) 730 days (2 years) after creation, fixed Hard-delete (security_audit.purge_expired, daily at 02:55 UTC); an account erasure turns the row's account keys into tombstone hashes, a tenant deletion leaves it GDPR Art. 32 (proof of access changes), Art. 5(2)

Every period except R-11 (TTL index), R-06a and R-38 (fixed) is read from exactly one setting (see Environment Variables for the exact names). For R-01, R-05 and R-07 there were already documented, older variable names before this change, but they had no effect — the code used fixed values. These older names now actually work and remain valid as aliases in addition. Every period comparison compares instants rather than strings, so a record is not captured too early or too late by a differently spelled timestamp.

Unconfirmed Accounts (R-02)

A never-confirmed account is removed automatically by the daily app.tasks.auth_tasks.cleanup_unverified_accounts task — RETENTION_UNVERIFIED_ACCOUNT_DAYS days after registration (default 7 days, minimum 1 day). The task runs the same full erasure as a self-filed erasure request, with the same erasure request as proof and the same automatic retry on a failed step — more on this further below under "All deletion paths do the same".

The task selects only accounts that were never confirmed, never demoted by an administrator and never signed in, and that have no provider row. Someone who registered locally has a LOCAL provider row and is therefore not yet reached. The task does not delete those accounts; it counts them on every run (local_registrations_pending in the result and the unverified_local_registrations_pending warning). Only once you have seen the number do you release the deletion with RETENTION_UNVERIFIED_LOCAL_REAP_ENABLED=true; after that only federated provider rows (Google, GitHub, Apple, OIDC) count as "linked", and service accounts stay exempt. If the count fails, the task keeps the narrow selector.

IP Anonymization (R-03)

IP addresses are automatically anonymized RETENTION_IP_ANONYMIZATION_DAYS days after the session was issued (default 7 days, minimum 1 day) — not deleted, as they may still be needed for detecting compromised sessions:

  • IPv4: Last octet set to 0 — 192.168.1.42 → 192.168.1.0
  • IPv6: Truncated to /48 prefix — 2001:db8:85a3::8a2e:370:7334 → 2001:db8:85a3::

The ip_anonymized_at field is set to the time of anonymization. This covers session IP addresses only — the IP address held by a consent record (see R-04 above) is not touched by it.

Export files: the file first, then the status (R-05)

The period is configurable via RETENTION_EXPORT_FILE_RETENTION_HOURS (default 72 hours, minimum 1 hour; the older name PRIVACY_EXPORT_RETENTION_HOURS remains valid as an alias). Cleanup of an expired data export runs in a fixed order: the export file is deleted from object storage first, and only then does the export switch to the expired status. If deleting the file fails, the export is left unchanged — the next hourly run retries it. Downloads are already refused once the 72-hour window has passed, regardless of the stored status. If no object storage is configured on the instance, the file cannot be deleted at all: the export is left open, and the run logs an error instead of flipping the status anyway.

Erasure Record Purge (R-06)

The daily retention.purge_expired_erasure_records task (04:30 UTC, after the erasure task at 04:00 UTC) hard-deletes completed erasure requests (erasure_requests, status=completed) whose completed_at is more than RETENTION_ERASURE_AUDIT_RETENTION_YEARS years in the past (default 3 years, minimum 1 year, maximum 3 years, counted in calendar years). This applies regardless of who triggered the erasure (origin: self_service, platform_admin, or unverified_cleanup).

A request that is still open or only partially completed (scheduled, in_progress, partially_completed) is never purged — it is still owed a run. A completed request without a completed_at is kept too. The run logs only the number of purged requests, never an account or request key.

Email Change Requests (R-07)

The hourly retention.expire_email_change_requests task (minute 15) sets a still unconfirmed email-change request to expired RETENTION_EMAIL_CHANGE_RETENTION_HOURS hours after the request (default 24 hours, minimum 1 hour; the older name PRIVACY_EMAIL_CHANGE_TTL_HOURS remains valid as an alias) and hard-deletes it in the same run, so the new address is not kept.

Revert Window of a Confirmed Email Change (R-07a)

Once an email change is confirmed, the previous address receives a one-time-use revert link (see Privacy — Changing Your Email Address). For that, the record keeps three extra fields: previous_email, the hash of the revert token (revert_token_hash), and its expiry (revert_expires_at), set to the confirmation time plus RETENTION_EMAIL_CHANGE_REVERT_DAYS (default 7 days, minimum 1 day).

The same hourly task as R-07 (retention.expire_email_change_requests, minute 15) closes the window in the same run: for every record whose revert_token_hash is set and whose revert_expires_at has passed or is unreadable, it clears all three fields — conservatively, so a record with an unreadable expiry is treated as expired. The revert link no longer works afterwards; the same run hard-deletes the rest of the record (new_email, timestamps) as R-07b once the stored revert_expires_at has passed.


Retention Matrix — Sensor Data

Sensor data can indirectly allow inferences about the presence and behavior of persons (CO2 curves, motion sensors, manual overrides). They are therefore subject to a tiered retention policy in TimescaleDB:

flowchart LR
    A["Raw data<br/>(full resolution)"]
    B["Hourly averages<br/>(avg, min, max)"]
    C["Daily averages<br/>(avg, min, max)"]
    D["Deleted"]

    A -- "after 90 days" --> B
    B -- "after 2 years" --> C
    C -- "after 5 years" --> D
Stage Time range Resolution TimescaleDB view
1 — Raw data 0–90 days Full measurement resolution sensor_readings
2 — Hourly 90 days–2 years 1 value per hour sensor_hourly
3 — Daily 2–5 years 1 value per day sensor_daily
Expiry After 5 years — Automatically deleted

Climate extreme events are retained permanently

Frost, heat waves, and storm events are archived permanently as ClimateEvent documents in ArangoDB — without personal reference. This is relevant for perennial plants (fruit trees, perennials) where climate history is needed over many years.

TimescaleDB Continuous Aggregates

Automatic downsampling is handled by TimescaleDB Continuous Aggregates and Retention Policies:

-- Stage 2: Hourly averages (automatically after 90 days)
CREATE MATERIALIZED VIEW sensor_hourly
  WITH (timescaledb.continuous) AS
  SELECT
    time_bucket('1 hour', timestamp) AS bucket,
    location_key,
    sensor_type,
    AVG(value)   AS avg_value,
    MIN(value)   AS min_value,
    MAX(value)   AS max_value,
    COUNT(*)     AS sample_count
  FROM sensor_readings
  GROUP BY bucket, location_key, sensor_type;

-- Stage 3: Daily averages (automatically after 2 years)
CREATE MATERIALIZED VIEW sensor_daily
  WITH (timescaledb.continuous) AS
  SELECT
    time_bucket('1 day', bucket) AS bucket,
    location_key,
    sensor_type,
    AVG(avg_value) AS avg_value,
    MIN(min_value) AS min_value,
    MAX(max_value) AS max_value,
    SUM(sample_count) AS sample_count
  FROM sensor_hourly
  GROUP BY time_bucket('1 day', bucket), location_key, sensor_type;

-- Retention policies (automatic deletion)
SELECT add_retention_policy('sensor_readings', INTERVAL '90 days');
SELECT add_retention_policy('sensor_hourly',   INTERVAL '2 years');
SELECT add_retention_policy('sensor_daily',    INTERVAL '5 years');

Deleting a sensor or a tenant

When a sensor or a whole tenant is deleted, Kamerplanter removes the hourly and daily averages (sensor_hourly, sensor_daily) together with the raw data — of any age, in the same deletion. The averages of other tenants and sensors are left untouched. A reading that arrives only after the deletion (for example from a device that is still sending) is rejected and does not start a new time series.


Statutory Minimum Retention Periods

These records must not be deleted before the statutory period has elapsed. For a GDPR erasure request (Art. 17), they are anonymized (user reference removed) but not deleted (Art. 17(3)(b)):

Ref Data Category Minimum Period Legal Basis
R-16 Harvest data (HarvestBatch, QualityAssessment, YieldMetric) 5 years CanG (German Cannabis Act)
R-17 Treatment applications (TreatmentApplication) 3 years PflSchG §11 (German Plant Protection Act)
R-18 Inspection records 3 years PflSchG §11 (German Plant Protection Act)

Deletion lock

Deleting an active harvest or treatment record would violate the CanG (German Cannabis Act) or PflSchG (German Plant Protection Act). Account deletion therefore does not delete these records; it only removes the personal reference. The record itself remains until the statutory period has elapsed.

When the period ends after a tenant deletion

The period counts from the date the record itself carries — harvest_date (harvest), applied_at (treatment), inspected_at (inspection) — and keeps running unchanged if the tenant or your account is deleted in the meantime. The deletion neither restarts nor shortens it.

Once it has ended, the daily task retention.purge_expired_legal_retention_rows (04:45 UTC) deletes the record for good: together with its quality assessments and yield metrics (for a harvest) and every link that still touches it — for treatments, the inherited pre-harvest-interval links to plants and runs as well.

Only records whose tenant no longer exists are deleted. The harvests, treatments and inspections of an existing garden are that garden's own records; this rule sets no maximum for them, and they stay.

A record without a readable date is never deleted — its age is not established. Exception: a harvest without a harvest date counts from its creation, because that is the date the system assumed as the harvest date when it was created.

First run after the update

The first run immediately deletes every retained record of a deleted tenant whose period has already ended — including records a tenant deletion left behind before the deletion inventory existed.

What an anonymized record looks like

The account reference is not set to null; it is replaced. Its new form depends on whether the erased account's records must stay linkable to each other for an audit:

Record Field holding the account reference Replaced by Also emptied
Harvest (HarvestBatch) harvested_by_key Tombstone hash anon_… harvester
Quality assessment (QualityAssessment) assessed_by_key Tombstone hash anon_… assessed_by
Treatment (TreatmentApplication) applied_by_key Tombstone hash anon_… applied_by
Inspection (Inspection) inspected_by_key Tombstone hash anon_… inspector
Task (Task) assigned_to_user_key Marker _anonymized —
Diary entry (PlantDiaryEntry) created_by, analysis_requested_by, analysis_claimed_by Marker _anonymized —
Task comment created_by Marker _anonymized —
Invitation you sent invited_by_user_key Marker _anonymized —
File in a garden (photo, import file) created_by Marker _anonymized —
Import job uploaded_by Marker _anonymized —
Weather-source setting of a site updated_by Marker _anonymized —
Manual actuator override created_by Marker _anonymized —
AI audit entry user_key Marker _anonymized —
Pest photo you promoted as an admin promoted_by Marker _anonymized —
Your personal garden, also with other members — fully deleted, see below —
Community garden or other garden you created (not a personal one) owner_user_key Marker _anonymized for a personal garden a late joiner keeps, also its name and short name, see below
Erasure audit (ErasureRequest) user_key Tombstone hash anon_… —
MCP audit entry of a service account service_account_key Tombstone hash anon_… —
  • The tombstone hash is anon_ followed by 16 hex characters. It is derived from the account key and the secret salt ERASURE_TOMBSTONE_SALT, cannot be reversed, and is always the same for the same account. The harvests, treatments and inspections of an erased account therefore stay linkable to each other for an audit without naming anyone.
  • The name fields (harvester, assessed_by, applied_by, inspector) are free text someone typed in when recording. A name is personal data on its own, so they are emptied in the same step.
  • Quality assessments are part of the harvest documentation and are now anonymized exactly like the harvest itself. They also appear in your data access report (GDPR Art. 15).
  • Yield metrics (YieldMetric) carry no account reference, so there is nothing to anonymize.

Older records without an account reference

Harvests, treatments, inspections and quality assessments carry the account-reference field only since a migration. Records written before it hold null there and only the typed-in name. The system does not guess an owner from free text, so account deletion does not reach these older records; their name field stays as it was entered.

What happens to your personal garden

When you register, Kamerplanter creates a personal garden for you. On account deletion it is always deleted with the account — even when other members are in it. It is fully deleted through the tenant-erasure inventory: sites, plants, planting runs, diary, tasks, tanks and everything else in it is gone afterward. Only harvest, quality, treatment and inspection records remain — as with any account deletion — under your tombstone hash, because the CanG (5 years) and PflSchG (3 years) require it; their free-text name fields are emptied. This garden deletion gets its own deletion record, and your erasure request states in the end what happened to the garden.

A personal garden can, like any garden, take further members (see Tenants & Gardens). For them:

  • Warned beforehand: as soon as you request the deletion, every other active member gets an email. It states the date of the final deletion so they can secure their data until then. The email names neither you nor your address nor the garden, because a personal garden is named after its owner.
  • What is guaranteed when the email does not arrive: Kamerplanter records on the deletion request whether the members were notified and retries an unsent message every night before the garden is deleted. A deletion request from before this rule, which nobody has been told about yet, gets the message in the next nightly run; the garden is then deleted no earlier than RETENTION_ERASURE_MEMBER_NOTICE_DAYS days after it (default 7). If the message cannot be delivered during that whole wait (for example because the operator has not set up mail), the deletion goes ahead anyway: your right to erasure does not depend on somebody else's mailbox. The operator sees the failure in the log and on the deletion request (members_notice_failures), with no address in either.
  • Deletion by an administrator: when a platform administrator deletes an account, it happens at once. The other members of the personal garden get an email saying the deletion is happening now; there is no grace period to secure anything. The administrator sees before confirming which gardens are affected and how many other members this hits.
  • Their content goes with it: everything the other members created in this garden is deleted too. Their own accounts and their memberships in other gardens are unaffected.
  • Preview before you confirm: the delete-account dialog shows, for each personal garden, that it is deleted and how many other members this affects.
  • Only exception — a late joiner: if someone newly joins between your request and the deletion run (invitations into the garden are revoked when you request the deletion, so this only happens by detours), the garden is kept for that person. Then only your owner reference is removed:
    • The owner reference is replaced with _anonymized.
    • The name and the short name become anonymized- followed by a string that cannot be traced back to the key. The short name stays unique, and no new garden can take it first: Kamerplanter never hands out short names that start with anonymized.
    • Who takes over the garden afterward is currently not defined by Kamerplanter.

This affects your own data irreversibly, too

There is no separate switch to keep only the personal garden while deleting your account: it is irreversibly gone with everything in it — sites, plants, diary, photos, tasks, tanks, including the other members' entries. Download your data export first (GDPR Art. 15/20) if you want to keep a copy of anything. If you want to run a garden together with others, create it as a garden of its own with several members instead of sharing your personal one.

If the deployment cannot delete your personal garden — for example because a sensor-reading store, the tombstone salt, or the reference-index/pest-image store is missing or misconfigured — it refuses the entire account deletion before anything changes, and retries automatically once the configuration is fixed (see below, "All deletion paths do the same").

A community garden you founded keeps its name, because the name belongs to the group. Only the owner reference is replaced. The owner reference grants no rights; rights come from the role in a membership. Only one thing is handed over: if you are the last person with the management right in an organization, the longest-serving remaining lead takes over management, and the members are notified. If no lead is left or you are the only member, the organization becomes orphaned: it is closed and deleted with all its data after the tenant-deletion grace period (RETENTION_TENANT_ERASURE_GRACE_DAYS, default 90 days); the remaining members and the operators are notified. The preview shows you both before you confirm the deletion. Both happen as soon as you request the deletion — not only at the final deletion up to 90 days later: your account is closed from the request on, and the organization must not stay without management for that long. The grace period of an orphaned organization starts with your request.

An AI tip you dismissed stays dismissed for the other members of your garden. Only the note that you dismissed it is replaced with _anonymized.

Accounts that were never confirmed, and that Kamerplanter removes once the period runs out, go through the same full deletion as any other account deletion — with the same erasure request as proof and the same automatic retry if a step fails.

What else is deleted

Besides the familiar categories, account deletion also deletes these records, which belong only to you and fall under no retention period:

  • your AI assistant conversations
  • your notifications and your notification settings
  • your calendar feeds: the feed link stops working after the deletion
  • your plant-disease diagnosis requests
  • your location assignments in gardens you were a member of
  • invitations you accepted (they name your email address)
  • the file entries of your own pest photos — the original file and its preview images (WebP, 128/512/1280 px) are already deleted at this point
  • your contributed reference-image vectors for plant recognition (curated reference images contributed by other users are unaffected)
  • your contributed pest-recognition vectors — regardless of whether your contribution was still promoted or had already been demoted at the time of deletion

The daily cleanup deactivates accounts that were never confirmed right away and deletes them the same way. It now also removes their membership and location assignments. If a step fails, the erasure request is left open and — as described further below — automatically retried with backoff instead of being silently dropped. The personal garden of such an account is handled the same way as for any other account deletion (see above, What happens to your personal garden).

What is deliberately left alone

Some fields are named "… by" but hold free text someone typed in when recording, such as "performed by" on watering and tank logs or "created by" on workflow templates. The system does not attribute such free text to an account and leaves it unchanged on deletion.

Logs of an erasure

The log lines of an erasure do not name your account key. They carry a salted reference instead, so the lines of one erasure can be linked to each other without naming anyone. They cannot be linked to the pseudonymised erasure audit, though.

For operators: the subject= field in log lines

Log lines carry a subject= field instead of an account key or email address. It holds a salted, purpose-separated reference (sub_ followed by 16 hex characters, an HMAC of the account key keyed with LOG_PSEUDONYM_SALT). The lines of one account stay correlatable with each other without naming anyone. The reference is deliberately not the tombstone hash (anon_…) that the erasure audit and the anonymised harvest and treatment records keep and that is still keyed with ERASURE_TOMBSTONE_SALT: someone holding only the logs cannot join them to those retained records. If the salt is missing or too short, the line carries the constant anon_unavailable instead — never the account key in the clear.

Registration and email events additionally log fields such as email_sha256 — a keyed digest of the email address (an HMAC with LOG_PSEUDONYM_SALT, 16 hex characters), never the address itself. A plain SHA-256 could be reversed with a list of addresses; the keyed digest cannot without the salt. Without a valid salt the field reads unavailable. Object-storage log lines (storage_put_object, storage_delete_object, and similar) mask the account segment of export-bundle keys: privacy/exports/<account key>/<export>.json becomes privacy/exports/<subject>/<export>.json. They replace the tenant key of an attachment (t/<tenant>/<category>/…) with the tenant reference ten_…; category, date, ULID and file extension stay for debugging. The same reference appears as tenant= on every log line that names a tenant — next to the subject reference or not, because two lines of one flow often share an entity key (an attachment id) and would join the two otherwise: the tenant key itself sits on the retained, pseudonymised rows and would join the pseudonym back to your tenant. A dashboard or alert that filters on tenant_key= has to filter on tenant= instead. In log lines the erasure-record key ter_<tenant> reads ter_ten_… — also where it only appears inside a text, such as the worker's succeeded in … line for the tenant-deletion task or a storage error that names t/<tenant>/…. Fields named for the actor (contributed_by, created_by, …) never hold the account key.

LOG_PSEUDONYM_SALT keys only these log references (and the requested_by_subject provenance field on erasure and tenant-erasure records) — separate from ERASURE_TOMBSTONE_SALT, which must never change. An operator may rotate the log salt (set the new value in both the backend and the Celery worker, restart both): log lines from before the rotation no longer correlate with later ones.

Error texts in these lines (error=) are cleaned the same way: the account key is replaced by the reference, and export-bundle paths are masked. Where an error text can contain a third party's address (a rejected email recipient), only the error type is logged (error_type=). Email addresses inside an error text become <email:…> digests, and URL query strings and fragments (which can hold coordinates or API keys) become ?<redacted>, and credentials embedded directly in a URL (scheme://user:password@…) are masked as well. The same holds for credentials in the path of a URL — the bot token of a Telegram address, the token of a Discord or Slack webhook, the device token of a push endpoint — including the lines the HTTP library writes itself on a connection error. An Apprise address as you typed it into the notification settings (slack://…, tgram://…, pover://…; for gotify://, matrix:// and ntfy:// only the server stays) is masked the same way if it ever lands in a text, and so is a signed token that consists of several dot-separated parts. The log of the optional apprise package itself is kept at WARNING, because it writes request payloads below that. When you subscribe to or unsubscribe from push notifications, the log names only the push service, not your device's address. An unexpected error (a traceback) goes through the same cleanup: an error message from the application's own domain logic appears in the log only as its error class and error code, any other exception cleaned as described above. An account key inside the text of a standard or library exception is something this cleanup cannot recognise. This applies to the application's structured log lines as well as to the tracebacks that uvicorn and the Celery worker write for an unhandled error, and to errors that never become a log line: a crash at startup, an error in a background thread. If an error tracker is connected, it receives the same cleaned texts; as the address of the request its event carries only the route pattern (/api/v1/t/{tenant_slug}/attachments/{key}/…), never the requested path with tenant slug and download token.

IP addresses appear in the application's log lines at most truncated the R-03 way (IPv4 last octet 0, IPv6 /48), as ip_prefix=. The same holds for lines a library writes about the client: the rate limiter's "ratelimit … exceeded" line carries the truncated address, not the full one. That now also applies to the access logs: uvicorn truncates the client address the same way and writes only the fixed route segments of the requested path (e.g. /api/v1/t/{}/plants/{}) — your tenant slug, your account key and a download token in the URL no longer appear there, nor does a query string. If the application runs behind the bundled nginx, the same holds for its own access log (kp_redacted): it names neither X-Forwarded-For nor the user agent or referrer, and a link such as /password-reset/<token> appears there only as /<spa-route>. nginx's error log, by contrast, cannot be redacted and is therefore kept to the narrow crit level.

How long your log pipeline (container runtime, Loki, json-file rotation) keeps the lines is your decision as operator — set a bounded retention and record it (NFR-011 §3.4).

To attribute a log line to an account, an operator must compute the reference with the same salt themselves — grepping for the account key does not work.

All deletion paths do the same

It makes no difference whether a platform admin deletes your account through user management, the daily cleanup removes a never-confirmed account, or you file an erasure request yourself: all three paths run the same erasure, with the same erasure request as proof. It cleans object storage (originals along with their preview images) and the recognition base (only your own reference-image and pest-image contributions — curated references stay), deletes your other records, anonymizes the ones under a retention obligation as described above, and removes your account last. The database part runs as one unit: it is either done completely or not at all.

The difference is timing: your own request is carried out by the daily run only once the 90 days have passed. A deletion by a platform admin or by the cleanup for never-confirmed accounts instead runs at once — your account is deactivated and all sessions ended first, then the erasure runs without waiting. In all three cases: afterward the request reads completed and carries the tombstone hash instead of your account key. If a run fails, the request stays open as partially_completed and is retried automatically with backoff until it succeeds (see below). While a request is open, you cannot file a second one; a second deletion attempt by a platform admin instead resumes the open request at once.

Attachments are deduplicated per tenant by the file's SHA-256 hash (issue #1770): if a second member uploads exactly the same file (or you do, in another category), a record of its own is created, but the bytes are stored once and referenced by both records. Storage cleanup (Phase 0) therefore only deletes the file once no other record still points to it; if a record belonging to another member still points to it, the object is kept. The erasure request (erasure_requests) counts both: storage_objects_removed (objects actually deleted) and storage_objects_retained_shared (objects kept because another member's record still holds them). The same numbers are logged by the retention.erasure.storage_hard_delete line as deleted and retained_shared, per scope and tenant. If the other member deletes their record while your erasure is running, no one holds the object once the ArangoDB step has run, so the erasure asks again afterwards and removes it. That is counted as storage_objects_released, stored with the completed status; a failure there is logged as retention.erasure.shared_object_release_failed.

For operators: retries and log levels

Every failed attempt is counted on the request (attempt_count, last_attempt_at), and the next attempt waits 1, 2, 4 and then at most 7 days (next_attempt_at). The request stays selected; until then the daily run skips it with retention.erasure.deferred (info level). The origin field (self_service, platform_admin, or unverified_cleanup) records who triggered the request; attempt counting, backoff, and log levels behave the same for all three. The object-storage and reference-index cleanup runs only once per request: once it has finished (pre_arango_completed_at), a later attempt repeats only the database part.

retention.erasure.failed and retention.erasure.steps_unreached appear at error level only on the first failure and on the fifth attempt (escalated=true), at info level in between. Each run reports the number of open, failing requests as open_failing in the retention.execute_scheduled_erasures.completed event.

A missing ERASURE_TOMBSTONE_SALT is a configuration error: every run writes exactly one retention.execute_scheduled_erasures.not_configured line at error level, spends no attempt and touches no data. Once it is fixed, all open requests run on the next daily run.

A missing INFERENCE_SERVICE_ENABLED/INFERENCE_SERVICE_URL on the celery-worker while reference-image contributions already exist on the index is a configuration error too (issue #1753) — but it only holds requests that have not yet passed Phase 0.5 (reference-index cleanup). Those requests stay partially_completed with an operator-facing message, spending no attempt; the run also writes one retention.execute_scheduled_erasures.reference_index_not_configured line at error level. See Setting Up Plant Identification for the configuration.

The same check applies to contributed pest-recognition vectors (issue #1759): if neither PEST_DETECTION_ENABLED nor INFERENCE_SERVICE_ENABLED is set on the celery-worker while a pest-photo contribution was ever promoted into the recognition base, the worker holds the affected requests the same way, with the same log line. A tenant deletion is refused with HTTP 503 in this case instead of leaving vectors behind; if the inference-service is merely unreachable at the moment, it instead responds with HTTP 502 and can be retried.

Since this change, the database part of an account deletion is preceded by the tenant-erasure inventory for every personal garden of the person. If the same configuration a tenant deletion needs is missing (sensor-reading store, ERASURE_TOMBSTONE_SALT, reference-index or pest-image store), the daily run holds the request the same way, spending no attempt; an immediate deletion by a platform admin instead responds with HTTP 503. The erasure request records the outcome per personal garden (erased, retained with a reason, or already absent) and reaches completed only once this step has run.


Tenant Deletion

When a platform admin or a member with the Management extra permission deletes a tenant, the system follows its own process, similarly shaped to account erasure — the difference is that the entire domain data of a garden is affected, not just the records of a single account.

Order

  1. Upfront checks: The special, technical platform tenant cannot be deleted (403). If the instance is not correctly configured for tenant deletion (missing ERASURE_TOMBSTONE_SALT, a misconfigured reference index or pest-image store), the system refuses the deletion with 503 and changes nothing.
  2. Proof, lock and grace period: A deletion record is created (it names neither the tenant's name, slug nor owner). The tenant enters the state "Deletion scheduled" (pending_deletion) and is closed at once for every member, API key and MCP client. Every member receives an email with the deletion date: until then they can save their own data through the personal data export. The actual deletion starts only after RETENTION_TENANT_ERASURE_GRACE_DAYS days (default 90). Until then a member with the Lead role and Management — or a platform admin in the admin area — can cancel the deletion with their own password; the tenant is back in the state it had before the deletion afterwards (a suspended one stays suspended), with every membership unchanged. Once the grace period has ended, the memberships are deactivated and the state changes to "Being deleted" — from then on nothing can be taken back.
  3. External cleanup: Contributed recognition vectors and pest prototypes are removed, followed by the tenant's sensor readings in the time-series database and all binary data under the object-storage prefix t/{tenant_key}/.
  4. One database transaction: Every domain record the tenant holds is deleted — sites, plants, planting runs, diary entries, tasks, tanks, sensors, feeding and watering logs, notifications, calendar feeds, its own master-data entries, memberships, invitations, location assignments and API keys restricted to the tenant — together with every link to a deleted record, and finally the tenant record itself.

What is retained

As with account erasure, the statutory minimum retention period for harvest and treatment documentation applies (R-16 through R-18, see above): harvest batches, quality assessments, treatments and inspections remain but are pseudonymized — each affected member's account reference is replaced by their tombstone hash, and name fields are emptied. The AI call log and the MCP call log are likewise retained until their own retention window expires, as is the deletion record itself — it is the proof of the deletion and drives its retry.

The retained harvest, treatment and inspection data is deleted once its period has ended (see When the period ends after a tenant deletion). The deletion record is kept as long as the tenant's longest-retained record, but at most 5 years after the deletion completed (R-06a); the daily task retention.purge_expired_tenant_erasure_records (04:50 UTC) deletes it afterwards. If the deletion retained no harvest, treatment or inspection data, the record is kept for the full 5 years.

Completeness is measured, not assumed

After the transaction, the system counts whether any record — even in a collection that is not part of the declared inventory — still references the tenant. If so, or if a step failed (e.g. the inference service was unreachable, 502), the deletion record stays partially_completed. The daily retry run picks it up at 04:30 UTC — after the account erasures — and retries every open tenant deletion with the same backoff as account erasure: 1, 2, 4 and then at most 7 days. A second deletion attempt for the same tenant while one is already running responds with 409 instead of starting another attempt.

What is not affected

The members' own accounts are unaffected — they keep their account and their memberships in other tenants. A member's personal tenant is not touched by the deletion of an other tenant. Deleting your own account, however, runs your personal tenant through this exact tenant-erasure inventory — fully, even when other members use it; only a late joiner keeps it alive (see above, What happens to your personal garden).


Celery Enforcement: Automated Execution

Each rule runs as its own Celery beat task on a schedule that matches its period. There is no central task that orchestrates every rule in one shared run, and none is planned: a single daily schedule would even be wrong for the hour-granular periods (R-05, R-07) — a run at 02:00 UTC would overshoot a 24-hour period by up to a day, keeping the record longer than declared.

Rule Celery task Schedule (UTC) Period setting
R-01 retention.execute_scheduled_erasures daily, 04:00 RETENTION_SOFT_DELETE_RETENTION_DAYS
R-01b app.tasks.tenant_tasks.resume_tenant_erasures daily, 04:30 RETENTION_TENANT_ERASURE_GRACE_DAYS (fixed when the tenant deletion is accepted)
R-02 app.tasks.auth_tasks.cleanup_unverified_accounts daily, 03:10 RETENTION_UNVERIFIED_ACCOUNT_DAYS
R-03 app.tasks.auth_tasks.anonymize_old_ips daily, 03:20 RETENTION_IP_ANONYMIZATION_DAYS
R-05 retention.expire_data_exports hourly, minute 20 RETENTION_EXPORT_FILE_RETENTION_HOURS
R-06 retention.purge_expired_erasure_records daily, 04:30 RETENTION_ERASURE_AUDIT_RETENTION_YEARS
R-07 retention.expire_email_change_requests hourly, minute 15 RETENTION_EMAIL_CHANGE_RETENTION_HOURS
R-07a retention.expire_email_change_requests (same run) hourly, minute 15 RETENTION_EMAIL_CHANGE_REVERT_DAYS
R-11 app.tasks.auth_tasks.cleanup_expired_tokens hourly, minute 10 Expiry of the token
R-12 app.tasks.tenant_tasks.cleanup_expired_invitations daily, 02:00 Expiry of the invitation (status expired), then RETENTION_INVITATION_RETENTION_DAYS until the hard-delete
R-16, R-17, R-18 retention.purge_expired_legal_retention_rows daily, 04:45 RETENTION_HARVEST_DATA_MIN_RETENTION_YEARS, RETENTION_TREATMENT_MIN_RETENTION_YEARS, RETENTION_INSPECTION_MIN_RETENTION_YEARS
R-06a retention.purge_expired_tenant_erasure_records daily, 04:50 fixed 5 years (cap), or the end of the tenant's retained data
R-38 security_audit.purge_expired daily, 02:55 fixed 730 days after the row was written

Each task logs its run in structured form (structlog) under its own event name with counters, for example:

  • retention.execute_scheduled_erasures.completed (processed)
  • cleanup_unverified_accounts (removed, failed, deferred, skipped, blocked)
  • anonymize_old_ips (anonymized)
  • retention.expire_data_exports.completed (expired)
  • retention.purge_expired_erasure_records.completed (purged, held_without_tombstone)
  • retention.expire_email_change_requests.completed (expired, revert_windows_closed)
  • cleanup_expired_tokens (removed)
  • expired_invitations_cleaned (count)
  • retention.purge_expired_legal_retention_rows.completed (purged per rule: rows, children, edges)
  • retention.purge_expired_tenant_erasure_records.completed (purged)

All retention tasks run on the clock (a cron expression), not as a fixed interval from the start of the beat process: restarting the beat pod does not move a run, so the "at the latest" statements of the privacy policy (period plus one beat interval) also hold across restarts.

Where an age filter cannot judge a record because its timestamp is missing or unreadable, it does not delete it but counts it and reports the counter as held_undated in the run event: in the account cleanup task (R-02), the call logs, the orphaned task photos, the consent records (R-04, purge_expired_consent_records), the confirmed e-mail changes (R-07b, expire_email_change_requests) and the glossary cache (glossary_cleanup_cache).

There is no single event line that summarizes every rule.

Metrics

There are currently no Prometheus metrics for the retention runs (internal reference: issue #1800). The only observability source is the structured log lines above, for example via kubectl logs -l app=celery-beat.


Configuration via Environment Variables

Every period is read from exactly one setting (RetentionService); its constructor checks the same floor again:

Setting Rule Default Minimum Maximum Older name (still valid)
RETENTION_SOFT_DELETE_RETENTION_DAYS R-01 90 1 90 PRIVACY_HARD_DELETE_AFTER_DAYS
RETENTION_UNVERIFIED_ACCOUNT_DAYS R-02 7 1 7 —
RETENTION_UNVERIFIED_LOCAL_REAP_ENABLED R-02 false (switch) — — —
RETENTION_IP_ANONYMIZATION_DAYS R-03 7 1 7 —
RETENTION_EXPORT_FILE_RETENTION_HOURS R-05 72 1 72 PRIVACY_EXPORT_RETENTION_HOURS
RETENTION_ERASURE_AUDIT_RETENTION_YEARS R-06 3 1 3 —
RETENTION_EMAIL_CHANGE_RETENTION_HOURS R-07 24 1 24 PRIVACY_EMAIL_CHANGE_TTL_HOURS
RETENTION_EMAIL_CHANGE_REVERT_DAYS R-07a 7 1 7 —
RETENTION_CONSENT_RETENTION_YEARS R-04 3 1 3 —
RETENTION_CONSENT_IP_ANONYMIZATION_DAYS R-04a 7 1 7 —
RETENTION_INVITATION_RETENTION_DAYS R-12 30 1 30 —
RETENTION_ERASURE_MEMBER_NOTICE_DAYS R-01a 7 1 7 —
RETENTION_TENANT_ERASURE_GRACE_DAYS R-01b 90 0 90 —
RETENTION_HARVEST_DATA_MIN_RETENTION_YEARS R-16 5 5 (CanG) — —
RETENTION_TREATMENT_MIN_RETENTION_YEARS R-17 3 3 (PflSchG §11) — —
RETENTION_INSPECTION_MIN_RETENTION_YEARS R-18 3 3 (PflSchG §11) — —

If both names of a row are set, the RETENTION_* name wins. The older names were documented before this change but had no effect — the code used fixed values; they now actually apply.

Not yet implemented

For the following rules there is no effective environment variable — no code reads them (internal reference: issue #1800):

  • R-14 (sensor data): RETENTION_SENSOR_* — the periods (90 days / 2 years / 5 years) are fixed values in the TimescaleDB migration and cannot be changed via an environment variable.
  • R-15 (actor logs): there is no actor-log store in the code, so there is no RETENTION_ACTOR_LOG_* setting either.

Interaction with GDPR Erasure Requests

When a data subject submits an erasure request under GDPR Art. 17, the following procedure applies:

flowchart TD
    A["Erasure request Art. 17"] --> B{"Statutory retention<br/>obligation?"}
    B -- "No" --> C["Immediately: Hard-delete<br/>or Soft-delete + 90d"]
    B -- "Yes (CanG, PflSchG)" --> D["Anonymization:<br/>account key → anon_… hash<br/>name fields emptied<br/>Record remains"]
    D --> E["After period elapses:<br/>Automatic hard-delete<br/>via Celery task"]
Data category Procedure
User account Soft-delete → hard-delete after 90 days
Consent records Delete immediately (on revocation)
Export files Delete immediately
Harvest data, quality assessments, treatments, inspections Anonymize (tombstone hash anon_…, name fields emptied), do not delete (Art. 17(3))
Tasks, task comments, diary entries, files, import jobs, settings and overrides in a (possibly shared) garden Replace the account reference with _anonymized, content remains
Your personal garden, also with other members Fully delete (tenant-erasure inventory); harvest/treatment/inspection records pseudonymized as above; the other members are notified at the request
Community garden or other garden you created (not a personal one) Replace the owner reference; for a personal garden a late joiner keeps, also its name and short name
Erasure audit Replace the account reference with the tombstone hash, keep for 1 year
Memberships, location assignments, sessions, API keys, consents, export requests, favorites, pest detections, own pest photos, AI conversations, notifications, calendar feeds, diagnosis requests, accepted invitations Delete

Sweep: Orphaned Pest-Recognition Prototypes

Before issue #1766, deleting a pest-photo contribution left its recognition prototype behind in the inference service's reference index (pest_embeddings, source = user_contributed). Such rows name no user — the Art. 17 account erasure finds prototypes only through a user's contribution documents and therefore cannot reach them; only a tenant deletion removed them along the way.

The Celery task pest_image.sweep_orphaned_prototypes (beat entry retention-sweep-orphaned-pest-prototypes-daily, daily at 04:30 UTC, after the scheduled erasures at 04:00 UTC) closes this gap:

  1. It pages through the contribution keys the index holds (POST /pest/reference/contributions/keys on the inference service).
  2. It checks in ArangoDB which of those keys still have a pest_image_contributions document.
  3. It deletes the prototypes of the others — active and deactivated rows alike — in batches of 500 through the same POST /pest/reference/contributions/erase endpoint the Art. 17 erasure uses.

Idempotent: a second run finds no orphan left and removes nothing. Fails loud: if the inference service is unreachable, the task fails (log event pest_prototype_orphan_sweep_failed), nothing is recorded, and the next beat run retries. A celery-worker missing both PEST_DETECTION_ENABLED/INFERENCE_SERVICE_ENABLED, once a pest-photo contribution has ever been promoted into the recognition base, refuses the run just like the scheduled erasure does (issue #1759). Without those flags and without that promotion marker the task reports skipped (log event pest_prototype_orphan_sweep_skipped) and records no run — it never saw the index.

Each run's counts are recorded in the system_settings singleton document under pest_prototype_orphan_sweep (first_run_at, last_run_at, last_examined, last_orphaned, last_removed, total_removed, binding) and as the log event pest_prototype_orphan_sweep_completed (examined/orphaned/removed/binding) — both name counts only, never a key.

Deactivated (demoted) prototypes whose contribution document still exists are left untouched by the sweep (curation).

How do I trigger the sweep immediately instead of waiting for 04:30 UTC?

kubectl exec deploy/<release>-celery-worker -- \
  celery -A app.tasks call pest_image.sweep_orphaned_prototypes
The worker needs the same flags as the scheduled erasure: PEST_DETECTION_ENABLED or INFERENCE_SERVICE_ENABLED plus INFERENCE_SERVICE_URL, and INTERNAL_SERVICE_TOKEN. Details: Setting Up Plant Identification.


Migration v0062: Splitting Shared Attachments Into Owned Records

Before issue #1770, a second upload of the same bytes within a tenant got back the first uploader's record — even across categories, for example between a pest-image contribution and a documentary photo (diary, task, inspection, harvest/storage observation, plant gallery). If the first uploader deleted their account, the second member's record was hard-deleted or anonymized along with it; if the second uploader deleted their account, the erasure never reached their contribution because no record belonged to them.

The migration v0062_split_shared_attachment_ownership brings existing data as close to the new shape as the data allows to reconstruct:

  1. The unique index on attachments.storage_key is dropped. Several uploaders now share one stored file; a non-unique index replaces it.
  2. Every pest-image contribution gets its own pest_reference record, unless the record it pointed to was already its own: a new record with the deterministic key pic-<contribution key> is created over the same stored file, named after the contributor, and the contribution is repointed to it. The original uploader's filename is not carried over.
  3. A pest_reference record that a documentation carrier references (diary entry, task, inspection, harvest/storage observation, plant gallery) is recategorized into a record of that category — otherwise it would be hard-deleted when the pest-image owner is erased, instead of being anonymized and retained like every other documentary photo.

Run it like any migration via python -m app.migrations upgrade; --dry-run computes every change and logs it (split_shared_attachment_ownership_dry_run, with the same counters) without writing anything. An interrupted run leaves no inconsistent state: the split key is deterministic and written with an UPSERT, so a re-run picks up exactly where it left off.

Not reversible

Rolling back would re-create exactly the shared ownership this migration removes.

What the migration cannot reconstruct

Two identical documentary photos (e.g. two diary uploads of the same file by two members) left only one record and no trace of the second uploader before #1770 — the carrying records (diary, task, …) mostly have no per-photo owner field. Such records are left unchanged; their file is never hard-deleted, because the documentation rule anonymizes and retains it — so nothing is lost. The second uploader's Art. 15 data export, however, will not list a record they never had.


Migration v0066: Resetting Legacy Stamps on Seed Rows

When tenants were introduced, the old migration v0004 assigned every row without an owner to a "default tenant" — including the bundled seed data. Since tenant deletion (#1769) removes the rows of its tenant, deleting that tenant would take seed data with it that is meant for everyone. On a legacy volume this concerns fertilizers, nutrient plans with their phase entries, workflow templates and task templates.

The migration v0066_reset_legacy_seed_tenant_stamps resets those rows to global (tenant_key == ""), but only when two things hold: the bundled seed file names the row (same key as the seed loader: product and brand, plan or workflow name, workflow name and task name), and the owner key is proven to be the v0004 stamp (a phase entry or task template carries it under a global parent, or it is on more than half of the expected seed fertilizers and workflows). A plan a tenant created itself and named like a seed stays that tenant's: if a name has a second global or identically stamped row, or is a clone, nothing is reset. Rows the seed file does not name are left alone.

What the measurement showed

Before #1957 the seed loaders reset fertilizers, plans, workflows and task templates to global on every start. What was left behind were the phase entries of the seed plans — they kept the stamp, and a tenant deletion would have stripped every seed plan of its phases. The migration closes exactly that gap and covers the other collections in case a seed run failed. The nutrient-plan loaders no longer reset a plan by name alone: they match only global plans, so a stamped plan this migration does not prove stays the tenant's and the loader creates the global seed plan next to it.

Check first: on a backup only

Never count the affected rows against the production database; use a restored backup or a dev cluster (arangosh or web UI, database kamerplanter):

RETURN {
  entries_under_global_plan: LENGTH(
    FOR d IN nutrient_plan_phase_entries
      FILTER d.tenant_key != null AND d.tenant_key != ""
      LET p = DOCUMENT(CONCAT("nutrient_plans/", d.plan_key))
      FILTER p != null AND (p.tenant_key == null OR p.tenant_key == "")
      RETURN 1),
  nutrient_plans_stamped_by_tenant: (FOR d IN nutrient_plans
    FILTER d.tenant_key != null AND d.tenant_key != ""
    COLLECT t = d.tenant_key WITH COUNT INTO n RETURN {tenant_key: t, rows: n})
}

entries_under_global_plan above 0 means the migration changes something. The same query for fertilizers, workflow_templates and task_templates is OPERATOR_COUNT_QUERY in the migration. The key that dominates these counts is the former default tenant.

Run it like any migration via python -m app.migrations upgrade; --dry-run counts the changes (reset_legacy_seed_tenant_stamps_dry_run) without writing anything. A second run changes nothing.

Not reversible

The old default-tenant key on the seed rows cannot be restored afterwards. Back up the database before upgrading.


Migration v0067: Cleaning Up Legacy References From the #1871 Holes

Before #1871/#1876 some routes let a tenant store a reference to another tenant's data. The fixes stop new cases; migration v0067_clean_legacy_foreign_references cleans the existing data:

  1. Shared workflow templates of a private species (generated, tenant_key == "") are assigned to the tenant that owns the species. Nothing is deleted.
  2. Slots whose location field and has_slot edge disagree: the edge follows the field, but only when both locations belong to the same tenant.
  3. Foreign edges (equipment_at, assigned_to_location, log_slot, feeds_from, entry_for_species) are deleted when both tenants are known and differ. Global targets and species granted to the tenant stay.

Also left unchanged, on purpose: templates of species granted to a tenant or when the owner already holds a generated plan, log_slot edges on slots whose field and edge name different tenants, and entry_for_species edges younger than the merge of the #1871 fixes (2026-09-26), where a grant that was later withdrawn can be the cause.

Anything that cannot be classified with certainty is left unchanged and shows up as …__left_unclassified in the counts. The run logs counts only and is idempotent. Count first with python -m app.migrations upgrade --dry-run.

Not reversible

A deleted edge and the previous owner of a re-assigned template cannot be restored. Take a backup (arangodump of workflow_templates, has_slot, equipment_at, assigned_to_location, log_slot, feeds_from, entry_for_species) before running it.

What stays

The source document keeps its field (for example the equipment's location_key); only the edge is removed.


Legacy data: cleaning up Home Assistant readings from before the fix

Until the Home Assistant poll was fixed, Kamerplanter stored every polled reading with an empty tenant key. Deleting a sensor or a tenant does not find such rows, so they stay until the 90-day, 2-year and 5-year tiers expire them — even when their sensor is long gone. New rows of this kind are no longer created. The legacy rows are not touched automatically, because a delete cannot be undone: you run the cleanup yourself, in two steps.

The command sorts every series with an empty tenant key into a class — in the raw data and in the hourly and daily averages:

Class Meaning What the command does
orphan The sensor document no longer exists. Deletes, only after confirmation.
live The sensor still exists. Counts, touches nothing.
unattributable The sensor key is empty or cannot be a key. Counts, touches nothing.

1. Dry run (the default, writes nothing):

kubectl exec -n kamerplanter deploy/<release>-backend -- \
  python -m app.migrations.purge_orphan_ha_readings

The output lists, per class, the series and the rows per table (raw, hourly, daily); for how many live series the tenant could be derived from the parent site, tank or location (a number only — those rows are not rewritten); and last the total number of orphan rows. The same number is in the prepared confirmation command.

2. Delete, confirmed with exactly that number:

kubectl exec -n kamerplanter deploy/<release>-backend -- \
  python -m app.migrations.purge_orphan_ha_readings --confirm-delete-orphans <number>
  • If the number differs from what the command measures now (rows arrived, an earlier run deleted part of them), it refuses (exit code 4) and deletes nothing. Repeat the dry run and confirm the new number.
  • Before each series the command checks again that its sensor is really missing, deletes in seven-day windows with one commit each, and measures again afterwards.
  • Stopping in the middle is harmless: windows already deleted stay deleted, the dry run shows the rest.
  • It never touches rows with a non-empty tenant key (not even for the same sensor); those belong to the tenant or sensor deletion.
  • The log carries counts only, no sensor or tenant keys.
  • The output also says how many sensor documents the command found in ArangoDB. If it shows 0 while orphan rows are reported, the wrong database is usually configured (ARANGODB_DATABASE): do not confirm. The command refuses a database that was never initialised.

Without TimescaleDB (light mode) the command reports "not applicable" and exits 0; when TimescaleDB is configured but unreachable it exits 1 and changes nothing.

Not reversible

Deleted readings cannot be restored. Back up the TimescaleDB database (pg_dump) before the run.

If rows remain after the run

An aggregate refresh that was running on an older snapshot can create the hourly buckets of the last three hours, or the daily buckets of the last three days, once more. The command reports this in the measurement after the delete; repeat the dry run once and confirm the new number.


Frequently Asked Questions

Can I extend the 90-day soft-delete period?

Yes, via RETENTION_SOFT_DELETE_RETENTION_DAYS (minimum 1 day). The period is not an undo window: an erasure request cannot be withdrawn, the account is closed from the request on (without a password), and it is deleted after the period in any case. The period gives the other members of a personal garden time to save their data.

What happens to an organization when its last management holder requests their account's deletion?

At the request, the longest-serving remaining lead takes over management. If no lead is left, the organization becomes orphaned and is deleted after RETENTION_TENANT_ERASURE_GRACE_DAYS; this cannot be cancelled. Members and platform admins are notified by email. There is no emergency admin and no detect_orphaned_tenants task any more.

How can I verify that the retention tasks are running correctly?

There is no Prometheus metric for this. Look in the structured logs (structlog) instead, for the event of the task in question, for example retention.execute_scheduled_erasures.completed or anonymize_old_ips (see above). In the Kubernetes cluster: kubectl logs -l app=celery-beat.

Are sensor data deleted when an account is deleted?

Sensor data in TimescaleDB has no direct user reference — it is assigned to a location (location_key), not an account. Your personal garden is fully deleted with your account (see What happens to your personal garden), so its sensor data is deleted with it — like with any tenant deletion. Only when a late joiner keeps the garden by exception is its sensor data retained, subject to the time-based retention policies only.

See also