Account Deletion and Content Disposition

What actually happens when a user deletes their account: which rows are anonymized, which are hard-deleted, and how a published Phenom Event’s disposition is decided by the creator’s own Section 6 declaration. Covers the three distinct deletion modes in the platform and why they differ.

The platform has three different deletion behaviours and they are frequently confused with one another. This page states which applies where, and why.

Reflects live state as of 2026-08-19.

The three deletion modes

Mode Applies to Behaviour Reversible
Anonymize in place the users row PII columns nulled, NOT NULL jsonb emptied, deleted_at stamped. The row survives. No
Soft delete moderation actions on a Phenom Event phenom.status = 'deactivated'. The row and its media survive. Yes
Hard delete Phenom Events the creator marked under EULA Section 6 DELETE FROM phenom plus permanent erasure of the media. No

Hard delete is a sanctioned option, not an accident. It exists specifically to satisfy the promise in EULA Section 8, and it is the only path in the platform that destroys a published Phenom Event.

Why the users row is never hard-deleted

chat_messages (user_id, deleted_by), chat_members, chat_bans, phenom_report_notes.author_id, list_shares, item_shares and teams.created_by all hold plain NO ACTION foreign keys to users(id). A hard delete is therefore either blocked outright or would destroy other people’s content. The row is anonymized in place instead: nulling display_name and email also releases their unique indexes, so the handle and address become reusable.

notification_settings, profile_visibility and urls are NOT NULL, so they reset to {} rather than NULL. For an anonymized row those mean the same thing, and nulling profile_visibility once made Hasura reject the entire mutation and broke account deletion for every user.

This is unchanged and remains correct. “Never hard-delete a users row” is still the rule.

Why a Phenom Event may be hard-deleted

The users reasoning does not transfer to phenom. Every foreign key pointing at phenom(id) is ON DELETE CASCADEphenom_coords, phenom_media, phenom_categories, phenom_shoots, phenom_behavior_types, phenom_sensor_data, phenom_likes, phenom_comments, phenom_reports, phenom_report_notes and phenom_moderation_assignments. Nothing blocks the delete, and dependent rows go with it.

The decision of whether to delete is not ours. EULA Section 6 asks the creator one question immediately before publish:

This recording includes my voice or shows my image

The answer is stored on the event as contains_creator_voice_or_image, with creator_presence_declared_at recording when the question was actually answered — a NULL there means the user was never asked, which is deliberately distinct from answering no. We never infer the answer; Section 6 commits us to relying entirely on what the creator tells us.

EULA Section 8 then binds the disposition to that answer:

  • We delete every published Phenom Event that you marked as containing your voice or your image, together with its associated media and records. This is permanent.
  • Published Phenom Events that you did not mark are not deleted. We instead sever them from you permanently.

The reasoning Section 8 gives for the asymmetry: a recording of a light in the sky with the creator silent and off camera does not identify them once severed from the account. A recording carrying their voice or face does, and no amount of database housekeeping changes that.

Moderation still uses soft delete

Nothing here changes the moderation posture. Staff actions in the N.E.S.T. console set phenom.status = 'deactivated' and never delete the row, so a moderation decision stays reversible and auditable. The nest-api regression test that asserts the moderation path issues no delete_phenom remains correct and should stay.

The distinction is by actor and reason, not by capability:

  • Staff acting on content → soft delete, always reversible.
  • A creator erasing their own declared presence → hard delete, permanent, and only ever scoped to that creator’s own marked events.

Media erasure

The media bucket has versioning enabled and no noncurrent-version expiration lifecycle. A plain DeleteObject therefore writes a delete marker and leaves every prior version fully retrievable by anyone who can name a VersionId. Section 8 says “permanent”, so erasure enumerates media/<phenomId>/ with ListObjectVersions and removes every version and delete marker explicitly.

Sweeping by prefix rather than by phenom_media.uri is deliberate: the cascade destroys the URI pointers, so they cannot be the source of truth for cleanup, and the prefix also catches thumbnails and any object no row references.

Storage failures are logged with the phenom ids and do not abort the deletion — an account that cannot be deleted because a bucket is unhealthy is a worse outcome than an orphaned object, and orphans remain sweepable by prefix. A database failure does abort: if the rows survive, the promise is unkept and the caller must not be told the account is gone.

Order of operations

  1. App — if the account is Apple-federated, re-present the Apple sheet for a fresh authorization code. Best-effort; never blocks.
  2. AppdeleteAccount Hasura Action, authenticated with the current Cognito JWT. It must run while the identity still exists.
  3. Handler — revoke the Apple grant. Best-effort; an Apple outage must not make accounts undeletable.
  4. Handler — erase media for, then hard-delete, the caller’s Section 6-marked events. Runs before the anonymize so a failure leaves the account intact rather than producing an anonymized profile whose marked recordings are still live.
  5. Handler — anonymize the users row and hard-delete device_push_tokens, push_subscriptions, user_notifications.
  6. App — Amplify deleteUser() deletes the Cognito identity, then local drafts and session state are cleared.

The handler returns { success: true } and nothing else. Row counts are logged server-side and deliberately never returned to the client — they leak nothing useful to the app, and keeping them out means the disposition logic can change without an API change or an app release.

Known gaps

  • Severance of unmarked events is incomplete. Anonymizing empties the attribution, but phenom.user_id still points at the anonymized row, so the link is not broken as Section 8 describes. phenom.user_id is ON DELETE SET NULL, but that never fires because the row is never deleted. Fixing this needs a decision — null the foreign key, or repoint to a sentinel row — and touches every read path that assumes user_id is present.
  • Six tables reference a Phenom Event with no foreign key and are orphaned by a hard delete: transcriptions, moderation_audit_log, chat_messages.phenom_id, list_items, item_shares, scene_iterations. transcriptions is the significant one — it holds the VTT and full text of the media, keyed by media URL hash with no link back to the event. For a recording marked as containing the creator’s voice, that table holds that voice as text, and it survives the delete.
  • In-app copy understates the outcome. The deletion confirmation says published events are “no longer linked to you”, which describes severance only and makes no Section 8 deletion promise at all.